mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 13:53:15 +00:00
fix: honest publish_review reporting + structured thread-not-found errors (#1481)
* fix: honest publish_review reporting + structured thread-not-found errors
- Document skipped_empty_re_review and dry_run in the publish_review
docstring and add a closing-summary contract to the reviewer prompt so
the agent never claims a review was published when review_id is null.
- Raise ReviewerThreadMissingError from replace_findings on SDK
NotFoundError; add_finding/update_finding/publish_review return a
structured do-not-retry result instead of raising, so the agent reports
the blocker after one failure instead of retrying 10-30 times.
* fix: translate thread 404s across all reviewer tool boundaries
get_thread_metadata now raises ReviewerThreadMissingError instead of
swallowing a missing thread as {} (which produced misleading 'No finding
found' results), set_reviewer_thread_metadata translates the SDK 404 the
same way, and every reviewer tool entrypoint (add/update/list findings,
publish_review incl. eval dry-run, resolve/reply thread) returns the
structured do-not-retry result.
---------
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
7cd882bb67
commit
8461979b0d
12 changed files with 331 additions and 35 deletions
|
|
@ -254,6 +254,23 @@ severities — they're not findings.
|
|||
- Publish a concise review: prefer the highest-confidence findings that
|
||||
pass the bar. Use fewer when fewer issues are defensible; publish zero
|
||||
only after the workflow above found no concrete regression.
|
||||
|
||||
# After publish_review — closing summary
|
||||
|
||||
Inspect the returned `review_id`, `skipped_empty_re_review`, and `dry_run`
|
||||
fields before composing your final message; `success: true` alone does NOT
|
||||
mean a review was posted.
|
||||
|
||||
- `review_id` is a number and neither flag is set → you may say the review
|
||||
was published/posted and cite `surfaced_count`.
|
||||
- `skipped_empty_re_review: true` or `review_id: null` → say "no new review
|
||||
was posted" / "the re-review had nothing new to surface". Do NOT use
|
||||
"published", "submitted", or "posted".
|
||||
- `dry_run: true` → say "Simulated publish (eval mode) — review not posted
|
||||
to GitHub", then list the findings inline. Do NOT claim publication.
|
||||
- `error: "thread_not_found"` → findings storage is gone; do not retry the
|
||||
tool. Report the blocker and include your intended findings inline in the
|
||||
final message.
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,9 +19,24 @@ from typing import Any, Literal, TypedDict, cast
|
|||
|
||||
from langgraph.config import get_config
|
||||
from langgraph_sdk import get_client
|
||||
from langgraph_sdk.errors import NotFoundError as LangGraphSDKNotFoundError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReviewerThreadMissingError(RuntimeError):
|
||||
"""The reviewer thread backing findings storage does not exist.
|
||||
|
||||
Raised instead of the SDK's ``NotFoundError`` so tool wrappers can return a
|
||||
structured do-not-retry result: the thread won't appear on retry (evicted,
|
||||
eval-mode, or never created), and blind retries burn the whole run.
|
||||
"""
|
||||
|
||||
def __init__(self, thread_id: str, original: Exception) -> None:
|
||||
super().__init__(f"Reviewer thread {thread_id!r} not found: {original}")
|
||||
self.thread_id = thread_id
|
||||
|
||||
|
||||
REVIEWER_THREAD_KIND = "reviewer"
|
||||
|
||||
# Suggestions are only useful when the reader can scan them at a glance and
|
||||
|
|
@ -287,10 +302,18 @@ def get_thread_id_from_runtime() -> str:
|
|||
|
||||
|
||||
async def get_thread_metadata(thread_id: str) -> dict[str, Any]:
|
||||
"""Fetch the current metadata for a thread. Returns ``{}`` on miss."""
|
||||
"""Fetch the current metadata for a thread.
|
||||
|
||||
Raises :class:`ReviewerThreadMissingError` when the thread does not exist
|
||||
(swallowing it as ``{}`` made tools report misleading results like "No
|
||||
finding found" instead of the do-not-retry contract). Other transient
|
||||
failures still degrade to ``{}``.
|
||||
"""
|
||||
client = get_client()
|
||||
try:
|
||||
thread = await client.threads.get(thread_id)
|
||||
except LangGraphSDKNotFoundError as exc:
|
||||
raise ReviewerThreadMissingError(thread_id, exc) from exc
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Failed to fetch thread metadata for %s", thread_id)
|
||||
return {}
|
||||
|
|
@ -339,7 +362,28 @@ async def replace_findings(thread_id: str, findings: list[Finding]) -> None:
|
|||
lost-update window a blind overwrite here leaves open.
|
||||
"""
|
||||
client = get_client()
|
||||
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
|
||||
try:
|
||||
await client.threads.update(thread_id=thread_id, metadata={"findings": findings})
|
||||
except LangGraphSDKNotFoundError as exc:
|
||||
raise ReviewerThreadMissingError(thread_id, exc) from exc
|
||||
|
||||
|
||||
def thread_missing_tool_result(exc: ReviewerThreadMissingError) -> dict[str, Any]:
|
||||
"""Structured tool result for a missing reviewer thread.
|
||||
|
||||
Returned (not raised) so the agent sees an explicit do-not-retry contract
|
||||
instead of an empty error blob it retries against.
|
||||
"""
|
||||
return {
|
||||
"success": False,
|
||||
"error": "thread_not_found",
|
||||
"thread_id": exc.thread_id,
|
||||
"note": (
|
||||
"Reviewer findings storage is unavailable. Do not retry; report the "
|
||||
"blocker and include intended findings inline in the final message."
|
||||
),
|
||||
"detail": str(exc),
|
||||
}
|
||||
|
||||
|
||||
async def mutate_findings(
|
||||
|
|
@ -520,7 +564,10 @@ async def set_reviewer_thread_metadata(
|
|||
metadata["slack_thread"] = slack_thread
|
||||
if extra:
|
||||
metadata.update(extra)
|
||||
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||
try:
|
||||
await client.threads.update(thread_id=thread_id, metadata=metadata)
|
||||
except LangGraphSDKNotFoundError as exc:
|
||||
raise ReviewerThreadMissingError(thread_id, exc) from exc
|
||||
|
||||
|
||||
def get_thread_watch_flag(metadata: dict[str, Any]) -> bool:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from ..reviewer_findings import (
|
|||
Confidence,
|
||||
DiffSide,
|
||||
Finding,
|
||||
ReviewerThreadMissingError,
|
||||
Severity,
|
||||
append_finding,
|
||||
clip_suggestion,
|
||||
|
|
@ -21,6 +22,7 @@ from ..reviewer_findings import (
|
|||
new_finding,
|
||||
normalize_finding_title,
|
||||
resolve_review_head_sha,
|
||||
thread_missing_tool_result,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -128,7 +130,10 @@ def add_finding(
|
|||
clipped_suggestion, suggestion_dropped = clip_suggestion(suggestion)
|
||||
|
||||
thread_id = get_thread_id_from_runtime()
|
||||
head_sha = asyncio.run(resolve_review_head_sha(thread_id, configurable))
|
||||
try:
|
||||
head_sha = asyncio.run(resolve_review_head_sha(thread_id, configurable))
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
|
||||
finding: Finding = new_finding(
|
||||
severity=_cast_severity(severity),
|
||||
|
|
@ -146,7 +151,10 @@ def add_finding(
|
|||
in_diff=in_diff,
|
||||
)
|
||||
|
||||
asyncio.run(append_finding(thread_id, finding))
|
||||
try:
|
||||
asyncio.run(append_finding(thread_id, finding))
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
result: dict[str, Any] = {"success": True, "finding_id": finding["id"]}
|
||||
if not in_diff:
|
||||
result["in_diff"] = False
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ import asyncio
|
|||
from typing import Any
|
||||
|
||||
from ..reviewer_findings import (
|
||||
ReviewerThreadMissingError,
|
||||
get_thread_id_from_runtime,
|
||||
thread_missing_tool_result,
|
||||
)
|
||||
from ..reviewer_findings import (
|
||||
list_findings as list_findings_async,
|
||||
|
|
@ -30,7 +32,10 @@ def list_findings(status_filter: str | None = None) -> dict[str, Any]:
|
|||
return {"findings": [], "count": 0, "error": f"Invalid status_filter: {status_filter}"}
|
||||
|
||||
thread_id = get_thread_id_from_runtime()
|
||||
findings = asyncio.run(list_findings_async(thread_id))
|
||||
try:
|
||||
findings = asyncio.run(list_findings_async(thread_id))
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
if status_filter is not None:
|
||||
findings = [f for f in findings if f.get("status") == status_filter]
|
||||
return {"findings": findings, "count": len(findings)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from ..dashboard.team_settings import get_team_review_trace_links_enabled
|
|||
from ..reviewer_diff import compute_diff_line_set, fetch_pr_diff, is_range_in_diff
|
||||
from ..reviewer_findings import (
|
||||
Finding,
|
||||
ReviewerThreadMissingError,
|
||||
Severity,
|
||||
_coerce_surface,
|
||||
filter_findings_for_publish,
|
||||
|
|
@ -21,6 +22,7 @@ from ..reviewer_findings import (
|
|||
replace_findings,
|
||||
resolve_review_head_sha,
|
||||
set_reviewer_thread_metadata,
|
||||
thread_missing_tool_result,
|
||||
)
|
||||
from ..reviewer_findings import (
|
||||
list_findings as list_findings_async,
|
||||
|
|
@ -74,7 +76,22 @@ def publish_review(
|
|||
|
||||
Returns:
|
||||
Dictionary with ``success``, ``review_id``, ``surfaced_count``,
|
||||
``hidden_count``, ``resolved_thread_count``.
|
||||
``hidden_count``, ``resolved_thread_count``, and sometimes
|
||||
``out_of_diff_count``, ``unresolvable_findings``, plus the flags below.
|
||||
|
||||
``success: true`` alone does NOT mean a GitHub Review was posted —
|
||||
check the flags:
|
||||
|
||||
- ``skipped_empty_re_review: true`` (with ``review_id: null``): an
|
||||
empty re-review was deliberately skipped. No GitHub Review was
|
||||
created; the call was a valid no-op. Do not describe the review as
|
||||
published/posted/submitted.
|
||||
- ``dry_run: true`` (with ``review_id: null``): eval/benchmark mode —
|
||||
the publish was simulated and nothing was posted to GitHub. Do not
|
||||
claim publication.
|
||||
|
||||
Only a numeric ``review_id`` (with neither flag set) confirms a real
|
||||
GitHub Review was created.
|
||||
"""
|
||||
if severity_threshold not in {"low", "medium", "high", "critical"}:
|
||||
return {"success": False, "error": f"Invalid severity_threshold: {severity_threshold}"}
|
||||
|
|
@ -99,13 +116,16 @@ def publish_review(
|
|||
return {"success": False, "error": "Missing head_sha in run config"}
|
||||
|
||||
if _is_reviewer_eval_mode(configurable):
|
||||
return asyncio.run(
|
||||
_publish_review_eval_dry_run_async(
|
||||
head_sha=head_sha,
|
||||
severity_threshold=_cast_severity(severity_threshold),
|
||||
cap=cap,
|
||||
try:
|
||||
return asyncio.run(
|
||||
_publish_review_eval_dry_run_async(
|
||||
head_sha=head_sha,
|
||||
severity_threshold=_cast_severity(severity_threshold),
|
||||
cap=cap,
|
||||
)
|
||||
)
|
||||
)
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
|
||||
token = get_github_token()
|
||||
if not token:
|
||||
|
|
@ -126,6 +146,8 @@ def publish_review(
|
|||
trace_link_config_override=configurable.get("review_trace_link_enabled"),
|
||||
)
|
||||
)
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
except GitHubAuthError as exc:
|
||||
thread_id = get_thread_id_from_runtime()
|
||||
if thread_id:
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ from langgraph.config import get_config
|
|||
|
||||
from ..reviewer_findings import (
|
||||
FindingInteraction,
|
||||
ReviewerThreadMissingError,
|
||||
append_finding_interaction,
|
||||
get_finding,
|
||||
get_thread_id_from_runtime,
|
||||
thread_missing_tool_result,
|
||||
update_finding_fields,
|
||||
)
|
||||
from ..reviewer_publish import reply_to_review_comment
|
||||
|
|
@ -37,16 +39,19 @@ def reply_to_finding_thread(finding_id: str, body: str) -> dict[str, Any]:
|
|||
if not token:
|
||||
return {"success": False, "error": "No GitHub token available"}
|
||||
|
||||
return asyncio.run(
|
||||
_reply_to_finding_thread_async(
|
||||
finding_id=finding_id,
|
||||
body=body,
|
||||
owner=str(repo_config["owner"]),
|
||||
repo=str(repo_config["name"]),
|
||||
pr_number=pr_number,
|
||||
token=token,
|
||||
try:
|
||||
return asyncio.run(
|
||||
_reply_to_finding_thread_async(
|
||||
finding_id=finding_id,
|
||||
body=body,
|
||||
owner=str(repo_config["owner"]),
|
||||
repo=str(repo_config["name"]),
|
||||
pr_number=pr_number,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
)
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
|
||||
|
||||
async def _reply_to_finding_thread_async(
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ from langgraph.config import get_config
|
|||
|
||||
from ..reviewer_findings import (
|
||||
Finding,
|
||||
ReviewerThreadMissingError,
|
||||
get_finding,
|
||||
get_thread_id_from_runtime,
|
||||
thread_missing_tool_result,
|
||||
update_finding_fields,
|
||||
update_finding_surface,
|
||||
)
|
||||
|
|
@ -67,17 +69,20 @@ def resolve_finding_thread(
|
|||
if not token:
|
||||
return {"success": False, "error": "No GitHub token available"}
|
||||
|
||||
result = asyncio.run(
|
||||
_resolve_finding_thread_async(
|
||||
finding_id=finding_id,
|
||||
status=status,
|
||||
note=normalized_note,
|
||||
owner=str(repo_config["owner"]),
|
||||
repo=str(repo_config["name"]),
|
||||
pr_number=pr_number,
|
||||
token=token,
|
||||
try:
|
||||
result = asyncio.run(
|
||||
_resolve_finding_thread_async(
|
||||
finding_id=finding_id,
|
||||
status=status,
|
||||
note=normalized_note,
|
||||
owner=str(repo_config["owner"]),
|
||||
repo=str(repo_config["name"]),
|
||||
pr_number=pr_number,
|
||||
token=token,
|
||||
)
|
||||
)
|
||||
)
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
if result.get("success") and isinstance(result.get("finding"), dict):
|
||||
thread_id = configurable.get("thread_id") if isinstance(configurable, dict) else None
|
||||
emit_finding_status_outcome(
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ from ..reviewer_findings import (
|
|||
DEFAULT_FINDING_TITLE,
|
||||
MAX_SUGGESTION_LINES,
|
||||
Finding,
|
||||
ReviewerThreadMissingError,
|
||||
clip_suggestion,
|
||||
get_thread_id_from_runtime,
|
||||
list_findings,
|
||||
normalize_finding_title,
|
||||
resolve_review_head_sha,
|
||||
thread_missing_tool_result,
|
||||
update_finding_fields,
|
||||
)
|
||||
from ..utils.reviewer_outcomes import emit_finding_status_outcome
|
||||
|
|
@ -129,7 +131,12 @@ def update_finding(
|
|||
config = get_config()
|
||||
configurable = config.get("configurable", {}) if isinstance(config, dict) else {}
|
||||
if status == "open":
|
||||
head_sha = asyncio.run(resolve_review_head_sha(get_thread_id_from_runtime(), configurable))
|
||||
try:
|
||||
head_sha = asyncio.run(
|
||||
resolve_review_head_sha(get_thread_id_from_runtime(), configurable)
|
||||
)
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
if head_sha:
|
||||
updates["last_confirmed_sha"] = head_sha
|
||||
|
||||
|
|
@ -148,7 +155,10 @@ def update_finding(
|
|||
return {"success": False, "error": "No fields provided to update"}
|
||||
|
||||
thread_id = get_thread_id_from_runtime()
|
||||
findings = asyncio.run(list_findings(thread_id))
|
||||
try:
|
||||
findings = asyncio.run(list_findings(thread_id))
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
finding = next((item for item in findings if item.get("id") == finding_id), None)
|
||||
if finding is None:
|
||||
return {"success": False, "error": f"No finding found with id {finding_id}"}
|
||||
|
|
@ -195,7 +205,10 @@ def update_finding(
|
|||
)
|
||||
return result
|
||||
|
||||
updated = asyncio.run(update_finding_fields(thread_id, finding_id, updates))
|
||||
try:
|
||||
updated = asyncio.run(update_finding_fields(thread_id, finding_id, updates))
|
||||
except ReviewerThreadMissingError as exc:
|
||||
return thread_missing_tool_result(exc)
|
||||
if updated is None:
|
||||
return {"success": False, "error": f"No finding found with id {finding_id}"}
|
||||
if status in {"resolved", "dismissed"} and not delegated_resolution:
|
||||
|
|
|
|||
|
|
@ -1273,3 +1273,18 @@ async def test_reviewer_injects_pr_title_and_body_into_context() -> None:
|
|||
assert "PR title and description" in captured["system_prompt"]
|
||||
assert "Add retry logic for uploads" in captured["system_prompt"]
|
||||
assert "Retries flaky uploads up to 3 times." in captured["system_prompt"]
|
||||
|
||||
|
||||
def test_reviewer_system_prompt_includes_closing_summary_contract() -> None:
|
||||
"""The prompt must tell the agent how to report publish_review outcomes:
|
||||
dry_run / skipped_empty_re_review / thread_not_found are not publications."""
|
||||
prompt = reviewer._reviewer_system_prompt(
|
||||
"/tmp/wd",
|
||||
repo_owner="o",
|
||||
repo_name="r",
|
||||
pr_number=1,
|
||||
)
|
||||
assert "skipped_empty_re_review" in prompt
|
||||
assert "dry_run" in prompt
|
||||
assert "Simulated publish (eval mode)" in prompt
|
||||
assert "thread_not_found" in prompt
|
||||
|
|
|
|||
|
|
@ -257,3 +257,78 @@ async def test_resolve_review_head_sha_falls_back_without_thread_id() -> None:
|
|||
head = await resolve_review_head_sha("", {"head_sha": "confighead"})
|
||||
assert head == "confighead"
|
||||
fake_client.threads.get.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_findings_raises_domain_error_when_thread_missing() -> None:
|
||||
import httpx
|
||||
from langgraph_sdk.errors import NotFoundError
|
||||
|
||||
from agent.reviewer_findings import ReviewerThreadMissingError
|
||||
|
||||
not_found = NotFoundError(
|
||||
"thread tid not found",
|
||||
response=httpx.Response(404, request=httpx.Request("PATCH", "http://x")),
|
||||
body=None,
|
||||
)
|
||||
fake_client = AsyncMock()
|
||||
fake_client.threads.update.side_effect = not_found
|
||||
|
||||
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||
with pytest.raises(ReviewerThreadMissingError) as excinfo:
|
||||
await replace_findings("tid", [_f(id="f_a")])
|
||||
|
||||
assert excinfo.value.thread_id == "tid"
|
||||
assert "not found" in str(excinfo.value)
|
||||
|
||||
|
||||
def _not_found(method: str = "GET") -> Exception:
|
||||
import httpx
|
||||
from langgraph_sdk.errors import NotFoundError
|
||||
|
||||
return NotFoundError(
|
||||
"thread tid not found",
|
||||
response=httpx.Response(404, request=httpx.Request(method, "http://x")),
|
||||
body=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_thread_metadata_raises_domain_error_when_thread_missing() -> None:
|
||||
"""A missing thread must surface as ReviewerThreadMissingError, not be
|
||||
swallowed into ``{}`` — that produced misleading tool results like
|
||||
"No finding found" instead of the do-not-retry contract."""
|
||||
from agent.reviewer_findings import ReviewerThreadMissingError, get_thread_metadata
|
||||
|
||||
fake_client = AsyncMock()
|
||||
fake_client.threads.get.side_effect = _not_found()
|
||||
|
||||
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||
with pytest.raises(ReviewerThreadMissingError):
|
||||
await get_thread_metadata("tid")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_thread_metadata_still_degrades_on_other_failures() -> None:
|
||||
from agent.reviewer_findings import get_thread_metadata
|
||||
|
||||
fake_client = AsyncMock()
|
||||
fake_client.threads.get.side_effect = RuntimeError("transient")
|
||||
|
||||
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||
assert await get_thread_metadata("tid") == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_reviewer_thread_metadata_raises_domain_error_when_thread_missing() -> None:
|
||||
from agent.reviewer_findings import (
|
||||
ReviewerThreadMissingError,
|
||||
set_reviewer_thread_metadata,
|
||||
)
|
||||
|
||||
fake_client = AsyncMock()
|
||||
fake_client.threads.update.side_effect = _not_found("PATCH")
|
||||
|
||||
with patch("agent.reviewer_findings.get_client", return_value=fake_client):
|
||||
with pytest.raises(ReviewerThreadMissingError):
|
||||
await set_reviewer_thread_metadata("tid", last_reviewed_sha="sha")
|
||||
|
|
|
|||
|
|
@ -2108,3 +2108,36 @@ async def test_publish_review_fetches_pr_diff_when_diff_line_set_missing() -> No
|
|||
assert {c["path"] for c in retry_inline} == {"in_diff.py"}
|
||||
assert result["success"] is True
|
||||
assert result["unresolvable_findings"] == ["f_bad"]
|
||||
|
||||
|
||||
def test_publish_review_tool_returns_structured_error_when_thread_missing() -> None:
|
||||
"""A missing reviewer thread surfaces as a do-not-retry tool result instead
|
||||
of an exception the middleware swallows into an empty tool message."""
|
||||
from agent.reviewer_findings import ReviewerThreadMissingError
|
||||
from agent.tools.publish_review import publish_review
|
||||
|
||||
publish_async = AsyncMock(
|
||||
side_effect=ReviewerThreadMissingError("tid", RuntimeError("thread tid not found"))
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"agent.tools.publish_review.get_config",
|
||||
return_value={
|
||||
"configurable": {
|
||||
"thread_id": "tid",
|
||||
"repo": {"owner": "o", "name": "r"},
|
||||
"pr_number": 7,
|
||||
"head_sha": "sha",
|
||||
},
|
||||
"metadata": {},
|
||||
},
|
||||
),
|
||||
patch("agent.tools.publish_review.get_github_token", return_value="token"),
|
||||
patch("agent.tools.publish_review._publish_review_async", publish_async),
|
||||
):
|
||||
result = publish_review()
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "thread_not_found"
|
||||
assert result["thread_id"] == "tid"
|
||||
assert "Do not retry" in result["note"]
|
||||
|
|
|
|||
|
|
@ -712,3 +712,54 @@ def test_list_findings_returns_all_when_filter_omitted() -> None:
|
|||
result = list_findings()
|
||||
|
||||
assert result["count"] == 2
|
||||
|
||||
|
||||
def test_add_finding_returns_structured_error_when_thread_missing() -> None:
|
||||
"""A missing reviewer thread must come back as a do-not-retry tool result,
|
||||
not a raised exception the agent retries against 10-30 times."""
|
||||
from agent.reviewer_findings import ReviewerThreadMissingError
|
||||
|
||||
async def fake_append(thread_id: str, finding: Any) -> Any:
|
||||
raise ReviewerThreadMissingError(thread_id, RuntimeError("thread X not found"))
|
||||
|
||||
with (
|
||||
patch("agent.tools.add_finding.get_config", return_value=_config()),
|
||||
patch("agent.tools.add_finding.get_thread_id_from_runtime", return_value="tid-1"),
|
||||
patch("agent.tools.add_finding.append_finding", side_effect=fake_append),
|
||||
):
|
||||
result = add_finding(
|
||||
severity="medium",
|
||||
confidence="high",
|
||||
category="correctness",
|
||||
file="foo.py",
|
||||
title="Rename breaks reference",
|
||||
description="rename",
|
||||
start_line=11,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "thread_not_found"
|
||||
assert result["thread_id"] == "tid-1"
|
||||
assert "Do not retry" in result["note"]
|
||||
|
||||
|
||||
def test_update_finding_returns_structured_error_when_thread_missing() -> None:
|
||||
from agent.reviewer_findings import ReviewerThreadMissingError
|
||||
|
||||
async def fake_update(thread_id: str, finding_id: str, updates: Any) -> Any:
|
||||
raise ReviewerThreadMissingError(thread_id, RuntimeError("thread X not found"))
|
||||
|
||||
with (
|
||||
patch("agent.tools.update_finding.get_config", return_value=_config()),
|
||||
patch("agent.tools.update_finding.get_thread_id_from_runtime", return_value="tid-1"),
|
||||
patch(
|
||||
"agent.tools.update_finding.list_findings",
|
||||
AsyncMock(return_value=[_existing_finding()]),
|
||||
),
|
||||
patch("agent.tools.update_finding.update_finding_fields", side_effect=fake_update),
|
||||
):
|
||||
result = update_finding(finding_id="f_a", status="resolved", note="fixed")
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error"] == "thread_not_found"
|
||||
assert result["thread_id"] == "tid-1"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue