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>
This commit is contained in:
paseo-bot[bot]
2026-09-29 13:51:43 +02:00
committed by GitHub
co-authored by 3ae3ae
parent d596861891
commit aff476b01b
2 changed files with 53 additions and 20 deletions
@@ -660,6 +660,7 @@ async function withCustomCodexProviderHome<T>(
session: AgentSession;
readCaptured: () => CapturedFakeCodexRecord[];
}) => Promise<T>,
providerPrompts: Record<string, string> = {},
): Promise<T> {
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<T>(
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<string[]> {
});
}
async function runPromptFromCustomCodexHome(prompt: string): Promise<CapturedFakeCodexRecord[]> {
async function runPromptFromCustomCodexHome(
prompt: string,
providerPrompts: Record<string, string> = {},
): Promise<CapturedFakeCodexRecord[]> {
return withCustomCodexProviderHome(async ({ session, readCaptured }) => {
await session.startTurn(prompt);
return readCaptured();
});
}, providerPrompts);
}
async function expandCustomPrompt(template: string, args: string): Promise<string> {
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([
{
@@ -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 {