mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user