diff --git a/.github/sync-manifest.yml b/.github/sync-manifest.yml
index 0d159b962..652a32914 100644
--- a/.github/sync-manifest.yml
+++ b/.github/sync-manifest.yml
@@ -584,6 +584,7 @@ scripts:
# (format step: `python scripts/langchain/issue_formatter.py`), which does NOT
# runtime-fetch scripts/langchain. Must stay copy-synced.
delivery: copy
+ template_sync: exact
- source: scripts/langchain/issue_pr_context.py
description: "Issue/PR context assembly helper with shared token budget caps"
diff --git a/langsmith-fleet-worker-attempt.json b/langsmith-fleet-worker-attempt.json
index 7b15a95be..a370bf0da 100644
--- a/langsmith-fleet-worker-attempt.json
+++ b/langsmith-fleet-worker-attempt.json
@@ -1,13 +1,13 @@
{
"agent": "codex",
"cli_version": "0.144.1",
- "emitted_at": "2026-08-09T02:16:27.270949Z",
+ "emitted_at": "2026-08-09T02:47:58.915158Z",
"execution_profile": "codex-default",
"fallback_models": [
"gpt-5.5"
],
"operation_role": "worker",
- "pr_number": "2998",
+ "pr_number": "2999",
"requested_model": "gpt-5.6-terra",
"runner": "reusable-codex-run",
"schema": "langsmith-fleet/v1",
diff --git a/scripts/langchain/issue_formatter.py b/scripts/langchain/issue_formatter.py
index 4f61d51aa..be59c8ad0 100755
--- a/scripts/langchain/issue_formatter.py
+++ b/scripts/langchain/issue_formatter.py
@@ -10,10 +10,12 @@
from __future__ import annotations
import argparse
+import importlib.util
import json
import os
import re
import sys
+from functools import lru_cache
from pathlib import Path
from typing import Any
@@ -46,6 +48,20 @@
# ~4 chars per token, so 50k chars ≈ 12.5k tokens, leaving headroom for prompt + output
MAX_ISSUE_BODY_SIZE = 50000
+
+@lru_cache(maxsize=1)
+def _issue_format_validator() -> Any:
+ """Load the fleet's single issue-format definition without forking it."""
+ validator_path = Path(__file__).resolve().parents[2] / ".github/scripts/issue_format.py"
+ spec = importlib.util.spec_from_file_location("_fleet_issue_format", validator_path)
+ if spec is None or spec.loader is None: # pragma: no cover - repository invariant
+ raise RuntimeError(f"Cannot load canonical issue-format validator: {validator_path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
# Workflow tags written into the reuse marker. Tagging every stage of the
# auto-pilot format -> optimize -> apply chain lets any stage detect a body it (or
# a sibling stage) already formatted and skip re-deriving it, which is the
@@ -151,6 +167,7 @@ def _strip_reuse_marker(text: str) -> str:
LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$")
CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$")
+VERIFY_HINT_REGEX = re.compile(r"\(verify:\s*([^\n)]+)\)", re.IGNORECASE)
def _context_token_budget() -> int:
@@ -355,6 +372,22 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str:
impl_text = join_or_placeholder(impl_lines, "_Not provided._")
tasks_text = join_or_placeholder(tasks_lines, "- [ ] _Not provided._")
acceptance_text = join_or_placeholder(acceptance_lines, "- [ ] _Not provided._")
+ try:
+ validator = _issue_format_validator()
+ except (ImportError, OSError, RuntimeError, SyntaxError):
+ # Consumer checkouts can be mid-sync or missing the canonical validator.
+ # Keep the pre-validator fallback usable instead of failing the formatter.
+ validator = None
+ if validator is not None and not validator.GATE.search(acceptance_text):
+ verify_hint = VERIFY_HINT_REGEX.search(tasks_text)
+ if verify_hint:
+ command = verify_hint.group(1).strip().strip("`")
+ if command.startswith("pytest "):
+ command = f"python3 -m {command}"
+ acceptance_text = (
+ f"{acceptance_text}\n"
+ f"- [ ] Run `{command}` and capture the command output in PR validation evidence."
+ )
parts = [
"## Why",
@@ -387,8 +420,12 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str:
def _formatted_output_valid(text: str) -> bool:
if not text:
return False
- required = ["## Tasks", "## Acceptance Criteria"]
- return all(section in text for section in required)
+ try:
+ return bool(_issue_format_validator().validate(text).ok)
+ except (ImportError, OSError, RuntimeError, SyntaxError):
+ # Preserve the former heading-only behavior until the copy-synced
+ # validator becomes available again.
+ return all(section in text for section in ("## Tasks", "## Acceptance Criteria"))
def _select_code_fence(text: str) -> str:
@@ -398,12 +435,10 @@ def _select_code_fence(text: str) -> str:
ORIGINAL_ISSUE_SUMMARY = "Original Issue"
-# Matches an Original-Issue block (and trailing whitespace) so it can
-# be replaced rather than nested. Non-greedy body, anchored to the closing tag.
-_ORIGINAL_ISSUE_BLOCK_RE = re.compile(
- r"\s*Original Issue
.*? [ \t]*\n?",
- re.DOTALL | re.IGNORECASE,
+_ORIGINAL_ISSUE_OPEN_RE = re.compile(
+ r"]*>\s*Original Issue
", re.IGNORECASE
)
+_DETAILS_TAG_RE = re.compile(r"?details\b[^>]*>", re.IGNORECASE)
# Captures the verbatim text fenced inside an Original-Issue block, so an
# already-embedded original can be recovered (and re-embedded once) instead of
# being wrapped again.
@@ -415,8 +450,25 @@ def _select_code_fence(text: str) -> str:
def _strip_original_issue_blocks(text: str) -> str:
- """Remove any embedded Original-Issue block(s) from ``text``."""
- return _ORIGINAL_ISSUE_BLOCK_RE.sub("", text).rstrip()
+ """Remove complete embedded Original-Issue blocks, including nested details."""
+ kept: list[str] = []
+ cursor = 0
+ while match := _ORIGINAL_ISSUE_OPEN_RE.search(text, cursor):
+ kept.append(text[cursor : match.start()])
+ depth = 1
+ end = match.end()
+ for tag in _DETAILS_TAG_RE.finditer(text, match.end()):
+ depth += -1 if tag.group(0).startswith("") else 1
+ if depth == 0:
+ end = tag.end()
+ break
+ else:
+ # Leave malformed markup intact rather than silently discarding it.
+ kept.append(text[match.start() :])
+ return "".join(kept).rstrip()
+ cursor = end
+ kept.append(text[cursor:])
+ return "".join(kept).rstrip()
def _innermost_original_issue(text: str) -> str | None:
@@ -705,6 +757,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any
pass
formatted = _format_issue_fallback(issue_body)
+ needs_refinement = not _formatted_output_valid(formatted)
# NOTE: Task decomposition is now handled by agents:optimize step
# which uses LLM for intelligent splitting. Don't do heuristic
# splitting here - it causes task explosion (issue #805, #1143).
@@ -716,6 +769,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any
"provider_used": None,
"used_llm": False,
"validation_audit": audit,
+ "needs_refinement": needs_refinement,
}
diff --git a/templates/consumer-repo/scripts/langchain/issue_formatter.py b/templates/consumer-repo/scripts/langchain/issue_formatter.py
index 367510f6e..be59c8ad0 100755
--- a/templates/consumer-repo/scripts/langchain/issue_formatter.py
+++ b/templates/consumer-repo/scripts/langchain/issue_formatter.py
@@ -10,28 +10,96 @@
from __future__ import annotations
import argparse
+import importlib.util
import json
import os
import re
import sys
+from functools import lru_cache
from pathlib import Path
from typing import Any
try:
+ from scripts.langchain._llm_client import get_llm_client as _get_llm_client
from scripts.langchain.checklist_utils import is_placeholder_checklist_text
from scripts.langchain.injection_guard import check_prompt_injection
- from scripts.langchain.issue_pr_context import ContextOptions, build_issue_context
+ from scripts.langchain.issue_pr_context import (
+ ContextOptions,
+ already_conformant,
+ build_formatted_body_marker,
+ build_issue_context,
+ reuse_formatted_body,
+ )
from scripts.langchain.trace_utils import TraceInfo, invoke_with_trace
except ImportError: # pragma: no cover - fallback for direct invocation
+ from _llm_client import get_llm_client as _get_llm_client
from checklist_utils import is_placeholder_checklist_text
from injection_guard import check_prompt_injection
- from issue_pr_context import ContextOptions, build_issue_context
+ from issue_pr_context import (
+ ContextOptions,
+ already_conformant,
+ build_formatted_body_marker,
+ build_issue_context,
+ reuse_formatted_body,
+ )
from trace_utils import TraceInfo, invoke_with_trace
# Maximum issue body size to prevent OpenAI rate limit errors (30k TPM limit)
# ~4 chars per token, so 50k chars ≈ 12.5k tokens, leaving headroom for prompt + output
MAX_ISSUE_BODY_SIZE = 50000
+
+@lru_cache(maxsize=1)
+def _issue_format_validator() -> Any:
+ """Load the fleet's single issue-format definition without forking it."""
+ validator_path = Path(__file__).resolve().parents[2] / ".github/scripts/issue_format.py"
+ spec = importlib.util.spec_from_file_location("_fleet_issue_format", validator_path)
+ if spec is None or spec.loader is None: # pragma: no cover - repository invariant
+ raise RuntimeError(f"Cannot load canonical issue-format validator: {validator_path}")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+# Workflow tags written into the reuse marker. Tagging every stage of the
+# auto-pilot format -> optimize -> apply chain lets any stage detect a body it (or
+# a sibling stage) already formatted and skip re-deriving it, which is the
+# primary defense against re-run amplification.
+REUSE_MARKER_WORKFLOWS = (
+ "agents-auto-pilot",
+ "agents-issue-optimizer",
+ "agents-63-issue-intake",
+ "issue_formatter",
+ "issue_optimizer",
+)
+
+
+def _with_reuse_marker(formatted: str) -> str:
+ """Append (or refresh) the reuse marker that lets later stages skip re-formatting.
+
+ The marker stores a sha256 fingerprint of the formatted body (hash-only). A
+ stale marker (body edited after formatting) simply fails the hash check
+ downstream and is ignored, so this is always safe to (re)write.
+ """
+ body = _strip_reuse_marker(formatted).rstrip()
+ marker = build_formatted_body_marker(
+ workflows=list(REUSE_MARKER_WORKFLOWS),
+ formatted_body=body,
+ embed_body=False, # hash-only: the formatted body is written back in full anyway
+ )
+ return f"{body}\n\n{marker}\n"
+
+
+def _strip_reuse_marker(text: str) -> str:
+ try:
+ from scripts.langchain.issue_pr_context import MARKER_RE
+ except ImportError: # pragma: no cover - fallback for direct invocation
+ from issue_pr_context import MARKER_RE
+
+ return MARKER_RE.sub("", text).rstrip()
+
+
ISSUE_FORMATTER_PROMPT = """
You are a formatting assistant. Convert the raw GitHub issue body into the
AGENT_ISSUE_TEMPLATE format with the exact section headers in order:
@@ -52,6 +120,14 @@
for Tasks/Acceptance).
- Output ONLY the formatted markdown with these sections (no extra commentary).
+Length & scope discipline (improve clarity, do NOT inflate):
+- Improve clarity WITHOUT increasing total length. Do not add a task, criterion,
+ or sentence unless it fills a genuinely missing mandatory section.
+- Preserve scope; do NOT invent file paths, functions, tests, or criteria the
+ source does not imply, and do NOT manufacture prose to fill placeholders.
+- Never restate the same point under two headings; never split a task that is
+ already a single ~10-minute action.
+
Raw issue body:
{issue_body}
""".strip()
@@ -91,6 +167,7 @@
LIST_ITEM_REGEX = re.compile(r"^(\s*)([-*+]|\d+[.)]|[A-Za-z][.)])\s+(.*)$")
CHECKBOX_REGEX = re.compile(r"^\[([ xX])\]\s*(.*)$")
+VERIFY_HINT_REGEX = re.compile(r"\(verify:\s*([^\n)]+)\)", re.IGNORECASE)
def _context_token_budget() -> int:
@@ -126,24 +203,6 @@ def _load_prompt() -> str:
return base_prompt
-def _get_llm_client(force_openai: bool = False) -> tuple[object, str] | None:
- """Get LLM client, trying GitHub Models first (cheaper), then OpenAI.
-
- Args:
- force_openai: If True, skip GitHub Models and use OpenAI directly.
- Use this for retry after GitHub Models 401 error.
- """
- try:
- from tools.langchain_client import build_chat_client
- except ImportError:
- return None
-
- resolved = build_chat_client(force_openai=force_openai)
- if not resolved:
- return None
- return resolved.client, resolved.provider
-
-
def _normalize_heading(text: str) -> str:
cleaned = re.sub(r"[#*_:]+", " ", text).strip().lower()
cleaned = re.sub(r"\s+", " ", cleaned)
@@ -313,6 +372,22 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str:
impl_text = join_or_placeholder(impl_lines, "_Not provided._")
tasks_text = join_or_placeholder(tasks_lines, "- [ ] _Not provided._")
acceptance_text = join_or_placeholder(acceptance_lines, "- [ ] _Not provided._")
+ try:
+ validator = _issue_format_validator()
+ except (ImportError, OSError, RuntimeError, SyntaxError):
+ # Consumer checkouts can be mid-sync or missing the canonical validator.
+ # Keep the pre-validator fallback usable instead of failing the formatter.
+ validator = None
+ if validator is not None and not validator.GATE.search(acceptance_text):
+ verify_hint = VERIFY_HINT_REGEX.search(tasks_text)
+ if verify_hint:
+ command = verify_hint.group(1).strip().strip("`")
+ if command.startswith("pytest "):
+ command = f"python3 -m {command}"
+ acceptance_text = (
+ f"{acceptance_text}\n"
+ f"- [ ] Run `{command}` and capture the command output in PR validation evidence."
+ )
parts = [
"## Why",
@@ -345,8 +420,12 @@ def join_or_placeholder(lines: list[str], placeholder: str) -> str:
def _formatted_output_valid(text: str) -> bool:
if not text:
return False
- required = ["## Tasks", "## Acceptance Criteria"]
- return all(section in text for section in required)
+ try:
+ return bool(_issue_format_validator().validate(text).ok)
+ except (ImportError, OSError, RuntimeError, SyntaxError):
+ # Preserve the former heading-only behavior until the copy-synced
+ # validator becomes available again.
+ return all(section in text for section in ("## Tasks", "## Acceptance Criteria"))
def _select_code_fence(text: str) -> str:
@@ -355,14 +434,91 @@ def _select_code_fence(text: str) -> str:
return "`" * fence_len
+ORIGINAL_ISSUE_SUMMARY = "Original Issue
"
+_ORIGINAL_ISSUE_OPEN_RE = re.compile(
+ r"]*>\s*Original Issue
", re.IGNORECASE
+)
+_DETAILS_TAG_RE = re.compile(r"?details\b[^>]*>", re.IGNORECASE)
+# Captures the verbatim text fenced inside an Original-Issue block, so an
+# already-embedded original can be recovered (and re-embedded once) instead of
+# being wrapped again.
+_ORIGINAL_ISSUE_INNER_RE = re.compile(
+ r"\s*Original Issue
\s*"
+ r"(?P`{3,})text\n(?P.*?)\n(?P=fence)\s* ",
+ re.DOTALL | re.IGNORECASE,
+)
+
+
+def _strip_original_issue_blocks(text: str) -> str:
+ """Remove complete embedded Original-Issue blocks, including nested details."""
+ kept: list[str] = []
+ cursor = 0
+ while match := _ORIGINAL_ISSUE_OPEN_RE.search(text, cursor):
+ kept.append(text[cursor : match.start()])
+ depth = 1
+ end = match.end()
+ for tag in _DETAILS_TAG_RE.finditer(text, match.end()):
+ depth += -1 if tag.group(0).startswith("") else 1
+ if depth == 0:
+ end = tag.end()
+ break
+ else:
+ # Leave malformed markup intact rather than silently discarding it.
+ kept.append(text[match.start() :])
+ return "".join(kept).rstrip()
+ cursor = end
+ kept.append(text[cursor:])
+ return "".join(kept).rstrip()
+
+
+def _innermost_original_issue(text: str) -> str | None:
+ """Return the deepest verbatim Original-Issue payload embedded in ``text``.
+
+ Nested blocks (from prior runaway cycles) are unwrapped layer by layer so the
+ true original is recovered, not a copy-of-a-copy.
+ """
+ inner: str | None = None
+ current = text
+ while True:
+ match = _ORIGINAL_ISSUE_INNER_RE.search(current)
+ if not match:
+ break
+ inner = match.group("inner")
+ current = inner
+ return inner
+
+
def _append_raw_issue_section(formatted: str, issue_body: str) -> str:
- raw = issue_body.strip()
+ """Embed the verbatim original issue once, idempotently.
+
+ Earlier behavior only checked whether the *input* already contained an
+ Original-Issue block, which let the block nest across auto-pilot cycles
+ (each pass re-wrapped the whole prior body — the 5-level nesting seen in
+ incident #1135). This version is idempotent: it recovers the innermost
+ verbatim original (from either the raw source or an already-embedded block),
+ strips every Original-Issue block from the formatted output, then appends
+ exactly one fresh block. Re-running on already-embedded output reproduces the
+ same single block.
+ """
+ # Recover the verbatim original, preferring the most authoritative source:
+ # 1. the innermost embedded original in the raw source (the canonical input),
+ # 2. the raw source minus any Original-Issue wrapper,
+ # 3. the innermost embedded original in the formatted output (covers the
+ # edge where the output already carries a block but the raw arg does not
+ # — the exact pre-fix nesting vector).
+ # This preserves the true original instead of re-embedding reformatted text.
+ raw = _innermost_original_issue(issue_body)
+ if raw is None:
+ raw = _strip_original_issue_blocks(issue_body.strip())
+ raw = raw.strip()
if not raw:
- return formatted
- marker = "Original Issue
"
- # Check INPUT body, not output - if input already has Original Issue, don't nest another
- if marker in raw:
- return formatted
+ recovered = _innermost_original_issue(formatted)
+ raw = recovered.strip() if recovered else ""
+ formatted_wo_block = _strip_original_issue_blocks(formatted)
+ if not raw:
+ # Nothing to embed. If the formatted body already had a block it has been
+ # stripped above; return the cleaned form so no stale nested copy remains.
+ return formatted_wo_block if ORIGINAL_ISSUE_SUMMARY in formatted else formatted
fence = _select_code_fence(raw)
details = (
"\n\n\n"
@@ -370,7 +526,7 @@ def _append_raw_issue_section(formatted: str, issue_body: str) -> str:
f"{fence}text\n{raw}\n{fence}\n"
" "
)
- return f"{formatted.rstrip()}{details}\n"
+ return f"{formatted_wo_block.rstrip()}{details}\n"
def _extract_tasks_from_formatted(body: str) -> list[str]:
@@ -473,6 +629,39 @@ def _is_github_models_auth_error(exc: Exception) -> bool:
return "401" in exc_str and "models" in exc_str
+def _reuse_already_formatted(issue_body: str, workflow: str) -> dict[str, Any] | None:
+ """Return a short-circuit result if ``issue_body`` is already formatted.
+
+ Two idempotency signals, in order of trust:
+
+ 1. A reuse marker whose embedded hash matches the visible body — the body is
+ byte-identical to a prior formatter output for this workflow chain.
+ 2. The body is structurally conformant (all template sections + an embedded
+ Original-Issue block).
+
+ In either case the body is returned unchanged (modulo a refreshed marker) so
+ no LLM rewrite occurs. Returns ``None`` when the body still needs formatting.
+ """
+ reused = reuse_formatted_body({"body": issue_body}, workflow)
+ if reused is not None:
+ return {
+ "formatted_body": _with_reuse_marker(reused),
+ "provider_used": None,
+ "used_llm": False,
+ "skipped": "reused_marker",
+ "validation_audit": None,
+ }
+ if already_conformant(issue_body):
+ return {
+ "formatted_body": _with_reuse_marker(issue_body),
+ "provider_used": None,
+ "used_llm": False,
+ "skipped": "already_conformant",
+ "validation_audit": None,
+ }
+ return None
+
+
def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any]:
if not issue_body:
issue_body = ""
@@ -487,7 +676,17 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any
"guard_reason": guard_result["reason"],
}
- issue_body = _capped_issue_body(issue_body, _context_workflow("issue_formatter"))
+ # Idempotency / anti-amplification: before re-deriving anything, check whether
+ # this body has already been formatted. Re-formatting an already-conformant
+ # body only paraphrases prior output and is the primary runaway-expansion
+ # vector (incidents #1135/#1143). Done on the *uncapped* body so detection
+ # still works on large already-formatted issues.
+ workflow = _context_workflow("issue_formatter")
+ reuse = _reuse_already_formatted(issue_body, workflow)
+ if reuse is not None:
+ return reuse
+
+ issue_body = _capped_issue_body(issue_body, workflow)
# Check size before processing to avoid rate limit errors
if len(issue_body) > MAX_ISSUE_BODY_SIZE:
@@ -544,6 +743,7 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any
# splitting here - it causes task explosion (issue #805, #1143).
formatted, audit = _validate_and_refine_tasks(formatted, use_llm=use_llm)
formatted = _append_raw_issue_section(formatted, issue_body)
+ formatted = _with_reuse_marker(formatted)
result = {
"formatted_body": formatted,
"provider_used": provider,
@@ -557,16 +757,19 @@ def format_issue_body(issue_body: str, *, use_llm: bool = True) -> dict[str, Any
pass
formatted = _format_issue_fallback(issue_body)
+ needs_refinement = not _formatted_output_valid(formatted)
# NOTE: Task decomposition is now handled by agents:optimize step
# which uses LLM for intelligent splitting. Don't do heuristic
# splitting here - it causes task explosion (issue #805, #1143).
formatted, audit = _validate_and_refine_tasks(formatted, use_llm=use_llm)
formatted = _append_raw_issue_section(formatted, issue_body)
+ formatted = _with_reuse_marker(formatted)
return {
"formatted_body": formatted,
"provider_used": None,
"used_llm": False,
"validation_audit": audit,
+ "needs_refinement": needs_refinement,
}
diff --git a/tests/scripts/test_issue_formatter.py b/tests/scripts/test_issue_formatter.py
index c1c88dcbd..ff4666afd 100644
--- a/tests/scripts/test_issue_formatter.py
+++ b/tests/scripts/test_issue_formatter.py
@@ -11,6 +11,10 @@
from scripts.langchain.issue_pr_context import reuse_formatted_body
+def _canonical_issue_format():
+ return issue_formatter._issue_format_validator()
+
+
def _install_fake_langchain(monkeypatch: pytest.MonkeyPatch, mock_chain: mock.MagicMock) -> None:
mock_template = mock.MagicMock()
mock_template.__or__ = mock.MagicMock(return_value=mock_chain)
@@ -64,6 +68,23 @@ def test_format_issue_fallback_adds_sections_and_checkboxes() -> None:
assert "- [ ] label transition works" in formatted
+def test_format_issue_fallback_adds_acceptance_gate_when_only_tasks_have_verify_hint() -> None:
+ raw = """## Tasks
+- [ ] Update `scripts/langchain/issue_formatter.py` and run `(verify: pytest tests/scripts/test_issue_formatter.py)`.
+
+## Acceptance Criteria
+- [ ] Formatter preserves the source acceptance prose.
+"""
+
+ formatted = issue_formatter.format_issue_body(raw, use_llm=False)["formatted_body"]
+ acceptance = _extract_section(formatted, "Acceptance Criteria")
+
+ assert "python3 -m pytest tests/scripts/test_issue_formatter.py" in acceptance
+ assert "Formatter preserves the source acceptance prose." in acceptance
+ assert _canonical_issue_format().GATE.search(acceptance)
+ assert _canonical_issue_format().validate(formatted).ok is True
+
+
def test_format_issue_fallback_preserves_tasks_without_decomposition() -> None:
"""Formatter preserves tasks as-is; decomposition is done by agents:optimize step.
@@ -112,7 +133,10 @@ def test_format_issue_fallback_uses_placeholders() -> None:
acceptance = _extract_section(formatted, "Acceptance Criteria")
assert tasks == "- [ ] _Not provided._"
- assert acceptance == "- [ ] _Not provided._"
+ assert acceptance.startswith("- [ ] _Not provided._")
+ assert "python3 -m pytest" not in acceptance
+ assert _canonical_issue_format().validate(formatted).ok is False
+ assert result["needs_refinement"] is True
def test_normalize_checklist_lines_drops_placeholder_checkboxes() -> None:
@@ -224,7 +248,11 @@ def test_format_issue_body_llm_path_includes_raw_issue(monkeypatch: pytest.Monke
mock_client = mock.MagicMock()
mock_chain = mock.MagicMock()
mock_response = mock.MagicMock()
- mock_response.content = "## Tasks\n- [ ] Do it\n\n## Acceptance Criteria\n- [ ] Done"
+ mock_response.content = (
+ "## Tasks\n- [ ] Update `scripts/langchain/issue_formatter.py`.\n\n"
+ "## Acceptance Criteria\n"
+ "- [ ] `pytest tests/scripts/test_issue_formatter.py` passes."
+ )
mock_response.response_metadata = {"run_id": "trace-format"}
mock_chain.invoke.return_value = mock_response
@@ -242,6 +270,42 @@ def test_format_issue_body_llm_path_includes_raw_issue(monkeypatch: pytest.Monke
assert "Raw issue text" in result["formatted_body"]
+def test_formatted_output_valid_uses_canonical_contract() -> None:
+ invalid = "## Tasks\n- [ ] Do it\n\n## Acceptance Criteria\n- [ ] Done"
+ valid = (
+ "## Tasks\n- [ ] Update `scripts/langchain/issue_formatter.py`.\n\n"
+ "## Acceptance Criteria\n"
+ "- [ ] `pytest tests/scripts/test_issue_formatter.py` passes."
+ )
+
+ assert issue_formatter._formatted_output_valid(invalid) is False
+ assert issue_formatter._formatted_output_valid(valid) is True
+
+
+def test_formatter_degrades_to_heading_validation_when_validator_cannot_load(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A briefly incomplete consumer sync must not make issue formatting crash."""
+ monkeypatch.setattr(
+ issue_formatter,
+ "_issue_format_validator",
+ mock.MagicMock(side_effect=OSError("validator unavailable")),
+ )
+ raw = """## Tasks
+
+- [ ] Run `(verify: pytest tests/scripts/test_issue_formatter.py)`.
+
+## Acceptance Criteria
+
+- [ ] Preserve a heading-only fallback while the validator is unavailable.
+"""
+
+ formatted = issue_formatter._format_issue_fallback(raw)
+
+ assert issue_formatter._formatted_output_valid(formatted) is True
+ assert "PR validation evidence" not in formatted
+
+
def test_format_issue_body_llm_invalid_output_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
mock_client = mock.MagicMock()
mock_chain = mock.MagicMock()
@@ -552,3 +616,36 @@ def test_append_raw_issue_section_collapses_nested_blocks() -> None:
out = issue_formatter._append_raw_issue_section("## Tasks\n\n- [ ] x", nested)
assert out.count("Original Issue
") == 1
assert out.count("TRUE ORIGINAL") == 1
+
+
+def test_strip_original_issue_blocks_removes_balanced_nested_details() -> None:
+ nested = """## Why
+
+Keep this text.
+
+
+Original Issue
+
+```text
+outer
+
+Original Issue
+
+```text
+inner
+```
+
+```
+
+
+## Scope
+
+Keep this too.
+"""
+
+ stripped = issue_formatter._strip_original_issue_blocks(nested)
+
+ assert "Keep this text." in stripped
+ assert "Keep this too." in stripped
+ assert "Original Issue" not in stripped
+ assert " " not in stripped