From 201cb618eaeb3755aa1d2050698f21cc40a11881 Mon Sep 17 00:00:00 2001 From: tend-agent <270458913+tend-agent@users.noreply.github.com> Date: Mon, 10 Aug 2026 06:54:36 +0000 Subject: [PATCH] fix(generator): don't double braces in a review prompt that never reaches format() --- generator/src/tend/workflows.py | 12 ++++-- generator/tests/test_config_edge_cases.py | 13 ++++-- generator/tests/test_generate.py | 49 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/generator/src/tend/workflows.py b/generator/src/tend/workflows.py index 2df6d171..f5b8e7d3 100644 --- a/generator/src/tend/workflows.py +++ b/generator/src/tend/workflows.py @@ -185,14 +185,20 @@ def _escape_braces(prompt: str, placeholder: str) -> tuple[str, bool]: Returns (escaped_prompt, needs_format). In the escaped prompt, {placeholder} is replaced with {0} for use with GitHub Actions format(), and all other braces are doubled to prevent format() from interpreting them. + + A prompt with no {placeholder} is returned untouched. Doubling is only + correct on the way into `format()`, which collapses each pair back to one + brace; without the placeholder the caller emits a bare string literal + instead, and GitHub Actions does not collapse braces there — the pairs + would reach the agent verbatim. """ sentinel = "\x00PLACEHOLDER\x00" text = prompt.replace(f"{{{placeholder}}}", sentinel) + if sentinel not in text: + return prompt, False # Double all remaining braces so format() treats them as literals text = text.replace("{", "{{").replace("}", "}}") - has_placeholder = sentinel in text - text = text.replace(sentinel, "{0}") - return text, has_placeholder + return text.replace(sentinel, "{0}"), True def _effective_cfg(cfg: Config, wf: WorkflowConfig) -> Config: diff --git a/generator/tests/test_config_edge_cases.py b/generator/tests/test_config_edge_cases.py index 19433195..a7e7fca6 100644 --- a/generator/tests/test_config_edge_cases.py +++ b/generator/tests/test_config_edge_cases.py @@ -299,7 +299,13 @@ def test_prompt_with_zero_placeholder(tmp_path: Path) -> None: def test_prompt_with_numbered_placeholders(tmp_path: Path) -> None: - """Prompt with {1}, {2} — escaped to prevent format() runtime errors.""" + """Prompt with {1}, {2} and no {pr_number} — emitted verbatim, not escaped. + + Escaping guards `format()`, which collapses each doubled pair back to one + brace. With no {pr_number} there is nothing to interpolate, so the prompt + is emitted as a bare GHA string literal instead — nothing collapses the + pairs there, and doubling would ship `{{1}}` to the agent. + """ path = _write_config( tmp_path, dedent("""\ @@ -312,9 +318,8 @@ def test_prompt_with_numbered_placeholders(tmp_path: Path) -> None: cfg = Config.load(path) workflows = {wf.filename: wf for wf in generate_all(cfg)} review = workflows["tend-review.yaml"] - # {1} and {2} are escaped to {{1}} and {{2}} — literals in GHA expressions - assert "{{1}}" in review.content - assert "{{2}}" in review.content + assert "format(" not in review.content + assert "'Fix issue {1} and {2}'" in review.content # --------------------------------------------------------------------------- diff --git a/generator/tests/test_generate.py b/generator/tests/test_generate.py index 81ce3167..35847737 100644 --- a/generator/tests/test_generate.py +++ b/generator/tests/test_generate.py @@ -23,6 +23,7 @@ from tend.workflows import ( _deep_merge, GENERATORS, + GeneratedWorkflow, generate_all, generate_install_test, generate_mention, @@ -395,6 +396,54 @@ def test_custom_prompt(tmp_path: Path) -> None: assert "Custom triage:" in triage.content +def _review_prompt(review: GeneratedWorkflow) -> str: + """The `prompt:` input the review job hands the harness action.""" + steps = yaml.safe_load(review.content)["jobs"]["review"]["steps"] + step = next( + s for s in steps if s.get("uses", "").startswith("max-sixty/tend/claude@") + ) + return step["with"]["prompt"] + + +def test_review_prompt_without_placeholder_keeps_literal_braces( + tmp_path: Path, +) -> None: + """A review prompt with braces but no `{pr_number}` reaches the agent verbatim. + + The review prompt is the only one emitted inside a GHA expression. With the + placeholder it goes through `format()`, which needs every other brace + doubled; without it, it is a bare string literal that GHA never collapses, + so doubling there would ship `{{...}}` to the agent. + """ + extra = dedent("""\ + workflows: + review: + prompt: "Review this PR. Skip files matching {generated}." + """) + cfg = Config.load(_minimal_config(tmp_path, extra)) + workflows = {wf.filename: wf for wf in generate_all(cfg)} + prompt = _review_prompt(workflows["tend-review.yaml"]) + assert "{generated}" in prompt + assert "{{generated}}" not in prompt + assert "format(" not in prompt + + +def test_review_prompt_with_placeholder_escapes_other_braces(tmp_path: Path) -> None: + """With `{pr_number}` present the prompt goes through `format()`, so the + placeholder becomes `{0}` and every other brace is doubled for it.""" + extra = dedent("""\ + workflows: + review: + prompt: "Review PR {pr_number}. Skip files matching {generated}." + """) + cfg = Config.load(_minimal_config(tmp_path, extra)) + workflows = {wf.filename: wf for wf in generate_all(cfg)} + prompt = _review_prompt(workflows["tend-review.yaml"]) + assert "format(" in prompt + assert "{0}" in prompt + assert "{{generated}}" in prompt + + def test_watched_workflows(tmp_path: Path) -> None: extra = dedent("""\ workflows: