Files
paperclip/packages/shared/src/routine-variables.test.ts
T
Devin Foley 4c26b984a7 fix(routines): detect variables when underscores are markdown-escaped (#8056)
## 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 <noreply@paperclip.ing>
2026-06-12 13:02:12 -07:00

112 lines
4.4 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
BUILTIN_ROUTINE_VARIABLE_NAMES,
extractRoutineVariableNames,
getBuiltinRoutineVariableValues,
interpolateRoutineTemplate,
isBuiltinRoutineVariable,
syncRoutineVariablesWithTemplate,
} from "./routine-variables.js";
describe("routine variable helpers", () => {
it("extracts placeholder names in first-appearance order", () => {
expect(
extractRoutineVariableNames("Review {{repo}} and {{priority}} for {{repo}}"),
).toEqual(["repo", "priority"]);
});
it("deduplicates placeholder names across the routine title and description", () => {
expect(
extractRoutineVariableNames([
"Triage {{repo}}",
"Review {{repo}} for {{priority}} bugs",
]),
).toEqual(["repo", "priority"]);
});
it("preserves existing metadata when syncing variables from a template", () => {
expect(
syncRoutineVariablesWithTemplate(["Triage {{repo}}", "Review {{repo}} and {{priority}}"], [
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
]),
).toEqual([
{ name: "repo", label: "Repository", type: "text", defaultValue: "paperclip", required: true, options: [] },
{ name: "priority", label: null, type: "text", defaultValue: null, required: true, options: [] },
]);
});
it("interpolates provided variable values into the routine template", () => {
expect(
interpolateRoutineTemplate("Review {{repo}} for {{priority}}", {
repo: "paperclip",
priority: "high",
}),
).toBe("Review paperclip for high");
});
it("identifies built-in variable names", () => {
expect(isBuiltinRoutineVariable("date")).toBe(true);
expect(isBuiltinRoutineVariable("timestamp")).toBe(true);
expect(isBuiltinRoutineVariable("repo")).toBe(false);
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("date")).toBe(true);
expect(BUILTIN_ROUTINE_VARIABLE_NAMES.has("timestamp")).toBe(true);
});
it("getBuiltinRoutineVariableValues returns date in YYYY-MM-DD format", () => {
const values = getBuiltinRoutineVariableValues();
expect(values.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
expect(values.date).toBe(new Date().toISOString().slice(0, 10));
});
it("getBuiltinRoutineVariableValues returns a human-readable timestamp with year, time, and UTC", () => {
const values = getBuiltinRoutineVariableValues();
const year = String(new Date().getUTCFullYear());
expect(values.timestamp).toContain(year);
expect(values.timestamp).toMatch(/\d{1,2}:\d{2}\s?(AM|PM)/);
expect(values.timestamp).toContain("UTC");
});
it("excludes built-in variables from syncRoutineVariablesWithTemplate", () => {
const result = syncRoutineVariablesWithTemplate(
"Daily report for {{date}} at {{timestamp}} — {{repo}}",
[],
);
expect(result).toEqual([
{ name: "repo", label: null, type: "text", defaultValue: null, required: true, options: [] },
]);
});
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" };
expect(
interpolateRoutineTemplate("Report for {{date}} ({{timestamp}}) on {{repo}}", allVars),
).toBe(`Report for ${builtins.date} (${builtins.timestamp}) on paperclip`);
});
});