diff --git a/orchestrate/skills/orchestrate/scripts/__tests__/prompt-literal-values.test.ts b/orchestrate/skills/orchestrate/scripts/__tests__/prompt-literal-values.test.ts new file mode 100644 index 000000000..1d0ed5ba9 --- /dev/null +++ b/orchestrate/skills/orchestrate/scripts/__tests__/prompt-literal-values.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { renderPromptTemplate } from "../core/prompts.ts"; + +describe("literal template values", () => { + const values = { + goal: "Build a feature", + agentId: "agent-123", + dispatcherInstruction: "", + slackChannelInstruction: "", + loopHygiene: "", + }; + + test("preserves JavaScript replacement tokens in a task goal", () => { + const goal = "Implement replacements $& and $` and $' literally"; + expect(renderPromptTemplate("root", { ...values, goal })).toContain(goal); + }); + + test("preserves code template braces in a task goal", () => { + const goal = "Render the user template {{customer}} literally"; + expect(renderPromptTemplate("root", { ...values, goal })).toContain(goal); + }); + + test("does not replace inserted text with other template variable values", () => { + const goal = "Test literal {{agentId}} syntax"; + expect(renderPromptTemplate("root", { ...values, goal })).toContain(goal); + }); + + test("ordinary template variables still render", () => { + const rendered = renderPromptTemplate("root", values); + expect(rendered).toContain(values.goal); + expect(rendered).toContain(values.agentId); + }); + + test("missing template variables still fail with an actionable error", () => { + expect(() => renderPromptTemplate("root", { goal: "test" })).toThrow("unrendered placeholders"); + }); +}); diff --git a/orchestrate/skills/orchestrate/scripts/core/prompts.ts b/orchestrate/skills/orchestrate/scripts/core/prompts.ts index f80f7f703..e3df9ba11 100644 --- a/orchestrate/skills/orchestrate/scripts/core/prompts.ts +++ b/orchestrate/skills/orchestrate/scripts/core/prompts.ts @@ -34,14 +34,14 @@ export function renderPromptTemplate( templateCache.set(name, template); } - let rendered = template; - for (const [key, value] of Object.entries(vars)) { - rendered = rendered.replaceAll(`{{${key}}}`, value); - } - - const unrendered = [...rendered.matchAll(/{{([^{}]+)}}/g)].map(m => - m[1].trim() - ); + const unrendered: string[] = []; + // Match placeholders in the template once. Values are task content, so they + // must not be interpreted as replacement tokens or additional placeholders. + const rendered = template.replace(/{{([^{}]+)}}/g, (placeholder, key: string) => { + if (Object.hasOwn(vars, key)) return vars[key]; + unrendered.push(key.trim()); + return placeholder; + }); if (unrendered.length > 0) { const placeholders = [...new Set(unrendered)].join(", "); throw new PlanValidationError(