diff --git a/extensions/codex/src/app-server/context-engine-projection.test.ts b/extensions/codex/src/app-server/context-engine-projection.test.ts index 587b79cf9d82..727b8483cc1e 100644 --- a/extensions/codex/src/app-server/context-engine-projection.test.ts +++ b/extensions/codex/src/app-server/context-engine-projection.test.ts @@ -71,6 +71,26 @@ describe("projectContextEngineAssemblyForCodex", () => { expect(ordered.prePromptMessageCount).toBe(1); }); + it("neutralizes explicit mention sigils in projected history but not the current request", () => { + const result = projectContextEngineAssemblyForCodex({ + assembledMessages: [ + textMessage("assistant", "The user did not invoke $example-manual."), + textMessage("user", "see [$other-skill](skill://other) and [@pkg](plugin://pkg@mp)"), + ], + originalHistoryMessages: [], + prompt: "run $current-skill now", + }); + + const context = result.promptText.slice(0, result.promptContextRange?.end); + // Codex byte-scans the whole turn text for `$name`; historical tokens must + // not survive in scannable form (codex-rs/skills/src/mentions.rs). + expect(context).not.toContain("$example-manual"); + expect(context).toContain("$example-manual"); + expect(context).toContain("[$other-skill](skill://other)"); + expect(context).toContain("[@pkg](plugin://pkg@mp)"); + expect(result.promptText).toContain("Current user request:\nrun $current-skill now"); + }); + it("frames projected history as reference data and omits tool payloads", () => { const result = projectContextEngineAssemblyForCodex({ assembledMessages: [ diff --git a/extensions/codex/src/app-server/context-engine-projection.ts b/extensions/codex/src/app-server/context-engine-projection.ts index 6aa7353e8d0d..dff5e7cbc8c7 100644 --- a/extensions/codex/src/app-server/context-engine-projection.ts +++ b/extensions/codex/src/app-server/context-engine-projection.ts @@ -38,6 +38,17 @@ const DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS = 20_000; const MIN_PROMPT_BUDGET_RATIO = 0.5; const MIN_PROMPT_BUDGET_TOKENS = 8_000; +// Codex scans every turn text input byte-for-byte for explicit `$name` skill +// mentions and `[@name](plugin://…)` links (codex-rs/skills/src/mentions.rs); +// quoted history must never count as a current explicit invocation, so swap +// the sigils to same-length fullwidth lookalikes (same technique as +// escapeCodexChatText). Only the raw current request stays selectable. +export function neutralizeCodexExplicitMentionSigils(text: string): string { + return text + .replace(/\$(?=[A-Za-z0-9_:-])/gu, "$") + .replace(/\[@(?=[A-Za-z0-9_:-]+\]\()/gu, "[@"); +} + /** Projects assembled OpenClaw context-engine messages into Codex prompt inputs. */ export function projectContextEngineAssemblyForCodex(params: { assembledMessages: AgentMessage[]; @@ -50,10 +61,12 @@ export function projectContextEngineAssemblyForCodex(params: { const prompt = params.prompt.trim(); const contextMessages = dropDuplicateTrailingPrompt(params.assembledMessages, prompt); const maxRenderedContextChars = normalizeRenderedContextMaxChars(params.maxRenderedContextChars); - const renderedContext = renderMessagesForCodexContext(contextMessages, { - maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars), - toolPayloadMode: params.toolPayloadMode ?? "elide", - }); + const renderedContext = neutralizeCodexExplicitMentionSigils( + renderMessagesForCodexContext(contextMessages, { + maxTextPartChars: resolveTextPartMaxChars(maxRenderedContextChars), + toolPayloadMode: params.toolPayloadMode ?? "elide", + }), + ); const boundedContext = renderedContext ? truncateOlderContext(renderedContext, maxRenderedContextChars) : undefined; diff --git a/extensions/codex/src/app-server/run-attempt-state.test.ts b/extensions/codex/src/app-server/run-attempt-state.test.ts new file mode 100644 index 000000000000..b64bb51bba6d --- /dev/null +++ b/extensions/codex/src/app-server/run-attempt-state.test.ts @@ -0,0 +1,21 @@ +// Codex tests cover run-attempt prompt state helpers. +import { describe, expect, it } from "vitest"; +import { prependCurrentInboundContext } from "./run-attempt-state.js"; + +describe("prependCurrentInboundContext", () => { + it("neutralizes explicit mention sigils in inbound context but not the prompt", () => { + const joined = prependCurrentInboundContext("run $current-skill now", { + text: "Quoted reply: please try $example-manual later", + }); + + expect(joined).toBe( + "Quoted reply: please try $example-manual later\n\nrun $current-skill now", + ); + }); + + it("returns the prompt unchanged without inbound context", () => { + expect(prependCurrentInboundContext("run $current-skill now", undefined)).toBe( + "run $current-skill now", + ); + }); +}); diff --git a/extensions/codex/src/app-server/run-attempt-state.ts b/extensions/codex/src/app-server/run-attempt-state.ts index 7c5ac24cfad6..9502f8341e89 100644 --- a/extensions/codex/src/app-server/run-attempt-state.ts +++ b/extensions/codex/src/app-server/run-attempt-state.ts @@ -10,6 +10,7 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; import { CodexAppServerRpcError } from "./client.js"; +import { neutralizeCodexExplicitMentionSigils } from "./context-engine-projection.js"; import { isJsonObject, type CodexServerNotification } from "./protocol.js"; import type { CodexAppServerBindingIdentity, @@ -136,8 +137,12 @@ export function prependCurrentInboundContext( prompt: string, context: EmbeddedRunAttemptParams["currentInboundContext"], ): string { + // Inbound context carries quoted replies and room backlog, not the raw + // current request; Codex must not resolve explicit mentions from it. const text = context?.text.trim(); - return text ? [text, prompt].filter(Boolean).join("\n\n") : prompt; + return text + ? [neutralizeCodexExplicitMentionSigils(text), prompt].filter(Boolean).join("\n\n") + : prompt; } export function waitForCodexNotificationDispatchTurn(): Promise {