From c8f385483354a85168d159b90e31c4cd05f58824 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 02:44:47 +0000 Subject: [PATCH 01/10] docs: require a user flow and a stuck-at proof in feature requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/feature_request.yml | 57 ++++++++++++++++++++-- CLAUDE.md | 2 +- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 4cc42901897..d1c31e4ff6d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -24,10 +24,61 @@ body: validations: required: true - type: textarea - id: motivation + id: user-flow attributes: - label: Motivation, pitch - description: Please outline the motivation for the proposal. Is your feature request related to a specific problem? e.g., "I'm working on X and would like Y to be possible". If this is related to another GitHub issue, please link here too. + label: User Flow + description: Two numbered lists walking the same end user through the same task, one today without the feature and one with it. Keep the guidance comments in the box while you fill it in, they explain every rule. + value: | + + + Today (without the feature): + + 1. + 2. + 3. + + With the feature: + + 1. + 2. + 3. + validations: + required: true + - type: textarea + id: how-far-you-got + attributes: + label: How far you got + description: Walk the "With the feature" list against a live proxy and paste the commands and output up to the step where you get stuck. Keep the guidance comments in the box while you fill it in, they explain every rule. + value: | + + validations: required: true - type: dropdown diff --git a/CLAUDE.md b/CLAUDE.md index 0e63b86036d..02cc8e024df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ When creating PRs, don't set base to `main`. `litellm_internal_staging` is the d When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule -Same applies for filing bug reports and .github/ISSUE_TEMPLATE/bug_report.yml +Same applies for filing bug reports and feature requests, and .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank From 471bb834ba9962452e446eda34308b9c7bbf7b60 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 02:45:44 +0000 Subject: [PATCH 02/10] docs: rename the feature request flow lists to before/after this feature Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/feature_request.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index d1c31e4ff6d..4ffa491883c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -27,25 +27,25 @@ body: id: user-flow attributes: label: User Flow - description: Two numbered lists walking the same end user through the same task, one today without the feature and one with it. Keep the guidance comments in the box while you fill it in, they explain every rule. + description: Two numbered lists walking the same end user through the same task, one as it goes today and one as it would ideally go with the feature. Keep the guidance comments in the box while you fill it in, they explain every rule. value: | - - Today (without the feature): + Before this feature (today): 1. 2. 3. - With the feature: + After this feature (ideal user flow): 1. 2. @@ -70,9 +70,9 @@ body: id: how-far-you-got attributes: label: How far you got - description: Walk the "With the feature" list against a live proxy and paste the commands and output up to the step where you get stuck. Keep the guidance comments in the box while you fill it in, they explain every rule. + description: Walk the "After this feature (ideal user flow)" list against a live proxy and paste the commands and output up to the step where you get stuck. Keep the guidance comments in the box while you fill it in, they explain every rule. value: | - + Config / setup the proxy ran with: + + Version or commit: + + Commands and their full output, up to the step that dead-ends: + + What stopped me there: + validations: required: true + - type: checkboxes + id: attempt-attestation + attributes: + label: About that attempt + options: + - label: I ran it against a live proxy myself, with no mocks, and the output above is what I actually got back + required: true - type: dropdown id: component attributes: From 38a1bec5c2de09e722faaf8a498e204d1f24ecd5 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 02:56:38 +0000 Subject: [PATCH 04/10] fix(triage): require evidence of the dead-end in feature requests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/scripts/triage_with_llm.py | 5 +++++ tests/test_litellm/test_github_triage_with_llm.py | 13 +++++++++++++ 2 files changed, 18 insertions(+) diff --git a/.github/scripts/triage_with_llm.py b/.github/scripts/triage_with_llm.py index a7bd145dbc8..0908c3d76cc 100644 --- a/.github/scripts/triage_with_llm.py +++ b/.github/scripts/triage_with_llm.py @@ -597,6 +597,11 @@ def build_issue_prompt(*, title: str, body: str) -> str: that it does not today). - Motivation / use case with a concrete example (config, API call, UI flow, or scenario showing what's blocked today). + - END-TO-END EVIDENCE OF THE DEAD-END: a video, a screenshot, or the + exact command(s) actually run paired with their real output, + showing the point where the flow stops today. Mocked or stubbed + dependencies do NOT count, and an unfilled template scaffold + (bare headings, empty numbered lists) counts as absent. For an issue that is neither a bug report nor a feature request (a question, support request, or discussion), PASS as long as it has a diff --git a/tests/test_litellm/test_github_triage_with_llm.py b/tests/test_litellm/test_github_triage_with_llm.py index 300fd7c0710..3172dc93e82 100644 --- a/tests/test_litellm/test_github_triage_with_llm.py +++ b/tests/test_litellm/test_github_triage_with_llm.py @@ -678,6 +678,19 @@ class TestBuildPrompts: assert "unfilled template scaffold" in normalized assert "counts as absent, not as evidence" in normalized + def test_issue_feature_rubric_requires_evidence_of_the_dead_end( + self, triage_module + ): + # The feature form asks the requester to walk the ideal flow against a + # live proxy and paste output up to the step that dead-ends, so the + # judge has to demand that evidence, and must not accept an unedited + # scaffold of bare headings as if it were a real attempt. + prompt = triage_module.build_issue_prompt(title="t", body="x") + normalized = " ".join(prompt.split()) + assert "END-TO-END EVIDENCE OF THE DEAD-END" in normalized + assert "showing the point where the flow stops today" in normalized + assert "unfilled template scaffold" in normalized + def test_should_not_crash_when_pr_body_contains_curly_braces(self, triage_module): """User-supplied content with `{` / `}` must NOT be re-parsed by `str.format()`. `format` only scans the template literal for From 1d9500066e31fc2c69012b809f8154978dea197b Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 02:59:10 +0000 Subject: [PATCH 05/10] fix: move feature request guidance out of prefilled values so required means filled Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/feature_request.yml | 52 ++++++++-------------- 1 file changed, 18 insertions(+), 34 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index fc79f5af6d2..5fbed194223 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -27,18 +27,16 @@ body: id: user-flow attributes: label: User Flow - description: Two numbered lists walking the same end user through the same task, one as it goes today and one as it would ideally go with the feature. Keep the guidance comments in the box while you fill it in, they explain every rule. - value: | - - - Before this feature (today): - - 1. - 2. - 3. - - After this feature (ideal user flow): - - 1. - 2. - 3. validations: required: true - type: textarea id: how-far-you-got attributes: label: How far you got - description: Walk the "After this feature (ideal user flow)" list against a live proxy and paste the commands and output up to the step where you get stuck. Keep the guidance comments in the box while you fill it in, they explain every rule. - value: | - + description: | + Run as many steps of the "After this feature (ideal user flow)" list as you can against a live proxy you ran yourself (e.g., `litellm --config config.yaml --detailed_debug` on localhost:4000), then paste the commands (e.g., curl) and their full output, ending at the step that dead-ends. Every rule below applies. + - Say plainly what stopped you there, in user terms: the option you passed came back ignored, the response 400'd naming an unsupported field, there is no button on the page for it. This is what proves the feature is genuinely missing rather than undocumented, and it is the single most useful thing you can give us + - No mocks. Where the flow involves a provider call, hit the real provider API, even though that costs real $. `pytest` commands are not enough + - Include the config.yaml (or SDK setup) and env vars the proxy ran with, plus the version or commit you were on. Keep the real values for env vars that aren't sensitive, and redact only the secrets: never paste a real API key, virtual key, database URL, or other credential, here or anywhere else in the issue + - If the provider already supports this, link their API docs and paste a direct call to them succeeding, so we can see the shape LiteLLM should be sending + - For UI asks: include screenshots of the page you got stuck on and its URL. Scrub keys and tokens out of screenshots too, they show up in headers, request panels, and the Admin UI + placeholder: | Config / setup the proxy ran with: Version or commit: @@ -86,7 +71,6 @@ body: Commands and their full output, up to the step that dead-ends: What stopped me there: - validations: required: true - type: checkboxes From 4eb4511f20267c392b5386d4f37d4a63169fc7f1 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:35:22 -0700 Subject: [PATCH 06/10] chore: make it clearer --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 02cc8e024df..a3c24b84ea8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ When creating PRs, don't set base to `main`. `litellm_internal_staging` is the d When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule -Same applies for filing bug reports and feature requests, and .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml +Same applies for filing bug reports and feature requests, with .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml, respectively If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank From cf1766be1bb49f61c020ed2ba8def74f9be46454 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 16:26:10 +0000 Subject: [PATCH 07/10] chore: drop the feature request attempt attestation and tighten its wording Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/feature_request.yml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 5fbed194223..30a7e839eb3 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -58,11 +58,11 @@ body: description: | Run as many steps of the "After this feature (ideal user flow)" list as you can against a live proxy you ran yourself (e.g., `litellm --config config.yaml --detailed_debug` on localhost:4000), then paste the commands (e.g., curl) and their full output, ending at the step that dead-ends. Every rule below applies. - - Say plainly what stopped you there, in user terms: the option you passed came back ignored, the response 400'd naming an unsupported field, there is no button on the page for it. This is what proves the feature is genuinely missing rather than undocumented, and it is the single most useful thing you can give us - - No mocks. Where the flow involves a provider call, hit the real provider API, even though that costs real $. `pytest` commands are not enough + - Say plainly what stopped you there, in user terms: the option you passed came back ignored, the response 400'd naming an unsupported field, there is no button on the page for it. This is what proves the feature is genuinely missing rather than undocumented + - No mocks. Where the flow involves a provider call, hit the real provider API, even if it costs real $$$. `pytest` commands are not enough - Include the config.yaml (or SDK setup) and env vars the proxy ran with, plus the version or commit you were on. Keep the real values for env vars that aren't sensitive, and redact only the secrets: never paste a real API key, virtual key, database URL, or other credential, here or anywhere else in the issue - If the provider already supports this, link their API docs and paste a direct call to them succeeding, so we can see the shape LiteLLM should be sending - - For UI asks: include screenshots of the page you got stuck on and its URL. Scrub keys and tokens out of screenshots too, they show up in headers, request panels, and the Admin UI + - For UI asks: include screenshots of the page you got stuck on and its URL. Scrub keys and tokens out of screenshots too (for example, the virtual key is briefly shown in the panel right after you create a virtual key) placeholder: | Config / setup the proxy ran with: @@ -73,13 +73,6 @@ body: What stopped me there: validations: required: true - - type: checkboxes - id: attempt-attestation - attributes: - label: About that attempt - options: - - label: I ran it against a live proxy myself, with no mocks, and the output above is what I actually got back - required: true - type: dropdown id: component attributes: From d4468ba63ab3c2a5048935ad5b5d53327ac684ba Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 16:31:27 +0000 Subject: [PATCH 08/10] chore: say real $ instead of $$$ in the feature request attempt rules Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 30a7e839eb3..41b097041f1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -59,7 +59,7 @@ body: Run as many steps of the "After this feature (ideal user flow)" list as you can against a live proxy you ran yourself (e.g., `litellm --config config.yaml --detailed_debug` on localhost:4000), then paste the commands (e.g., curl) and their full output, ending at the step that dead-ends. Every rule below applies. - Say plainly what stopped you there, in user terms: the option you passed came back ignored, the response 400'd naming an unsupported field, there is no button on the page for it. This is what proves the feature is genuinely missing rather than undocumented - - No mocks. Where the flow involves a provider call, hit the real provider API, even if it costs real $$$. `pytest` commands are not enough + - No mocks. Where the flow involves a provider call, hit the real provider API, even if it costs real $. `pytest` commands are not enough - Include the config.yaml (or SDK setup) and env vars the proxy ran with, plus the version or commit you were on. Keep the real values for env vars that aren't sensitive, and redact only the secrets: never paste a real API key, virtual key, database URL, or other credential, here or anywhere else in the issue - If the provider already supports this, link their API docs and paste a direct call to them succeeding, so we can see the shape LiteLLM should be sending - For UI asks: include screenshots of the page you got stuck on and its URL. Scrub keys and tokens out of screenshots too (for example, the virtual key is briefly shown in the panel right after you create a virtual key) From e59da407503145a2087253f89cb1955d645a309b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:43:16 -0700 Subject: [PATCH 09/10] fix(triage): ask for dead-end evidence in feature request recovery comments --- .github/scripts/triage_with_llm.py | 13 +++++++++---- .../test_litellm/test_github_triage_with_llm.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/.github/scripts/triage_with_llm.py b/.github/scripts/triage_with_llm.py index 0908c3d76cc..6499a063b39 100644 --- a/.github/scripts/triage_with_llm.py +++ b/.github/scripts/triage_with_llm.py @@ -843,8 +843,11 @@ def format_issue_close_comment(verdict: dict) -> str: "video, a screenshot, or the exact commands you ran with their real output / " "traceback) plus expected vs. actual behavior. Written steps with no run output, " "video, or screenshot don't count, and mocked or stubbed runs don't count.\n" - " - For **feature requests**: a concrete description of what should change, plus a " - "use case and example (config / API call / UI flow).\n" + " - For **feature requests**: a concrete description of what should change, a " + "use case and example (config / API call / UI flow), plus end-to-end evidence of " + "the dead-end (a video, a screenshot, or the exact commands you ran with their " + "real output showing where the flow stops today). Mocked or stubbed runs don't " + "count.\n" "2. Comment `@agent-shin reconsider`. I'll re-run triage and reopen the issue if it " "now meets the bar. (GitHub doesn't let external authors reopen an issue a maintainer " "or bot closed, so the comment-based reconsider is the reliable path.)\n" @@ -950,8 +953,10 @@ def format_grace_warning_issue_comment(verdict: dict) -> str: "screenshot, or the exact commands you ran with their real output / traceback) plus " "expected vs. actual behavior. Written steps with no run output don't count, and " "mocked or stubbed runs don't count.\n" - "- For **feature requests**: a concrete description of what should change, plus a use " - "case and example (config / API call / UI flow).\n" + "- For **feature requests**: a concrete description of what should change, a use " + "case and example (config / API call / UI flow), plus end-to-end evidence of the " + "dead-end (a video, a screenshot, or the exact commands you ran with their real " + "output showing where the flow stops today). Mocked or stubbed runs don't count.\n" "\n" "**If the issue does get auto-closed in 2 hours**, comment `@agent-shin reconsider` " "and I'll re-evaluate. If it now meets the bar, I'll reopen the issue.\n" diff --git a/tests/test_litellm/test_github_triage_with_llm.py b/tests/test_litellm/test_github_triage_with_llm.py index 3172dc93e82..50b198f62c5 100644 --- a/tests/test_litellm/test_github_triage_with_llm.py +++ b/tests/test_litellm/test_github_triage_with_llm.py @@ -207,6 +207,23 @@ class TestCloseCommentText: assert "end-to-end qa proof" in body.lower() assert "mock" in body.lower() + def test_issue_recovery_comments_should_name_feature_dead_end_evidence( + self, triage_module + ): + # The feature-request pass bar demands end-to-end evidence of the + # dead-end, so the close and grace-warning recovery bullets must ask + # for it too — otherwise a requester follows those exact instructions + # (description + use case only) and fails `reconsider` again with no + # hint of what else was needed. + verdict = {"verdict": "fail", "missing": [], "explanation": ""} + for body in ( + triage_module.format_issue_close_comment(verdict), + triage_module.format_grace_warning_issue_comment(verdict), + ): + normalized = " ".join(body.split()) + assert "end-to-end evidence of the dead-end" in normalized + assert "showing where the flow stops today" in normalized + def test_all_agent_shin_comments_should_use_bullet_train_emoji(self, triage_module): # The bullet train (🚅) is Agent Shin's symbol, matching the LiteLLM # logo; the previous wave (👋) was generic and didn't match the bot's From 812abcc7f55153cf5ac11a49d76c0b3a6775f4e3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:57:46 -0700 Subject: [PATCH 10/10] fix(triage): track and credit feature dead-end evidence in the verdict --- .github/scripts/triage_with_llm.py | 17 ++++++++---- .../test_github_triage_with_llm.py | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/.github/scripts/triage_with_llm.py b/.github/scripts/triage_with_llm.py index 6499a063b39..e23a012425a 100644 --- a/.github/scripts/triage_with_llm.py +++ b/.github/scripts/triage_with_llm.py @@ -597,11 +597,13 @@ def build_issue_prompt(*, title: str, body: str) -> str: that it does not today). - Motivation / use case with a concrete example (config, API call, UI flow, or scenario showing what's blocked today). - - END-TO-END EVIDENCE OF THE DEAD-END: a video, a screenshot, or the - exact command(s) actually run paired with their real output, - showing the point where the flow stops today. Mocked or stubbed - dependencies do NOT count, and an unfilled template scaffold - (bare headings, empty numbered lists) counts as absent. + - END-TO-END EVIDENCE OF THE DEAD-END (set + `has_dead_end_evidence=true` only when this is present): a video, + a screenshot, or the exact command(s) actually run paired with + their real output, showing the point where the flow stops today. + Mocked or stubbed dependencies do NOT count, and an unfilled + template scaffold (bare headings, empty numbered lists) counts as + absent. For an issue that is neither a bug report nor a feature request (a question, support request, or discussion), PASS as long as it has a @@ -615,6 +617,7 @@ def build_issue_prompt(*, title: str, body: str) -> str: "has_repro": boolean, "has_expected_vs_actual": boolean, "has_motivation_example": boolean, + "has_dead_end_evidence": boolean, "missing": ["plain-english strings naming what is missing"], "explanation": "1-2 sentence reasoning for the team to skim" }} @@ -712,6 +715,10 @@ _ISSUE_BUG_LABELS: tuple[tuple[str, str], ...] = ( ) _ISSUE_FEATURE_LABELS: tuple[tuple[str, str], ...] = ( ("has_motivation_example", "Motivation and concrete example"), + ( + "has_dead_end_evidence", + "End-to-end evidence of the dead-end (video, screenshot, or command + real output)", + ), ) diff --git a/tests/test_litellm/test_github_triage_with_llm.py b/tests/test_litellm/test_github_triage_with_llm.py index 50b198f62c5..96b77e80457 100644 --- a/tests/test_litellm/test_github_triage_with_llm.py +++ b/tests/test_litellm/test_github_triage_with_llm.py @@ -306,6 +306,27 @@ class TestCloseCommentText: assert "Expected vs. actual behavior" in body assert "- ✅ End-to-end evidence of the bug" not in body + def test_issue_close_comment_should_credit_feature_dead_end_evidence( + self, triage_module + ): + # A feature requester who pasted their dead-end run but skipped the + # motivation must see the evidence credited and only the motivation + # listed as a gap — without a dedicated verdict field the praise + # block could never acknowledge the work they did do. + body = triage_module.format_issue_close_comment( + { + "verdict": "fail", + "kind": "feature", + "has_motivation_example": False, + "has_dead_end_evidence": True, + "missing": ["motivation / use case"], + "explanation": "no use case given", + } + ) + assert "What you got right" in body + assert "- ✅ End-to-end evidence of the dead-end" in body + assert "- ✅ Motivation and concrete example" not in body + def test_close_comments_should_use_softer_park_for_later_framing( self, triage_module ): @@ -707,6 +728,11 @@ class TestBuildPrompts: assert "END-TO-END EVIDENCE OF THE DEAD-END" in normalized assert "showing the point where the flow stops today" in normalized assert "unfilled template scaffold" in normalized + # The evidence has its own verdict field so feature requesters who + # provided it get credited in "What you got right", exactly like + # `has_repro` credits bug evidence. + assert "`has_dead_end_evidence=true` only when this is present" in normalized + assert '"has_dead_end_evidence": boolean' in normalized def test_should_not_crash_when_pr_body_contains_curly_braces(self, triage_module): """User-supplied content with `{` / `}` must NOT be re-parsed by