23ae44e432
Adds assess_prompt_completeness() to _implementer_prefetch.py — a
pure helper that translates the carrier's per-section completion
flags into a single {degraded, missing_sections, error_kinds} dict.
The aggregate data_complete flag already encoded the AND, but as a
single bool it lost the *which sections* signal an operator needs
to triage a degraded cycle. This helper keeps both together.
dispatch_implementer._prefetch_prompt now calls the helper after
prefetch and stashes the result on the per-item context as
prompt_completeness. When IMPLEMENTER_DEGRADED_PROMPT_LOG_ENABLED=1
(default OFF, per the dmpipeline safety contract) AND the cycle is
degraded, also emits a WARN log line naming the specific missing
sections so an operator tailing the dispatcher log sees the
silent-degradation signal without parsing telemetry JSONL.
The completeness signal is always stashed on the context regardless
of the flag, so future telemetry / status-comment paths can pick it
up without operators flipping any switch. The flag specifically
gates the LOG emission — the harvest plan's primary value
("converts a silent correctness risk into an observable one").
5 unit tests for the helper covering happy / single-fail / multi-
fail / diff-truncation / direct-data_complete-flip paths.
Refs: docs/development/final-working-harvest-plan.md (W8).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
314 lines
13 KiB
Python
314 lines
13 KiB
Python
"""Unit tests for ``tools/_implementer_prefetch.py`` helpers.
|
|
|
|
Focused on the bounded comment-carry logic added for run-8 findings
|
|
R8-2 / R8-3: ``_bounded_comment_view`` decides which comments reach
|
|
the worker prompt + sentinel verbatim — the most-recent N plus every
|
|
non-bot comment — while the bot's older attempt comments are left to
|
|
the ``_attempt_history`` digest.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from .conftest import load_tool_module
|
|
|
|
|
|
@pytest.fixture
|
|
def prefetch_mod():
|
|
return load_tool_module("_implementer_prefetch")
|
|
|
|
|
|
def _attempt_comment(i: int) -> dict:
|
|
"""A bot ``**Implementation Attempt**`` comment (digested, not
|
|
carried verbatim once it ages past the recent window)."""
|
|
return {
|
|
"id": i,
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"body": f"**Implementation Attempt** — Tier 0: qwen — Failed\n\nattempt {i}",
|
|
}
|
|
|
|
|
|
def _human_comment(i: int) -> dict:
|
|
"""A human / reviewer comment — always carried verbatim."""
|
|
return {
|
|
"id": i,
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"body": f"human comment {i}",
|
|
"user": {"login": "drew"},
|
|
}
|
|
|
|
|
|
def _bot_status_comment(i: int, login: str = "HAL9000") -> dict:
|
|
"""A bot status / claim / sentinel comment — bot-authored but does
|
|
NOT carry the structured ``**Implementation Attempt**`` marker
|
|
(so a content-only classifier misses it). On a bot-heavy PR like
|
|
#30 these dominate the comment list and would balloon a naive
|
|
'keep all non-attempt' view (run-10 inspection)."""
|
|
return {
|
|
"id": i,
|
|
"created_at": "2026-01-01T00:00:00Z",
|
|
"body": f"<!-- merge_drive.py: claim --> status update {i}",
|
|
"user": {"login": login},
|
|
}
|
|
|
|
|
|
class TestBoundedCommentView:
|
|
def test_under_cap_returns_all(self, prefetch_mod):
|
|
comments = [_attempt_comment(i) for i in range(10)]
|
|
assert prefetch_mod._bounded_comment_view(comments, 50) == comments
|
|
|
|
def test_over_cap_all_bot_returns_last_n(self, prefetch_mod):
|
|
comments = [_attempt_comment(i) for i in range(120)]
|
|
view = prefetch_mod._bounded_comment_view(comments, 50)
|
|
assert [c["id"] for c in view] == list(range(70, 120))
|
|
|
|
def test_human_comments_always_kept(self, prefetch_mod):
|
|
# 120 comments, all bot attempts except ids 5 and 10 (human).
|
|
# cap=50 → view = the two early human comments + the last 50,
|
|
# order preserved.
|
|
comments = [
|
|
_human_comment(i) if i in (5, 10) else _attempt_comment(i)
|
|
for i in range(120)
|
|
]
|
|
view = prefetch_mod._bounded_comment_view(comments, 50)
|
|
assert [c["id"] for c in view] == [5, 10] + list(range(70, 120))
|
|
|
|
def test_human_in_recent_window_not_duplicated(self, prefetch_mod):
|
|
comments = [_attempt_comment(i) for i in range(120)]
|
|
comments[100] = _human_comment(100) # human AND inside the last 50
|
|
view = prefetch_mod._bounded_comment_view(comments, 50)
|
|
ids = [c["id"] for c in view]
|
|
assert ids == list(range(70, 120))
|
|
assert ids.count(100) == 1
|
|
|
|
def test_empty(self, prefetch_mod):
|
|
assert prefetch_mod._bounded_comment_view([], 50) == []
|
|
|
|
def test_bot_status_comments_dropped_by_author(self, prefetch_mod):
|
|
"""Run-10 bug: an older comment authored by a known bot login
|
|
but lacking the structured attempt marker (claim / status /
|
|
sentinel posts, etc.) must be DROPPED from the view, not kept
|
|
as "human." On PR #30 those non-attempt bot comments were
|
|
1240 of 1480 and ballooned the bounded view to 1252 items.
|
|
Author-based classification catches them; content-only does not.
|
|
"""
|
|
# 200 bot status comments (claim/sentinel/etc., no attempt marker)
|
|
# — these are exactly the comments the old content-only rule
|
|
# mis-classified as "human" and kept.
|
|
comments = [_bot_status_comment(i) for i in range(200)]
|
|
view = prefetch_mod._bounded_comment_view(
|
|
comments, 50, bot_logins=("HAL9000", "HAL9001"),
|
|
)
|
|
# Only the most-recent 50 survive — everything else was bot-
|
|
# authored noise.
|
|
assert [c["id"] for c in view] == list(range(150, 200))
|
|
|
|
def test_bot_and_human_mixed_collapses_to_cap_plus_humans(
|
|
self, prefetch_mod,
|
|
):
|
|
"""End-to-end: a realistic bot-heavy PR shape — 200 bot status
|
|
comments, 200 bot attempt comments, 3 real human comments
|
|
scattered through. The view should collapse to (real humans) +
|
|
(the recent N), regardless of how many bot comments exist.
|
|
"""
|
|
comments: list[dict] = []
|
|
# Interleave 200 status + 200 attempt comments, with humans
|
|
# at positions 50, 150, 250 (well outside the recent-N window
|
|
# so we verify they survive on author/content grounds, not on
|
|
# recency).
|
|
human_positions = {50, 150, 250}
|
|
next_human = 0
|
|
for i in range(403):
|
|
if i in human_positions:
|
|
comments.append(_human_comment(next_human))
|
|
next_human += 1
|
|
elif i % 2 == 0:
|
|
comments.append(_bot_status_comment(i))
|
|
else:
|
|
comments.append(_attempt_comment(i))
|
|
view = prefetch_mod._bounded_comment_view(
|
|
comments, 50, bot_logins=("HAL9000", "HAL9001"),
|
|
)
|
|
# Real humans before the recent window + the last 50 comments.
|
|
# Recent window starts at index 403 - 50 = 353, so all 3 humans
|
|
# (at 50, 150, 250) are pre-window and survive on author check.
|
|
human_ids_in_view = [
|
|
c["id"] for c in view
|
|
if isinstance(c.get("user"), dict)
|
|
and c["user"].get("login") == "drew"
|
|
]
|
|
assert human_ids_in_view == [0, 1, 2]
|
|
# Total view size: 3 humans + 50 recent. The recent 50 may
|
|
# contain a mix of bots and (no) humans (no human positions
|
|
# in [353, 402]); the size is bounded.
|
|
assert len(view) == 3 + 50
|
|
|
|
def test_explicit_empty_bot_logins_keeps_unidentified_bot_status(
|
|
self, prefetch_mod,
|
|
):
|
|
"""When the caller passes ``bot_logins=()`` explicitly (env-less
|
|
environments without the safety-net fallback in play), the
|
|
author check is a no-op — the belt-and-braces ``is_attempt_comment``
|
|
still drops structured attempts, but bot status comments that
|
|
lack the marker survive. Documents the failure mode if the env
|
|
is misconfigured AND no fallback fires; the production default
|
|
path always has at least the HAL9000/HAL9001 fallback so this
|
|
scenario does not occur outside tests."""
|
|
comments = [_bot_status_comment(i) for i in range(120)]
|
|
view = prefetch_mod._bounded_comment_view(
|
|
comments, 50, bot_logins=(),
|
|
)
|
|
# Without author detection, status comments masquerade as
|
|
# humans and all 120 survive.
|
|
assert len(view) == 120
|
|
|
|
|
|
class TestPrefetchResultFields:
|
|
def test_new_comment_fields_present_with_defaults(self, prefetch_mod):
|
|
r = prefetch_mod.ImplementerPrefetchResult()
|
|
assert r.pr_comments_view == []
|
|
assert r.pr_comments_digest == {}
|
|
|
|
|
|
class TestFetchPrContextWiring:
|
|
"""``_fetch_pr_context`` must derive BOTH ``pr_comments_view`` and
|
|
``pr_comments_digest`` from the full comment list it gets back from
|
|
the cache — the full list stays on ``pr_comments`` (the dedup path
|
|
in dispatch_implementer needs it), the bounded view + digest are
|
|
what the prompt / sentinel consume. The section-ordering tests
|
|
drive ``build_*_prompt`` with a hand-built result, so this is the
|
|
only place the actual fetcher wiring is asserted (run-8 R8-2/R8-3).
|
|
"""
|
|
|
|
def test_view_and_digest_derived_from_full_comment_list(
|
|
self, prefetch_mod, monkeypatch, tmp_path
|
|
):
|
|
from .conftest import make_dispatch_config
|
|
|
|
# 122 comments: a human at the very start, 120 bot attempt
|
|
# comments, a human at the very end. cap=50 → the bounded view
|
|
# is the early human (kept because non-bot) + the last 50.
|
|
comments = (
|
|
[_human_comment(0)]
|
|
+ [_attempt_comment(i) for i in range(1, 121)]
|
|
+ [_human_comment(121)]
|
|
)
|
|
cap = prefetch_mod.DEFAULT_MAX_PROMPT_COMMENTS
|
|
|
|
# Stub every Forgejo round-trip _fetch_pr_context makes before
|
|
# the comments fetch so the test is hermetic and fast. Only
|
|
# pr_details must be non-None (a None there early-returns).
|
|
monkeypatch.setattr(
|
|
prefetch_mod._review_fetch, "fetch_pr_details",
|
|
lambda cfg, n: {"number": n, "title": "t",
|
|
"head": {"sha": "abc", "ref": "feature/x"}},
|
|
)
|
|
monkeypatch.setattr(
|
|
prefetch_mod._pr_diff, "fetch_pr_diff_detailed",
|
|
lambda cfg, n: ("diff --git a/x b/x\n", False, "", {}),
|
|
)
|
|
monkeypatch.setattr(
|
|
prefetch_mod._review_fetch, "fetch_ci_status",
|
|
lambda cfg, sha: None,
|
|
)
|
|
# The cache hands back the FULL list; the wiring under test is
|
|
# what _fetch_pr_context does with it.
|
|
monkeypatch.setattr(
|
|
prefetch_mod._pr_comments_cache, "get_pr_comments",
|
|
lambda cfg, n: (comments, True),
|
|
)
|
|
|
|
cfg = make_dispatch_config(tmp_path)
|
|
result = prefetch_mod.fetch_pr_fix_context(
|
|
cfg, {"number": 30, "title": "t"}
|
|
)
|
|
|
|
# Full list preserved verbatim on pr_comments.
|
|
assert result.pr_comments == comments
|
|
# Bounded view = exactly _bounded_comment_view's output:
|
|
# early human + the last cap comments, order preserved.
|
|
assert result.pr_comments_view == prefetch_mod._bounded_comment_view(
|
|
comments, cap
|
|
)
|
|
assert len(result.pr_comments_view) == cap + 1
|
|
assert result.pr_comments_view[0] == _human_comment(0)
|
|
assert _human_comment(121) in result.pr_comments_view
|
|
# An old bot attempt is dropped from the view (it survives only
|
|
# via the digest rollup); a recent one is kept. ``_attempt_comment``
|
|
# bodies end with ``attempt {i}``, so match on the suffix.
|
|
view_bodies = [c["body"] for c in result.pr_comments_view]
|
|
assert not any(b.endswith("attempt 1") for b in view_bodies)
|
|
assert any(b.endswith("attempt 120") for b in view_bodies)
|
|
# Digest is computed from the FULL list, not the bounded view.
|
|
digest = result.pr_comments_digest
|
|
assert digest["total_comments"] == 122
|
|
assert digest["attempt_count"] == 120
|
|
assert digest["non_attempt_count"] == 2
|
|
assert digest["by_tier"] == {0: 120}
|
|
assert "rendered" in digest
|
|
|
|
|
|
class TestAssessPromptCompleteness:
|
|
"""W8 harvest (2026-05-15) — translate the prefetch carrier's
|
|
per-section completion flags into a single loud-signal dict."""
|
|
|
|
def _mod(self):
|
|
return load_tool_module("_implementer_prefetch", fresh=True)
|
|
|
|
def test_fully_complete_result_is_not_degraded(self):
|
|
"""Happy path: a fresh ImplementerPrefetchResult (all defaults
|
|
True) is not degraded."""
|
|
mod = self._mod()
|
|
result = mod.ImplementerPrefetchResult()
|
|
out = mod.assess_prompt_completeness(result)
|
|
assert out["degraded"] is False
|
|
assert out["missing_sections"] == []
|
|
assert out["error_kinds"] == []
|
|
|
|
def test_single_failed_section_marks_degraded(self):
|
|
"""Failure case: one section's completed-flag is False —
|
|
degraded=True and that section is listed."""
|
|
mod = self._mod()
|
|
result = mod.ImplementerPrefetchResult()
|
|
result.ci_status_completed = False
|
|
result.error_kinds.append("ci_status:transient")
|
|
out = mod.assess_prompt_completeness(result)
|
|
assert out["degraded"] is True
|
|
assert "ci_status" in out["missing_sections"]
|
|
assert "ci_status:transient" in out["error_kinds"]
|
|
|
|
def test_truncated_diff_is_called_out_separately(self):
|
|
"""Failure case: a truncated diff (bytes present but
|
|
bounded) shows up as ``diff_truncated`` in missing_sections
|
|
— operators need to see this even though
|
|
diff_completed remains True for the same row."""
|
|
mod = self._mod()
|
|
result = mod.ImplementerPrefetchResult()
|
|
result.diff_truncated = True
|
|
out = mod.assess_prompt_completeness(result)
|
|
assert out["degraded"] is True
|
|
assert "diff_truncated" in out["missing_sections"]
|
|
|
|
def test_multiple_failures_aggregate(self):
|
|
"""Failure case: when multiple sections fail, all are listed
|
|
— operators see the full picture, not just the first
|
|
failure."""
|
|
mod = self._mod()
|
|
result = mod.ImplementerPrefetchResult()
|
|
result.pr_details_completed = False
|
|
result.linked_issues_completed = False
|
|
out = mod.assess_prompt_completeness(result)
|
|
assert out["degraded"] is True
|
|
assert set(out["missing_sections"]) == {"pr_details", "linked_issues"}
|
|
|
|
def test_data_complete_false_with_no_section_flags_still_degraded(self):
|
|
"""Failure case: defensive — if a caller flipped
|
|
``data_complete=False`` directly without touching the
|
|
per-section flags (legacy code path), the aggregate still
|
|
surfaces as degraded so the loud-signal does not miss it."""
|
|
mod = self._mod()
|
|
result = mod.ImplementerPrefetchResult()
|
|
result.data_complete = False
|
|
out = mod.assess_prompt_completeness(result)
|
|
assert out["degraded"] is True
|