From 8cf1800ee961813b7f88f7de422c20e100ab58b4 Mon Sep 17 00:00:00 2001 From: VACInc <3279061+VACInc@users.noreply.github.com> Date: Sun, 3 May 2026 12:54:14 -0400 Subject: [PATCH] fix codex thread continuity --- CHANGELOG.md | 1 + .../codex/src/app-server/run-attempt.test.ts | 120 ++++++++++++++++++ .../codex/src/app-server/run-attempt.ts | 33 +++++ .../codex/src/app-server/thread-lifecycle.ts | 105 +++++++++++---- 4 files changed, 233 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79fea17b3cb..098b1dfd3b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -326,6 +326,7 @@ Docs: https://docs.openclaw.ai - CLI/message: exit cleanly with a nonzero status when message-command plugin registry loading fails before dispatch, preventing `openclaw-message` children from staying alive after plugin load errors. Fixes #76168. - Plugins/config: report configured plugins that are present but blocked by path-safety checks as blocked instead of stale `plugin not found` entries, and deduplicate repeated blocked-candidate warnings during discovery. Fixes #76144. Thanks @mayank6136. - Gateway/update: recover an installed-but-unloaded macOS LaunchAgent after package updates, rerun Gateway health/version/channel readiness checks, and print restart, reinstall, and rollback guidance before reporting update failure. (#76790) Thanks @jonathanlindsay. +- Codex/runtime: preserve native Codex thread bindings across dynamic-tool reorder and no-tool maintenance turns, and project mirrored history when a legacy Codex run must start without a native binding, preventing follow-up requests from losing conversation context. Thanks @VACInc. - CLI/plugins: explain when a missing plugin command alias belongs to a bundled plugin that is disabled by default, including the `openclaw plugins enable ` repair command. (#76835) - Gateway/Bonjour: auto-start LAN multicast discovery only on macOS hosts while preserving explicit `openclaw plugins enable bonjour` startup elsewhere, so Linux servers and containers that do not need LAN discovery avoid default mDNS probing and watchdog churn. Refs #74209. - Gateway/macOS: stop `doctor` and LaunchAgent recovery from running `launchctl kickstart -k` after a fresh bootstrap, avoiding an immediate SIGTERM of the just-started gateway while still nudging already-loaded launchd jobs. Fixes #76261. Thanks @solosage1. diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 36b3d026f15..bf33227e364 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -146,6 +146,14 @@ function assistantMessage(text: string, timestamp: number) { }; } +function userMessage(text: string, timestamp: number) { + return { + role: "user" as const, + content: [{ type: "text" as const, text }], + timestamp, + }; +} + function createAppServerHarness( requestImpl: (method: string, params: unknown) => Promise, options: { @@ -752,6 +760,34 @@ describe("runCodexAppServerAttempt", () => { ); }); + it("projects mirrored history when starting Codex without a native thread binding", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const sessionManager = SessionManager.open(sessionFile); + sessionManager.appendMessage(userMessage("we are fixing the Opik default project", Date.now())); + sessionManager.appendMessage(assistantMessage("Opik default project context", Date.now() + 1)); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.prompt = "make the default webpage openclaw"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + await new Promise((resolve) => setImmediate(resolve)); + await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" }); + await run; + + const turnStart = harness.requests.find((request) => request.method === "turn/start"); + const inputText = + (turnStart?.params as { input?: Array<{ text?: string }> } | undefined)?.input?.[0]?.text ?? + ""; + + expect(inputText).toContain("OpenClaw assembled context for this turn:"); + expect(inputText).toContain("we are fixing the Opik default project"); + expect(inputText).toContain("Opik default project context"); + expect(inputText).toContain("Current user request:"); + expect(inputText).toContain("make the default webpage openclaw"); + }); + it("passes OpenClaw bootstrap files through Codex config instructions", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); @@ -2048,6 +2084,90 @@ describe("runCodexAppServerAttempt", () => { expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); }); + it("resumes a bound Codex thread when dynamic tools are reordered", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult("thread-existing"); + } + if (method === "thread/resume") { + return threadStartResult("thread-existing"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("wiki_status"), createNamedDynamicTool("diffs")], + appServer, + }); + const binding = await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createNamedDynamicTool("diffs"), createNamedDynamicTool("wiki_status")], + appServer, + }); + + expect(binding.threadId).toBe("thread-existing"); + expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]); + }); + + it("keeps the previous dynamic tool fingerprint for transient no-tool maintenance turns", async () => { + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const params = createParams(sessionFile, workspaceDir); + const appServer = createThreadLifecycleAppServerOptions(); + let nextThread = 1; + const request = vi.fn(async (method: string) => { + if (method === "thread/start") { + return threadStartResult(`thread-${nextThread++}`); + } + if (method === "thread/resume") { + return threadStartResult("thread-1"); + } + throw new Error(`unexpected method: ${method}`); + }); + + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + const fingerprint = (await readCodexAppServerBinding(sessionFile))?.dynamicToolsFingerprint; + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [], + appServer, + }); + await startOrResumeThread({ + client: { request } as never, + params, + cwd: workspaceDir, + dynamicTools: [createMessageDynamicTool("Send and manage messages.")], + appServer, + }); + + await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({ + dynamicToolsFingerprint: fingerprint, + threadId: "thread-1", + }); + expect(request.mock.calls.map(([method]) => method)).toEqual([ + "thread/start", + "thread/start", + "thread/resume", + ]); + }); + it("preserves the binding when the app-server closes during thread resume", async () => { const sessionFile = path.join(tempDir, "session.jsonl"); const workspaceDir = path.join(tempDir, "workspace"); diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 6f195eaf368..a082ecb7325 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -88,8 +88,10 @@ import { readCodexAppServerBinding, type CodexAppServerThreadBinding } from "./s import { readCodexMirroredSessionHistoryMessages } from "./session-history.js"; import { clearSharedCodexAppServerClientIfCurrent } from "./shared-client.js"; import { + areCodexDynamicToolFingerprintsCompatible, buildDeveloperInstructions, buildTurnStartParams, + codexDynamicToolsFingerprint, startOrResumeThread, } from "./thread-lifecycle.js"; import { @@ -500,6 +502,20 @@ export async function runCodexAppServerAttempt( error: formatErrorMessage(assembleErr), }); } + } else if ( + shouldProjectMirroredHistoryForCodexStart({ + startupBinding, + dynamicToolsFingerprint: codexDynamicToolsFingerprint(toolBridge.specs), + historyMessages, + }) + ) { + const projection = projectContextEngineAssemblyForCodex({ + assembledMessages: historyMessages, + originalHistoryMessages: historyMessages, + prompt: params.prompt, + }); + promptText = projection.promptText; + prePromptMessageCount = projection.prePromptMessageCount; } const promptBuild = await resolveAgentHarnessBeforePromptBuildResult({ prompt: promptText, @@ -1546,6 +1562,23 @@ async function buildDynamicTools(input: DynamicToolBuildParams) { }); } +function shouldProjectMirroredHistoryForCodexStart(params: { + startupBinding: CodexAppServerThreadBinding | undefined; + dynamicToolsFingerprint: string; + historyMessages: AgentMessage[]; +}): boolean { + if (!params.historyMessages.some((message) => message.role === "user")) { + return false; + } + if (!params.startupBinding?.threadId) { + return true; + } + return !areCodexDynamicToolFingerprintsCompatible({ + previous: params.startupBinding.dynamicToolsFingerprint, + next: params.dynamicToolsFingerprint, + }); +} + async function withCodexStartupTimeout(params: { timeoutMs: number; timeoutFloorMs?: number; diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index 023eb85544b..a43f3c3f279 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -47,20 +47,37 @@ export async function startOrResumeThread(params: { agentDir: params.params.agentDir, config: params.params.config, }); + let preserveExistingBinding = false; if (binding?.threadId) { // `/codex resume ` writes a binding before the next turn can know // the dynamic tool catalog, so only invalidate fingerprints we actually have. if ( binding.dynamicToolsFingerprint && - binding.dynamicToolsFingerprint !== dynamicToolsFingerprint + !areDynamicToolFingerprintsCompatible( + binding.dynamicToolsFingerprint, + dynamicToolsFingerprint, + ) ) { - embeddedAgentLog.debug( - "codex app-server dynamic tool catalog changed; starting a new thread", - { - threadId: binding.threadId, - }, - ); - await clearCodexAppServerBinding(params.params.sessionFile); + preserveExistingBinding = shouldStartTransientNoToolThread({ + previous: binding.dynamicToolsFingerprint, + next: dynamicToolsFingerprint, + }); + if (preserveExistingBinding) { + embeddedAgentLog.debug( + "codex app-server dynamic tools unavailable for turn; starting transient thread", + { + threadId: binding.threadId, + }, + ); + } else { + embeddedAgentLog.debug( + "codex app-server dynamic tool catalog changed; starting a new thread", + { + threadId: binding.threadId, + }, + ); + await clearCodexAppServerBinding(params.params.sessionFile); + } } else { try { const authProfileId = params.params.authProfileId ?? binding.authProfileId; @@ -142,23 +159,25 @@ export async function startOrResumeThread(params: { config: params.params.config, }); const createdAt = new Date().toISOString(); - await writeCodexAppServerBinding( - params.params.sessionFile, - { - threadId: response.thread.id, - cwd: params.cwd, - authProfileId: params.params.authProfileId, - model: response.model ?? params.params.modelId, - modelProvider: response.modelProvider ?? modelProvider, - dynamicToolsFingerprint, - createdAt, - }, - { - authProfileStore: params.params.authProfileStore, - agentDir: params.params.agentDir, - config: params.params.config, - }, - ); + if (!preserveExistingBinding) { + await writeCodexAppServerBinding( + params.params.sessionFile, + { + threadId: response.thread.id, + cwd: params.cwd, + authProfileId: params.params.authProfileId, + model: response.model ?? params.params.modelId, + modelProvider: response.modelProvider ?? modelProvider, + dynamicToolsFingerprint, + createdAt, + }, + { + authProfileStore: params.params.authProfileStore, + agentDir: params.params.agentDir, + config: params.params.config, + }, + ); + } return { schemaVersion: 1, threadId: response.thread.id, @@ -284,8 +303,21 @@ function buildHeartbeatCollaborationInstructions(): string { ].join("\n\n"); } +export function codexDynamicToolsFingerprint(dynamicTools: CodexDynamicToolSpec[]): string { + return fingerprintDynamicTools(dynamicTools); +} + +export function areCodexDynamicToolFingerprintsCompatible(params: { + previous?: string; + next: string; +}): boolean { + return areDynamicToolFingerprintsCompatible(params.previous, params.next); +} + function fingerprintDynamicTools(dynamicTools: CodexDynamicToolSpec[]): string { - return JSON.stringify(dynamicTools.map(fingerprintDynamicToolSpec)); + return JSON.stringify( + dynamicTools.map(fingerprintDynamicToolSpec).toSorted(compareJsonFingerprint), + ); } function fingerprintDynamicToolSpec(tool: JsonValue): JsonValue { @@ -320,6 +352,27 @@ function stabilizeJsonValue(value: JsonValue): JsonValue { return stable; } +const EMPTY_DYNAMIC_TOOLS_FINGERPRINT = JSON.stringify([]); + +function areDynamicToolFingerprintsCompatible(previous: string | undefined, next: string): boolean { + return !previous || previous === next; +} + +function shouldStartTransientNoToolThread(params: { + previous: string | undefined; + next: string; +}): boolean { + return Boolean( + params.previous && + params.previous !== EMPTY_DYNAMIC_TOOLS_FINGERPRINT && + params.next === EMPTY_DYNAMIC_TOOLS_FINGERPRINT, + ); +} + +function compareJsonFingerprint(left: JsonValue, right: JsonValue): number { + return JSON.stringify(left).localeCompare(JSON.stringify(right)); +} + export function buildDeveloperInstructions(params: EmbeddedRunAttemptParams): string { const promptOverlay = renderCodexRuntimePromptOverlay(params); const sections = [