From 4c26b984a7f3dceda0ffedef0d98b0cbc6a6463e Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Fri, 12 Jun 2026 13:02:12 -0700 Subject: [PATCH] fix(routines): detect variables when underscores are markdown-escaped (#8056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Routines let users templatize titles and instructions with `{{name}}` placeholders that get prompted for at run time > - A user reported that `{{pr_url}}` typed into a routine description was never detected as a variable, while camelCase placeholders worked > - Root cause: the routine description is edited via MDXEditor (WYSIWYG markdown). On save its serializer (`mdast-util-to-markdown`) defensively escapes intraword underscores, so a user-typed `{{pr_url}}` is stored as `{{pr\_url}}`. The variable matcher only accepted `[A-Za-z0-9_]` inside the placeholder, so the backslash broke the match and the variable was silently dropped > - This PR widens the matcher to tolerate `\_` and unescapes it back to `_` on capture, in both extraction and interpolation, so a single name is detected and resolved regardless of whether the source markdown was hand-typed or round-tripped through a WYSIWYG editor > - The benefit is that snake_case placeholders behave the same in the routine UI and the executor, removing a silent failure mode ## Linked Issues or Issue Description Refs PAPA-771 — "Fix example for variable_name". Initially scoped as a docs-copy fix (change the example to camelCase), but investigation showed the underlying bug was that the parser could not see the variable when it was authored as `{{pr_url}}` in a WYSIWYG-edited description. This PR addresses the bug directly so snake_case names work as users expect. (Supersedes the now-closed PR #8054 — same commit, fresh branch so the diff is visible.) ## What Changed - `packages/shared/src/routine-variables.ts`: - Widened the `ROUTINE_VARIABLE_MATCHER` regex to accept `\_` inside placeholder names (in addition to `[A-Za-z0-9_]`) - Added `unescapeRoutineVariableName` and applied it on capture in both `extractRoutineVariableNames` and `interpolateRoutineTemplate`, so the looked-up variable name is normalized regardless of the markdown escape - `packages/shared/src/routine-variables.test.ts`: - Added tests covering plain snake_case (`{{pr_url}}`), markdown-escaped (`{{pr\_url}}`), multi-underscore (`{{pr\_url\_v2}}`), sync-with-template, and interpolation ## Verification - `pnpm --filter @paperclipai/shared exec vitest run routine-variables` → 13 passed (4 new), confirming both plain and escaped underscore placeholders are detected and interpolated as the same variable name - Reproduced the root cause out-of-band with `mdast-util-from-markdown` + `mdast-util-to-markdown` to confirm the editor's serializer is the source of the `\_` escape ## Risks - Low. Change is confined to `packages/shared/src/routine-variables.ts`; the regex is strictly more permissive in a tightly bounded way (only `\_` is newly allowed) and the captured name is normalized. `isValidRoutineVariableName` is unchanged, so stored names are still strict identifiers. ## Model Used - Claude (Anthropic), `claude-opus-4-7` (Opus 4.7), via Claude Code ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above — only my own closed #8054 supersedes/duplicates this one. Open PRs touching `routine-variables.ts` (#7184, #7186, #6993, #7187) are all for unrelated concerns (server-side PATCH persistence, GH#6525) - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass — `routine-variables` suite passes (13/13) - [x] I have added or updated tests where applicable — 4 new tests covering snake_case + escaped forms - [ ] If this change affects the UI, I have included before/after screenshots — no UI change in this PR - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green — pending CI re-run after the push for the Greptile blank-line nit - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups — initial review was 5/5; the one P2 (missing blank line) is addressed in the latest push - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- packages/shared/src/routine-variables.test.ts | 25 +++++++++++++++++++ packages/shared/src/routine-variables.ts | 17 ++++++++++--- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/routine-variables.test.ts b/packages/shared/src/routine-variables.test.ts index a129ee48..15d63a65 100644 --- a/packages/shared/src/routine-variables.test.ts +++ b/packages/shared/src/routine-variables.test.ts @@ -76,6 +76,31 @@ describe("routine variable helpers", () => { ]); }); + it("extracts snake_case variable names", () => { + expect(extractRoutineVariableNames("Open {{pr_url}} for review")).toEqual(["pr_url"]); + }); + + it("extracts variable names whose underscores were markdown-escaped by a WYSIWYG editor", () => { + // MDXEditor / mdast-util-to-markdown defensively escape intraword underscores + // when serializing rich-text back to markdown, so `{{pr_url}}` is stored as `{{pr\_url}}`. + expect(extractRoutineVariableNames("Open {{pr\\_url}} for review")).toEqual(["pr_url"]); + expect(extractRoutineVariableNames("{{pr\\_url\\_v2}}")).toEqual(["pr_url_v2"]); + }); + + it("syncRoutineVariablesWithTemplate handles markdown-escaped underscores", () => { + expect( + syncRoutineVariablesWithTemplate("Open {{pr\\_url}}", []), + ).toEqual([ + { name: "pr_url", label: null, type: "text", defaultValue: null, required: true, options: [] }, + ]); + }); + + it("interpolates variables that appear with markdown-escaped underscores", () => { + expect( + interpolateRoutineTemplate("Open {{pr\\_url}}", { pr_url: "https://example.com" }), + ).toBe("Open https://example.com"); + }); + it("interpolates built-in variables alongside user variables", () => { const builtins = getBuiltinRoutineVariableValues(); const allVars = { ...builtins, repo: "paperclip" }; diff --git a/packages/shared/src/routine-variables.ts b/packages/shared/src/routine-variables.ts index b83148d4..84257188 100644 --- a/packages/shared/src/routine-variables.ts +++ b/packages/shared/src/routine-variables.ts @@ -1,6 +1,14 @@ import type { RoutineVariable } from "./types/routine.js"; -const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z][A-Za-z0-9_]*)\s*\}\}/g; +// Tolerate markdown-escaped underscores (`\_`) inside placeholders. WYSIWYG markdown +// editors (e.g. MDXEditor) serialize `_` between word chars as `\_` to prevent +// reparse-as-emphasis, so a user-typed `{{pr_url}}` is stored as `{{pr\_url}}`. +const ROUTINE_VARIABLE_MATCHER = /\{\{\s*([A-Za-z](?:\\_|[A-Za-z0-9_])*)\s*\}\}/g; + +function unescapeRoutineVariableName(raw: string): string { + return raw.replace(/\\_/g, "_"); +} + type RoutineTemplateInput = string | null | undefined | Array; /** @@ -50,7 +58,7 @@ export function extractRoutineVariableNames(template: RoutineTemplateInput): str const found = new Set(); for (const source of normalizeRoutineTemplateInput(template)) { for (const match of source.matchAll(ROUTINE_VARIABLE_MATCHER)) { - const name = match[1]; + const name = match[1] ? unescapeRoutineVariableName(match[1]) : ""; if (name && !found.has(name)) { found.add(name); } @@ -97,7 +105,8 @@ export function interpolateRoutineTemplate( if (template == null) return null; if (!values || Object.keys(values).length === 0) return template; return template.replace(ROUTINE_VARIABLE_MATCHER, (match, rawName: string) => { - if (!(rawName in values)) return match; - return stringifyRoutineVariableValue(values[rawName]); + const name = unescapeRoutineVariableName(rawName); + if (!(name in values)) return match; + return stringifyRoutineVariableValue(values[name]); }); }