From aff476b01b97078cfedc2d17b44f598960f2978d Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:51:43 +0200 Subject: [PATCH] fix(codex): insert custom prompt arguments literally (#5669) Custom prompt expansion replaced each placeholder kind in its own pass, so text inserted by an earlier pass was expanded again ($ARGUMENTS containing "$1"), and named values went through String.replace, which reads "$&" and friends as replacement patterns. Match every placeholder in one pass and insert values from a callback. Closes #5613 Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: 3ae3ae <83870598+3ae3ae@users.noreply.github.com> --- .../providers/codex-app-server-agent.test.ts | 41 ++++++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 32 +++++++-------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d4a96b45e6..1d84dbca0c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -660,6 +660,7 @@ async function withCustomCodexProviderHome( session: AgentSession; readCaptured: () => CapturedFakeCodexRecord[]; }) => Promise, + providerPrompts: Record = {}, ): Promise { const tempDir = await mkdtemp(path.join(tmpdir(), "codex-provider-home-")); const daemonCodexHome = path.join(tempDir, "daemon-codex-home"); @@ -676,6 +677,9 @@ async function withCustomCodexProviderHome( path.join(providerCodexHome, "prompts", "probe-profile.md"), "---\ndescription: Provider home prompt\n---\nfrom the provider home\n", ); + for (const [name, content] of Object.entries(providerPrompts)) { + writeFileSync(path.join(providerCodexHome, "prompts", `${name}.md`), content); + } writeFileSync( fakeAppServerPath, ` @@ -753,11 +757,21 @@ async function listPromptCommandsFromCustomCodexHome(): Promise { }); } -async function runPromptFromCustomCodexHome(prompt: string): Promise { +async function runPromptFromCustomCodexHome( + prompt: string, + providerPrompts: Record = {}, +): Promise { return withCustomCodexProviderHome(async ({ session, readCaptured }) => { await session.startTurn(prompt); return readCaptured(); - }); + }, providerPrompts); +} + +async function expandCustomPrompt(template: string, args: string): Promise { + const records = await runPromptFromCustomCodexHome(`/prompts:template ${args}`, { template }); + const turnStart = records.find((record) => record.method === "turn/start"); + const params = turnStart?.params as { input: Array<{ text: string }> }; + return params.input[0].text; } function capturedThreadStartConfig(records: CapturedFakeCodexRecord[]): unknown { @@ -2484,6 +2498,29 @@ describe("Codex app-server provider", () => { expect(JSON.stringify(turnStart?.params)).toContain("from the provider home"); }); + test("inserts custom prompt arguments literally", async () => { + await expect( + expandCustomPrompt("Implement this exact request: $ARGUMENTS", "Preserve $1 unchanged"), + ).resolves.toBe("Implement this exact request: Preserve $1 unchanged"); + await expect( + expandCustomPrompt("Treat this as literal replacement text: $VALUE.", 'VALUE="$&"'), + ).resolves.toBe("Treat this as literal replacement text: $&."); + await expect(expandCustomPrompt("$1 then $2", '"$2" two')).resolves.toBe("$2 then two"); + await expect(expandCustomPrompt("$A and $B", 'A="$B" B=b')).resolves.toBe("$B and b"); + await expect( + expandCustomPrompt("$ARGUMENTS", "keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"), + ).resolves.toBe("keep __CODEX_DOLLAR_PLACEHOLDER__ as typed"); + }); + + test("expands custom prompt placeholders and escapes", async () => { + await expect( + expandCustomPrompt("$1/$2 $NAME costs $$5, all: $ARGUMENTS, $MISSING", "one NAME=n two"), + ).resolves.toBe("one/two n costs $5, all: one NAME=n two, $MISSING"); + await expect( + expandCustomPrompt("$$ARGUMENTS $$1 $$NAME $9|$NAME_SUFFIX", "first NAME=value"), + ).resolves.toBe("$ARGUMENTS $1 $NAME |$NAME_SUFFIX"); + }); + test("deduplicates Codex skill slash commands returned from multiple skill roots", async () => { const commands = await listCommandsFromFakeCodex([ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 55d7886df8..1d0aca552c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -786,25 +786,21 @@ function expandCodexCustomPrompt(template: string, args: string | undefined): st positional.push(token); } - const dollarPlaceholder = "__CODEX_DOLLAR_PLACEHOLDER__"; - let out = template.split("$$").join(dollarPlaceholder); + // Match every placeholder in one pass, so inserted argument text is never expanded again. + const namedPlaceholders = Object.keys(named) + .sort((a, b) => b.length - a.length) + .map((key) => `${escapeRegExp(key)}\\b`); + const placeholder = new RegExp( + `\\$(${["\\$", "ARGUMENTS", "[1-9]", ...namedPlaceholders].join("|")})`, + "g", + ); - out = out.split("$ARGUMENTS").join(trimmedArgs); - - for (let i = 1; i <= 9; i += 1) { - const value = positional[i - 1] ?? ""; - out = out.split(`$${i}`).join(value); - } - - const namedKeys = Object.keys(named).sort((a, b) => b.length - a.length); - for (const key of namedKeys) { - const value = named[key] ?? ""; - const re = new RegExp(`\\$${escapeRegExp(key)}\\b`, "g"); - out = out.replace(re, value); - } - - out = out.split(dollarPlaceholder).join("$"); - return out; + return template.replace(placeholder, (match, name: string) => { + if (name === "$") return "$"; + if (name === "ARGUMENTS") return trimmedArgs; + if (/^[1-9]$/.test(name)) return positional[Number(name) - 1] ?? ""; + return named[name] ?? match; + }); } interface CodexMcpServerConfig {