From 22c5ced69ffde4e9756c5cd8acac5ad0d5dc2954 Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Sat, 20 Jun 2026 23:45:09 +0800 Subject: [PATCH] fix(codex): preserve projected context after hooks --- .../run-attempt.context-engine.test.ts | 49 +++++++++++++++++-- .../codex/src/app-server/run-attempt.ts | 9 +++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts index 08a9a92bb4f9..f081a5b68c4a 100644 --- a/extensions/codex/src/app-server/run-attempt.context-engine.test.ts +++ b/extensions/codex/src/app-server/run-attempt.context-engine.test.ts @@ -9,9 +9,15 @@ import { type HarnessContextEngine as ContextEngine, } from "openclaw/plugin-sdk/agent-harness-runtime"; import { SessionManager } from "openclaw/plugin-sdk/agent-sessions"; +import { + initializeGlobalHookRunner, + resetGlobalHookRunner, +} from "openclaw/plugin-sdk/hook-runtime"; +import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime"; import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CodexAppServerClientFactory } from "./client-factory.js"; +import { CODEX_TURN_START_TEXT_INPUT_MAX_CHARS } from "./context-engine-projection.js"; import type { CodexServerNotification } from "./protocol.js"; import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js"; import { @@ -71,9 +77,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({ web_search: "disabled", }); -function writeCodexAppServerBinding( - ...args: Parameters -) { +function writeCodexAppServerBinding(...args: Parameters) { const [sessionFile, binding, lookup] = args; return writeRawCodexAppServerBinding( sessionFile, @@ -349,6 +353,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { afterEach(async () => { resetCodexAppServerClientFactoryForTest(); + resetGlobalHookRunner(); vi.restoreAllMocks(); await fs.rm(tempDir, { recursive: true, force: true }); }); @@ -493,6 +498,44 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => { await run; }); + it("bounds active context-engine projections when prompt hooks append context", async () => { + initializeGlobalHookRunner( + createMockPluginRegistry([ + { hookName: "before_prompt_build", handler: async () => ({ appendContext: "hook tail" }) }, + ]), + ); + const sessionFile = path.join(tempDir, "session.jsonl"); + const workspaceDir = path.join(tempDir, "workspace"); + const contextEngine = createContextEngine({ + assemble: vi.fn(async () => ({ + messages: [ + assistantMessage( + `older context ${"x".repeat(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS)} recent anchor`, + 10, + ), + ], + estimatedTokens: 300_000, + })), + }); + const harness = createStartedThreadHarness(); + const params = createParams(sessionFile, workspaceDir); + params.contextEngine = contextEngine; + params.contextTokenBudget = 300_000; + params.prompt = "current prompt survives"; + + const run = runCodexAppServerAttempt(params); + await harness.waitForMethod("turn/start"); + + const inputText = getRequestInputText(harness); + expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS); + expect(inputText).toContain("recent anchor"); + expect(inputText).toContain("current prompt survives"); + expect(inputText).toContain("hook tail"); + + await harness.completeTurn(); + await run; + }); + it("uses configured compaction reserve when sizing Codex context-engine projections", 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 d967fc1aa131..0d5a6bf18e08 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -1028,10 +1028,15 @@ export async function runCodexAppServerAttempt( prompt: string, turnPromptText: string, ): CodexProjectedContextRange | undefined => { - if (!promptContextRange || !prompt.endsWith(promptText) || !turnPromptText.endsWith(prompt)) { + if (!promptContextRange || !turnPromptText.endsWith(prompt)) { + return undefined; + } + // Prompt-build hooks can append context after the projected prompt, so it + // is no longer necessarily the suffix of the assembled prompt. + const promptTextOffset = prompt.lastIndexOf(promptText); + if (promptTextOffset === -1) { return undefined; } - const promptTextOffset = prompt.length - promptText.length; const turnPromptOffset = turnPromptText.length - prompt.length + promptTextOffset; return { start: turnPromptOffset + promptContextRange.start,