@openhands/extensions 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/custom-codereview-guide.md +44 -1
- package/.claude-plugin/marketplace.json +7 -5
- package/.codex-plugin/marketplace.json +7 -5
- package/.github/workflows/build-vscode-app.yml +3 -3
- package/.github/workflows/check-extensions.yml +1 -1
- package/.github/workflows/tests.yml +1 -1
- package/.plugin/marketplace.json +7 -5
- package/.release-please-manifest.json +1 -1
- package/AGENTS.md +7 -2
- package/README.md +3 -3
- package/apps/vscode/README.md +8 -6
- package/apps/vscode/canvas-extension.json +11 -3
- package/apps/vscode/extension.js +60 -12
- package/apps/vscode/scripts/package.mjs +20 -74
- package/automations/bundle-index.js +9 -7
- package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
- package/automations/catalog/github-issue-triage/manifest.json +4 -3
- package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
- package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
- package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
- package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
- package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
- package/integrations/catalog/atlassian-rovo.json +4 -3
- package/integrations/catalog/bitbucket.json +16 -1
- package/integrations/catalog/github.json +1 -0
- package/integrations/catalog/gitlab.json +1 -1
- package/marketplaces/openhands-extensions.json +7 -5
- package/package.json +1 -1
- package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
- package/plugins/pr-review/README.md +1 -0
- package/plugins/pr-review/scripts/prompt.py +44 -11
- package/plugins/runtime-api-configs/SKILL.md +1 -1
- package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
- package/pyproject.toml +1 -1
- package/python/openhands_extensions/_version.py +1 -1
- package/skills/canvas-extension-api/SKILL.md +4 -1
- package/skills/canvas-extension-api/references/backend-safety.md +3 -3
- package/skills/canvas-extension-api/references/native-design.md +250 -0
- package/skills/canvas-extension-api/references/v1-contract.md +7 -4
- package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
- package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
- package/skills/github/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/README.md +73 -14
- package/skills/github-issue-triage/SKILL.md +26 -46
- package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
- package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
- package/skills/github-issue-triage/scripts/worker.py +153 -103
- package/skills/github-pr-reviewer/README.md +103 -1
- package/skills/github-pr-reviewer/SKILL.md +35 -8
- package/skills/github-pr-reviewer/references/state-schema.md +8 -0
- package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
- package/skills/github-pr-reviewer/scripts/main.py +163 -39
- package/skills/github-pr-reviewer/scripts/publication.py +193 -0
- package/skills/github-pr-reviewer/scripts/worker.py +297 -8
- package/skills/github-pr-reviewer/tests/test_main.py +73 -5
- package/skills/gitlab-issue-to-mr/README.md +10 -3
- package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
- package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
- package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
- package/skills/index.js +10 -10
- package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
- package/skills/jira-issue-to-pr/README.md +18 -11
- package/skills/jira-issue-to-pr/SKILL.md +72 -23
- package/skills/jira-issue-to-pr/references/setup.md +43 -12
- package/skills/jira-issue-to-pr/scripts/main.py +136 -26
- package/skills/openhands-automation/references/custom-automation.md +76 -8
- package/skills/slack-channel-monitor/README.md +6 -1
- package/skills/slack-channel-monitor/SKILL.md +6 -3
- package/skills/slack-channel-monitor/scripts/main.py +36 -4
- package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
- package/skills/ticket-to-code-change/SKILL.md +5 -3
- package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
- package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
- package/tests/test_agent_conversation_dispatch.py +173 -6
- package/tests/test_automation_llm_provenance.py +1209 -0
- package/tests/test_github_review_publication.py +422 -0
- package/tests/test_github_reviewer_delivery.py +174 -21
- package/tests/test_github_triage_delivery.py +411 -238
- package/tests/test_gitlab_issue_to_mr.py +284 -0
- package/tests/test_jira_issue_to_pr.py +200 -6
- package/tests/test_pr_review_prompt_injection.py +353 -0
- package/tests/test_slack_channel_monitor.py +32 -1
- package/tests/test_vscode_app.py +8 -2
- package/tests/vscode-app.test.mjs +8 -2
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
"""Regression coverage for display-only LLM provenance."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import threading
|
|
7
|
+
import types
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _load_script(name, monkeypatch, tmp_path):
|
|
15
|
+
monkeypatch.setenv("WORKSPACE_BASE", str(tmp_path))
|
|
16
|
+
monkeypatch.delenv("AUTOMATION_MODEL", raising=False)
|
|
17
|
+
path = Path(__file__).parents[1] / "skills" / name / "scripts" / "main.py"
|
|
18
|
+
monkeypatch.syspath_prepend(str(path.parent))
|
|
19
|
+
module = types.ModuleType(name.replace("-", "_"))
|
|
20
|
+
module.__file__ = str(path)
|
|
21
|
+
source = path.read_text()
|
|
22
|
+
if name == "slack-channel-monitor":
|
|
23
|
+
source = source.split("\nPOLL_ITERATIONS = 10", 1)[0]
|
|
24
|
+
exec(compile(source, str(path), "exec"), module.__dict__)
|
|
25
|
+
return module
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(params=["github-pr-reviewer", "slack-channel-monitor"])
|
|
29
|
+
def automation(request, monkeypatch, tmp_path):
|
|
30
|
+
return _load_script(request.param, monkeypatch, tmp_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def settings(automation, monkeypatch):
|
|
35
|
+
data = {
|
|
36
|
+
"active_profile": "active-profile",
|
|
37
|
+
"agent_settings": {
|
|
38
|
+
"llm": {
|
|
39
|
+
"model": "openai/active",
|
|
40
|
+
"provider_connection_id": "shared-provider",
|
|
41
|
+
"api_key": "resolved-key",
|
|
42
|
+
"base_url": "https://provider.example/v1",
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
monkeypatch.setattr(automation, "_fetch_settings", lambda *_: data)
|
|
47
|
+
return data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_display_does_not_change_default_agent_configuration(
|
|
51
|
+
automation, settings, monkeypatch, tmp_path
|
|
52
|
+
):
|
|
53
|
+
monkeypatch.setenv("AUTOMATION_MODEL", "other-profile")
|
|
54
|
+
payloads = []
|
|
55
|
+
|
|
56
|
+
def request(_url, _key, method, path, payload):
|
|
57
|
+
assert (method, path) == ("POST", "/api/conversations")
|
|
58
|
+
payloads.append(payload)
|
|
59
|
+
return {"id": "conversation"}
|
|
60
|
+
|
|
61
|
+
monkeypatch.setattr(automation, "_oh_request", request)
|
|
62
|
+
monkeypatch.setattr(automation, "_build_secrets_payload", lambda *_: {})
|
|
63
|
+
monkeypatch.setattr(automation, "_get_mcp_config", lambda *_: None)
|
|
64
|
+
kwargs = {}
|
|
65
|
+
if automation.__name__ == "github_pr_reviewer":
|
|
66
|
+
kwargs["workspace_dir"] = tmp_path
|
|
67
|
+
automation.create_conversation(
|
|
68
|
+
"http://agent", "session-key", "Review this", **kwargs
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
assert payloads[0]["agent"] == {
|
|
72
|
+
"kind": "Agent",
|
|
73
|
+
"llm": settings["agent_settings"]["llm"],
|
|
74
|
+
"tools": [{"name": "terminal"}, {"name": "file_editor"}],
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_provenance_footer_replaces_incorrect_model(automation):
|
|
79
|
+
body = "Assessment\n\nLLM profile: `wrong` · Model: `wrong`"
|
|
80
|
+
result = automation._with_llm_provenance(body, "selected", "openai/selected")
|
|
81
|
+
assert result == (
|
|
82
|
+
"Assessment\n\nLLM profile: `selected` · Model: `openai/selected`"
|
|
83
|
+
)
|
|
84
|
+
assert (
|
|
85
|
+
automation._with_llm_provenance(result, "selected", "openai/selected") == result
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- Legacy reviewer (main.py polling): a footer only on this run's own review
|
|
90
|
+
|
|
91
|
+
DISCLOSURE = "_This review was posted by an AI agent (OpenHands)._"
|
|
92
|
+
CONV_A = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
|
|
93
|
+
CONV_B = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"
|
|
94
|
+
FOOTER_A = "LLM profile: `selected-profile` · Model: `openai/selected`"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _marked(conversation_id, text="Assessment\n\n✅ APPROVED"):
|
|
98
|
+
return f"{DISCLOSURE}\n<!-- openhands-review-run: {conversation_id} -->\n\n{text}"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@pytest.fixture
|
|
102
|
+
def reviewer(monkeypatch, tmp_path):
|
|
103
|
+
module = _load_script("github-pr-reviewer", monkeypatch, tmp_path)
|
|
104
|
+
module._AUTH_LOGIN = "review-bot"
|
|
105
|
+
monkeypatch.setattr(module, "conversation_status", lambda *_: "finished")
|
|
106
|
+
monkeypatch.setattr(module, "conversation_final_response", lambda *_: "Review text")
|
|
107
|
+
monkeypatch.setattr(module, "_release_checkout", lambda *_: None)
|
|
108
|
+
return module
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _record(conversation_id=CONV_A, profile="selected-profile", model="openai/selected"):
|
|
112
|
+
return {
|
|
113
|
+
"status": "active",
|
|
114
|
+
"conversation_id": conversation_id,
|
|
115
|
+
"pr_number": 42,
|
|
116
|
+
"head_sha": "head",
|
|
117
|
+
"last_activity": 0.0,
|
|
118
|
+
"llm_profile": profile,
|
|
119
|
+
"llm_model": model,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.fixture
|
|
124
|
+
def completion():
|
|
125
|
+
return _record()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _GitHub:
|
|
129
|
+
"""One PR's reviews and comments, shared by every run that reads them."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, *bodies):
|
|
132
|
+
self.reviews = [
|
|
133
|
+
{
|
|
134
|
+
"id": 101 + index,
|
|
135
|
+
"user": {"login": "review-bot"},
|
|
136
|
+
"commit_id": "head",
|
|
137
|
+
"state": "COMMENTED",
|
|
138
|
+
"submitted_at": f"2026-09-10T10:0{index}:00Z",
|
|
139
|
+
"body": body,
|
|
140
|
+
}
|
|
141
|
+
for index, body in enumerate(bodies)
|
|
142
|
+
]
|
|
143
|
+
self.writes = []
|
|
144
|
+
|
|
145
|
+
def paginate(self, _token, path):
|
|
146
|
+
assert path == "/repos/owner/repo/pulls/42/reviews"
|
|
147
|
+
return [dict(review) for review in self.reviews]
|
|
148
|
+
|
|
149
|
+
def request(self, _token, method, path, body=None):
|
|
150
|
+
self.writes.append((method, path, body))
|
|
151
|
+
if method == "PUT":
|
|
152
|
+
review_id = int(path.rsplit("/", 1)[1])
|
|
153
|
+
for review in self.reviews:
|
|
154
|
+
if review["id"] == review_id:
|
|
155
|
+
review["body"] = body["body"]
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
def install(self, module, monkeypatch):
|
|
159
|
+
monkeypatch.setattr(module, "_github_paginate", self.paginate)
|
|
160
|
+
monkeypatch.setattr(module, "_github_request", self.request)
|
|
161
|
+
return self
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _complete(reviewer, rec):
|
|
165
|
+
reviewer._check_conversation_completion(
|
|
166
|
+
rec,
|
|
167
|
+
{42: {"head": {"sha": "head"}}},
|
|
168
|
+
"token",
|
|
169
|
+
"http://agent",
|
|
170
|
+
"key",
|
|
171
|
+
"owner/repo",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.parametrize("footer", ["", "\n\nLLM profile: `wrong` · Model: `wrong`"])
|
|
176
|
+
def test_own_review_gets_exact_provenance_before_closing(
|
|
177
|
+
reviewer, completion, monkeypatch, footer
|
|
178
|
+
):
|
|
179
|
+
github = _GitHub(_marked(CONV_A) + footer).install(reviewer, monkeypatch)
|
|
180
|
+
|
|
181
|
+
_complete(reviewer, completion)
|
|
182
|
+
|
|
183
|
+
assert completion["status"] == "closed"
|
|
184
|
+
assert github.writes == [
|
|
185
|
+
(
|
|
186
|
+
"PUT",
|
|
187
|
+
"/repos/owner/repo/pulls/42/reviews/101",
|
|
188
|
+
{"body": f"{_marked(CONV_A)}\n\n{FOOTER_A}"},
|
|
189
|
+
)
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_correct_review_footer_needs_no_write(reviewer, completion, monkeypatch):
|
|
194
|
+
github = _GitHub(f"{_marked(CONV_A)}\n\n{FOOTER_A}").install(reviewer, monkeypatch)
|
|
195
|
+
|
|
196
|
+
_complete(reviewer, completion)
|
|
197
|
+
|
|
198
|
+
assert completion["status"] == "closed"
|
|
199
|
+
assert github.writes == []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@pytest.mark.parametrize("failure", ["lookup", "repair", "fallback"])
|
|
203
|
+
def test_failed_publication_remains_active_for_retry(
|
|
204
|
+
reviewer, completion, monkeypatch, failure
|
|
205
|
+
):
|
|
206
|
+
def fail(*args, **kwargs):
|
|
207
|
+
raise RuntimeError("GitHub unavailable")
|
|
208
|
+
|
|
209
|
+
_GitHub(_marked(CONV_A)).install(reviewer, monkeypatch)
|
|
210
|
+
if failure == "lookup":
|
|
211
|
+
monkeypatch.setattr(reviewer, "_github_paginate", fail)
|
|
212
|
+
elif failure == "fallback":
|
|
213
|
+
monkeypatch.setattr(reviewer, "_github_paginate", lambda *_: [])
|
|
214
|
+
monkeypatch.setattr(reviewer, "_github_request", fail)
|
|
215
|
+
_complete(reviewer, completion)
|
|
216
|
+
assert completion["status"] == "active"
|
|
217
|
+
assert "completed_at" not in completion
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.parametrize(
|
|
221
|
+
"body",
|
|
222
|
+
[
|
|
223
|
+
"Assessment\n\n✅ APPROVED",
|
|
224
|
+
_marked(CONV_B),
|
|
225
|
+
f"{_marked(CONV_A)}\n\n> Quoted: <!-- openhands-review-run: {CONV_B} -->",
|
|
226
|
+
],
|
|
227
|
+
ids=["no-marker", "another-run", "two-markers"],
|
|
228
|
+
)
|
|
229
|
+
def test_reviews_this_run_cannot_claim_are_never_edited(
|
|
230
|
+
reviewer, completion, monkeypatch, body
|
|
231
|
+
):
|
|
232
|
+
github = _GitHub(body).install(reviewer, monkeypatch)
|
|
233
|
+
|
|
234
|
+
_complete(reviewer, completion)
|
|
235
|
+
|
|
236
|
+
# The review exists, so the run is complete and posts no duplicate result,
|
|
237
|
+
# but nothing marks it as this conversation's, so its body is left alone.
|
|
238
|
+
assert completion["status"] == "closed"
|
|
239
|
+
assert github.writes == []
|
|
240
|
+
assert github.reviews[0]["body"] == body
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@pytest.mark.parametrize(
|
|
244
|
+
"change",
|
|
245
|
+
[{"state": "PENDING"}, {"user": {"login": "someone-else"}}, {"commit_id": "old"}],
|
|
246
|
+
)
|
|
247
|
+
def test_unrelated_reviews_are_not_relabelled(
|
|
248
|
+
reviewer, completion, monkeypatch, change
|
|
249
|
+
):
|
|
250
|
+
github = _GitHub(_marked(CONV_A)).install(reviewer, monkeypatch)
|
|
251
|
+
github.reviews[0].update(change)
|
|
252
|
+
|
|
253
|
+
_complete(reviewer, completion)
|
|
254
|
+
|
|
255
|
+
assert completion["status"] == "closed"
|
|
256
|
+
[(method, path, body)] = github.writes
|
|
257
|
+
assert (method, path) == ("POST", "/repos/owner/repo/issues/42/comments")
|
|
258
|
+
assert "Review text" in body["body"]
|
|
259
|
+
assert body["body"].endswith(FOOTER_A)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def test_overlapping_runs_on_one_head_each_annotate_only_their_own_review(
|
|
263
|
+
reviewer, monkeypatch
|
|
264
|
+
):
|
|
265
|
+
"""Two automations share a bot account and both review the same head."""
|
|
266
|
+
github = _GitHub(_marked(CONV_A), _marked(CONV_B)).install(reviewer, monkeypatch)
|
|
267
|
+
run_a = _record(CONV_A, "review-sonnet", "anthropic/claude-sonnet-4-5")
|
|
268
|
+
run_b = _record(CONV_B, "review-gpt", "openai/gpt-5.5")
|
|
269
|
+
|
|
270
|
+
_complete(reviewer, run_a)
|
|
271
|
+
_complete(reviewer, run_b)
|
|
272
|
+
# A collector that runs again finds its review already correct; the later,
|
|
273
|
+
# latest review by the same bot (B's) is still not its to rewrite.
|
|
274
|
+
_complete(reviewer, _record(CONV_A, "review-sonnet", "anthropic/claude-sonnet-4-5"))
|
|
275
|
+
|
|
276
|
+
assert [(method, path) for method, path, _ in github.writes] == [
|
|
277
|
+
("PUT", "/repos/owner/repo/pulls/42/reviews/101"),
|
|
278
|
+
("PUT", "/repos/owner/repo/pulls/42/reviews/102"),
|
|
279
|
+
]
|
|
280
|
+
assert github.reviews[0]["body"] == (
|
|
281
|
+
f"{_marked(CONV_A)}\n\n"
|
|
282
|
+
"LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`"
|
|
283
|
+
)
|
|
284
|
+
assert github.reviews[1]["body"] == (
|
|
285
|
+
f"{_marked(CONV_B)}\n\nLLM profile: `review-gpt` · Model: `openai/gpt-5.5`"
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def test_legacy_prompt_names_its_conversation_and_leaves_the_footer_to_the_script(
|
|
290
|
+
reviewer, monkeypatch, tmp_path
|
|
291
|
+
):
|
|
292
|
+
created = {}
|
|
293
|
+
|
|
294
|
+
def create(_url, _key, prompt, _workspace, agent=None, conversation_id=None):
|
|
295
|
+
created.update(prompt=prompt, conversation_id=conversation_id, agent=agent)
|
|
296
|
+
return conversation_id
|
|
297
|
+
|
|
298
|
+
monkeypatch.setattr(reviewer, "_prepare_repository", lambda *_: tmp_path)
|
|
299
|
+
monkeypatch.setattr(
|
|
300
|
+
reviewer,
|
|
301
|
+
"_get_agent_dict",
|
|
302
|
+
lambda *_: {"kind": "Agent", "llm": {"model": "openai/default-model"}},
|
|
303
|
+
)
|
|
304
|
+
monkeypatch.setattr(reviewer, "create_conversation", create)
|
|
305
|
+
monkeypatch.setattr(reviewer, "_post_github_comment", lambda *_: True)
|
|
306
|
+
reviews = {}
|
|
307
|
+
|
|
308
|
+
conversation_id = reviewer._process_review_request(
|
|
309
|
+
"token",
|
|
310
|
+
"http://agent",
|
|
311
|
+
"key",
|
|
312
|
+
"http://canvas",
|
|
313
|
+
"owner/repo",
|
|
314
|
+
{"number": 42, "head": {"sha": "head"}},
|
|
315
|
+
{"id": 7, "created_at": "2026-09-10T10:00:00Z"},
|
|
316
|
+
reviews,
|
|
317
|
+
lambda: None,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
assert conversation_id == created["conversation_id"]
|
|
321
|
+
assert f"<!-- openhands-review-run: {conversation_id} -->" in created["prompt"]
|
|
322
|
+
assert "do not write that footer yourself" in created["prompt"]
|
|
323
|
+
assert "LLM profile:" not in created["prompt"]
|
|
324
|
+
[record] = reviews.values()
|
|
325
|
+
assert record["conversation_id"] == conversation_id
|
|
326
|
+
assert (record["llm_profile"], record["llm_model"]) == (
|
|
327
|
+
"default",
|
|
328
|
+
"openai/default-model",
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
# --- Catalog reviewer (worker.py): provenance from the conversation that ran
|
|
333
|
+
|
|
334
|
+
PROFILE_ID = "11111111-1111-4111-8111-111111111111"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class _AgentServer:
|
|
338
|
+
"""The Agent Server routes the dispatcher reads provenance from."""
|
|
339
|
+
|
|
340
|
+
def __init__(self):
|
|
341
|
+
self.conversations = {}
|
|
342
|
+
self.profiles = [
|
|
343
|
+
{
|
|
344
|
+
"id": PROFILE_ID,
|
|
345
|
+
"name": "Code reviewer",
|
|
346
|
+
"revision": 3,
|
|
347
|
+
"llm_profile_ref": "review-sonnet",
|
|
348
|
+
}
|
|
349
|
+
]
|
|
350
|
+
self.paths = []
|
|
351
|
+
|
|
352
|
+
def add(self, conversation_id, model, revision=3):
|
|
353
|
+
self.conversations[str(conversation_id)] = {
|
|
354
|
+
"id": str(conversation_id),
|
|
355
|
+
"agent": {"kind": "Agent", "llm": {"model": model, "api_key": "**********"}},
|
|
356
|
+
"launched_agent_profile": {
|
|
357
|
+
"agent_profile_id": PROFILE_ID,
|
|
358
|
+
"revision": revision,
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
def __enter__(self):
|
|
363
|
+
server = self
|
|
364
|
+
|
|
365
|
+
class Handler(BaseHTTPRequestHandler):
|
|
366
|
+
def log_message(self, *_args):
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
def do_GET(self):
|
|
370
|
+
server.paths.append(self.path)
|
|
371
|
+
if (
|
|
372
|
+
self.headers.get("X-Session-API-Key") != "session"
|
|
373
|
+
or self.headers.get("X-Expose-Secrets") is not None
|
|
374
|
+
):
|
|
375
|
+
self.send_error(401)
|
|
376
|
+
return
|
|
377
|
+
if self.path == "/api/agent-profiles":
|
|
378
|
+
body = {"profiles": server.profiles}
|
|
379
|
+
elif self.path.startswith("/api/conversations/"):
|
|
380
|
+
body = server.conversations.get(self.path.rsplit("/", 1)[1])
|
|
381
|
+
else:
|
|
382
|
+
body = None
|
|
383
|
+
if body is None:
|
|
384
|
+
self.send_error(404)
|
|
385
|
+
return
|
|
386
|
+
content = json.dumps(body).encode()
|
|
387
|
+
self.send_response(200)
|
|
388
|
+
self.send_header("Content-Type", "application/json")
|
|
389
|
+
self.send_header("Content-Length", str(len(content)))
|
|
390
|
+
self.end_headers()
|
|
391
|
+
self.wfile.write(content)
|
|
392
|
+
|
|
393
|
+
self._http = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
394
|
+
self._thread = threading.Thread(target=self._http.serve_forever, daemon=True)
|
|
395
|
+
self._thread.start()
|
|
396
|
+
self.url = f"http://127.0.0.1:{self._http.server_port}"
|
|
397
|
+
return self
|
|
398
|
+
|
|
399
|
+
def __exit__(self, *_exc):
|
|
400
|
+
self._http.shutdown()
|
|
401
|
+
self._thread.join()
|
|
402
|
+
self._http.server_close()
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@pytest.fixture
|
|
406
|
+
def shipped(tmp_path, monkeypatch):
|
|
407
|
+
"""The catalog bundle's worker, with the SDK and KV boundaries faked."""
|
|
408
|
+
from unittest.mock import MagicMock
|
|
409
|
+
|
|
410
|
+
from github_automation_helpers import worker
|
|
411
|
+
|
|
412
|
+
module = worker("github-pr-reviewer", tmp_path, monkeypatch)
|
|
413
|
+
dispatch = sys.modules["agent_conversation"]
|
|
414
|
+
kv = {}
|
|
415
|
+
|
|
416
|
+
def kv_request(key, method, value=None):
|
|
417
|
+
from urllib.error import HTTPError
|
|
418
|
+
|
|
419
|
+
key, _, query = key.partition("?")
|
|
420
|
+
if method == "GET":
|
|
421
|
+
return kv.get(key)
|
|
422
|
+
if query == "nx=true" and key in kv:
|
|
423
|
+
raise HTTPError(key, 409, "exists", {}, None)
|
|
424
|
+
kv[key] = value
|
|
425
|
+
return {"key": key, "value": value}
|
|
426
|
+
|
|
427
|
+
monkeypatch.setattr(dispatch, "_kv_request", kv_request)
|
|
428
|
+
monkeypatch.setattr(dispatch, "_register_tools", lambda: None)
|
|
429
|
+
workspace = MagicMock()
|
|
430
|
+
workspace.__enter__.return_value = workspace
|
|
431
|
+
workspace.get_secrets.return_value = {}
|
|
432
|
+
monkeypatch.setattr(dispatch, "RemoteWorkspace", lambda **_kwargs: workspace)
|
|
433
|
+
sent = {}
|
|
434
|
+
live = {}
|
|
435
|
+
|
|
436
|
+
def attach(_workspace, conversation_id, visualizer=None):
|
|
437
|
+
if str(conversation_id) not in live:
|
|
438
|
+
raise dispatch.httpx.HTTPStatusError(
|
|
439
|
+
"missing", request=MagicMock(), response=MagicMock(status_code=404)
|
|
440
|
+
)
|
|
441
|
+
conversation = MagicMock()
|
|
442
|
+
conversation.state.execution_status = (
|
|
443
|
+
dispatch.ConversationExecutionStatus.IDLE
|
|
444
|
+
)
|
|
445
|
+
conversation.send_message.side_effect = (
|
|
446
|
+
lambda text: sent.setdefault(str(conversation_id), []).append(text)
|
|
447
|
+
)
|
|
448
|
+
return conversation
|
|
449
|
+
|
|
450
|
+
def create(_workspace, request, visualizer=None):
|
|
451
|
+
live[str(request.conversation_id)] = request
|
|
452
|
+
sent.setdefault(str(request.conversation_id), []).append(
|
|
453
|
+
request.initial_message.content[0].text
|
|
454
|
+
)
|
|
455
|
+
return MagicMock()
|
|
456
|
+
|
|
457
|
+
monkeypatch.setattr(dispatch.RemoteConversation, "attach", attach)
|
|
458
|
+
monkeypatch.setattr(dispatch.RemoteConversation, "create", create)
|
|
459
|
+
monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD", raising=False)
|
|
460
|
+
return types.SimpleNamespace(module=module, kv=kv, sent=sent, live=live)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _dispatcher(shipped, monkeypatch, server, automation_id):
|
|
464
|
+
monkeypatch.setenv("AGENT_SERVER_URL", server.url)
|
|
465
|
+
monkeypatch.setenv("SESSION_API_KEY", "session")
|
|
466
|
+
monkeypatch.setenv("AUTOMATION_AGENT_PROFILE_ID", PROFILE_ID)
|
|
467
|
+
monkeypatch.setenv(
|
|
468
|
+
"AUTOMATION_EVENT_PAYLOAD", json.dumps({"automation_id": automation_id})
|
|
469
|
+
)
|
|
470
|
+
dispatcher = sys.modules["agent_conversation"].AgentConversationDispatcher()
|
|
471
|
+
monkeypatch.delenv("AUTOMATION_EVENT_PAYLOAD")
|
|
472
|
+
return dispatcher
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def _pr_reviewer(shipped, dispatcher, github):
|
|
476
|
+
run = object.__new__(shipped.module.PullRequestReviewer)
|
|
477
|
+
run.config = {"trigger_label": "openhands-review"}
|
|
478
|
+
run.repository = "owner/repo"
|
|
479
|
+
run.token = "token"
|
|
480
|
+
run.token_name = "GITHUB_TOKEN"
|
|
481
|
+
run.github_login = "all-hands-bot"
|
|
482
|
+
run.dispatcher = dispatcher
|
|
483
|
+
run.gh = github.gh
|
|
484
|
+
run.gh_pages = github.gh_pages
|
|
485
|
+
return run
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@pytest.mark.parametrize("reported_model,configured_model", [
|
|
489
|
+
("selected-by-acp-server", "acp-managed"),
|
|
490
|
+
("selected-by-acp-server", "requested-override"),
|
|
491
|
+
(None, "acp-managed"),
|
|
492
|
+
(None, "requested-override"),
|
|
493
|
+
])
|
|
494
|
+
def test_shipped_worker_displays_acp_session_model_instead_of_configuration(
|
|
495
|
+
shipped, monkeypatch, reported_model, configured_model
|
|
496
|
+
):
|
|
497
|
+
repository = _Repository()
|
|
498
|
+
with _AgentServer() as server:
|
|
499
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
500
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
501
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
502
|
+
server.add(conversation_id, configured_model)
|
|
503
|
+
server.conversations[conversation_id].update(
|
|
504
|
+
agent={"kind": "ACPAgent", "llm": {"model": configured_model}},
|
|
505
|
+
current_model_id=reported_model,
|
|
506
|
+
)
|
|
507
|
+
server.profiles[0]["llm_profile_ref"] = None
|
|
508
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
509
|
+
|
|
510
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
511
|
+
|
|
512
|
+
assert review["body"].endswith(
|
|
513
|
+
f"LLM profile: `unknown` · Model: `{reported_model or 'unknown'}`"
|
|
514
|
+
)
|
|
515
|
+
assert server.paths == [f"/api/conversations/{conversation_id}", "/api/agent-profiles"]
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
class _Repository:
|
|
519
|
+
"""A pull request's reviews, shared by every automation using one bot."""
|
|
520
|
+
|
|
521
|
+
def __init__(self, sha="head-2"):
|
|
522
|
+
# The pull request closes a high-priority issue, so an approval may
|
|
523
|
+
# hand off to a maintainer.
|
|
524
|
+
self.pr = {
|
|
525
|
+
"number": 2,
|
|
526
|
+
"head": {"sha": sha},
|
|
527
|
+
"labels": [],
|
|
528
|
+
"body": "Fixes #7",
|
|
529
|
+
}
|
|
530
|
+
self.reviews = []
|
|
531
|
+
self.writes = []
|
|
532
|
+
|
|
533
|
+
def publish(self, body, sha=None):
|
|
534
|
+
review = {
|
|
535
|
+
"id": 101 + len(self.reviews),
|
|
536
|
+
"user": {"login": "all-hands-bot"},
|
|
537
|
+
"commit_id": sha or self.pr["head"]["sha"],
|
|
538
|
+
"state": "COMMENTED",
|
|
539
|
+
"submitted_at": f"2026-09-10T10:0{len(self.reviews) + 1}:00Z",
|
|
540
|
+
"body": body,
|
|
541
|
+
}
|
|
542
|
+
self.reviews.append(review)
|
|
543
|
+
return review
|
|
544
|
+
|
|
545
|
+
def gh(self, method, path, body=None):
|
|
546
|
+
if (method, path) == ("GET", ""):
|
|
547
|
+
return {"id": 99}
|
|
548
|
+
if (method, path) == ("GET", "/pulls/2"):
|
|
549
|
+
return self.pr
|
|
550
|
+
if (method, path) == ("GET", "/issues/7"):
|
|
551
|
+
return {"labels": [{"name": "priority:high"}]}
|
|
552
|
+
self.writes.append((method, path, body))
|
|
553
|
+
if method == "PUT":
|
|
554
|
+
review_id = int(path.rsplit("/", 1)[1])
|
|
555
|
+
next(r for r in self.reviews if r["id"] == review_id)["body"] = body["body"]
|
|
556
|
+
return {}
|
|
557
|
+
|
|
558
|
+
def gh_pages(self, path):
|
|
559
|
+
assert path == "/pulls/2/reviews"
|
|
560
|
+
return [dict(review) for review in self.reviews]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
TRIGGER = {"id": 42, "created_at": "2026-09-10T10:00:00Z"}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _review_text(prompt):
|
|
567
|
+
"""The review an agent following the prompt publishes."""
|
|
568
|
+
marker = re.search(r"`(<!-- openhands-review-run: [^`]+ -->)`", prompt).group(1)
|
|
569
|
+
return f"{DISCLOSURE}\n{marker}\n\nLooks correct.\n\n✅ APPROVED"
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def test_shipped_worker_stamps_the_selected_profile_model_on_its_review(
|
|
573
|
+
shipped, monkeypatch
|
|
574
|
+
):
|
|
575
|
+
repository = _Repository()
|
|
576
|
+
with _AgentServer() as server:
|
|
577
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
578
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
579
|
+
result = run._start_review(99, repository.pr, TRIGGER, "head-2", None)
|
|
580
|
+
conversation_id = result["conversation_id"]
|
|
581
|
+
# The profile-backed conversation the server started runs the model
|
|
582
|
+
# its selected agent profile resolves to.
|
|
583
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
584
|
+
[prompt] = shipped.sent[conversation_id]
|
|
585
|
+
review = repository.publish(_review_text(prompt))
|
|
586
|
+
|
|
587
|
+
completed = run._finish_completed_review(repository.pr, TRIGGER)
|
|
588
|
+
|
|
589
|
+
assert result["disposition"] == "created"
|
|
590
|
+
assert f"<!-- openhands-review-run: {conversation_id} -->" in prompt
|
|
591
|
+
assert "LLM profile:" not in prompt
|
|
592
|
+
assert completed is True
|
|
593
|
+
assert repository.writes == [
|
|
594
|
+
(
|
|
595
|
+
"PUT",
|
|
596
|
+
f"/pulls/2/reviews/{review['id']}",
|
|
597
|
+
{
|
|
598
|
+
"body": _review_text(prompt)
|
|
599
|
+
+ "\n\nLLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`"
|
|
600
|
+
},
|
|
601
|
+
)
|
|
602
|
+
]
|
|
603
|
+
# Provenance came from the conversation and its launching agent profile,
|
|
604
|
+
# never from the scanner's own default settings.
|
|
605
|
+
assert "/api/settings" not in server.paths
|
|
606
|
+
assert server.paths == [
|
|
607
|
+
f"/api/conversations/{conversation_id}",
|
|
608
|
+
"/api/agent-profiles",
|
|
609
|
+
]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def test_shipped_worker_reports_a_reused_conversation_s_own_llm(shipped, monkeypatch):
|
|
613
|
+
repository = _Repository()
|
|
614
|
+
with _AgentServer() as server:
|
|
615
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
616
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
617
|
+
first = run._start_review(99, repository.pr, TRIGGER, "head-2", None)
|
|
618
|
+
conversation_id = first["conversation_id"]
|
|
619
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
620
|
+
repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
621
|
+
# The agent profile is edited to another LLM profile, and the PR
|
|
622
|
+
# moves to a new head that the same conversation reviews next.
|
|
623
|
+
server.profiles[0].update(revision=4, llm_profile_ref="review-gpt")
|
|
624
|
+
repository.pr["head"]["sha"] = "head-3"
|
|
625
|
+
second = run._start_review(
|
|
626
|
+
99, repository.pr, {"id": 43, "created_at": "2026-09-10T11:00:00Z"},
|
|
627
|
+
"head-3", None,
|
|
628
|
+
)
|
|
629
|
+
prompt = shipped.sent[conversation_id][-1]
|
|
630
|
+
review = repository.publish(_review_text(prompt))
|
|
631
|
+
|
|
632
|
+
run._finish_completed_review(
|
|
633
|
+
repository.pr, {"id": 43, "created_at": "2026-09-10T10:00:30Z"}
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
assert second == {"disposition": "resumed", "conversation_id": conversation_id}
|
|
637
|
+
assert f"<!-- openhands-review-run: {conversation_id} -->" in prompt
|
|
638
|
+
# The reused conversation still runs the model it was launched with. Its
|
|
639
|
+
# launching profile has moved on, so the profile name is not guessed.
|
|
640
|
+
[(_, path, body)] = repository.writes
|
|
641
|
+
assert path == f"/pulls/2/reviews/{review['id']}"
|
|
642
|
+
assert body["body"].endswith(
|
|
643
|
+
"LLM profile: `unknown` · Model: `anthropic/claude-sonnet-4-5`"
|
|
644
|
+
)
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def test_two_automations_sharing_a_bot_each_annotate_only_their_own_review(
|
|
648
|
+
shipped, monkeypatch
|
|
649
|
+
):
|
|
650
|
+
"""Overlapping runs, same bot, same head: neither rewrites the other's review."""
|
|
651
|
+
repository = _Repository()
|
|
652
|
+
with _AgentServer() as server:
|
|
653
|
+
server.profiles.append(
|
|
654
|
+
{"id": "22222222-2222-4222-8222-222222222222", "name": "GPT reviewer",
|
|
655
|
+
"revision": 1, "llm_profile_ref": "review-gpt"}
|
|
656
|
+
)
|
|
657
|
+
with (
|
|
658
|
+
_dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher_a,
|
|
659
|
+
_dispatcher(shipped, monkeypatch, server, "automation-b") as dispatcher_b,
|
|
660
|
+
):
|
|
661
|
+
run_a = _pr_reviewer(shipped, dispatcher_a, repository)
|
|
662
|
+
run_b = _pr_reviewer(shipped, dispatcher_b, repository)
|
|
663
|
+
conversation_a = run_a._start_review(
|
|
664
|
+
99, repository.pr, TRIGGER, "head-2", None
|
|
665
|
+
)["conversation_id"]
|
|
666
|
+
conversation_b = run_b._start_review(
|
|
667
|
+
99, repository.pr, TRIGGER, "head-2", None
|
|
668
|
+
)["conversation_id"]
|
|
669
|
+
server.add(conversation_a, "anthropic/claude-sonnet-4-5")
|
|
670
|
+
server.add(conversation_b, "openai/gpt-5.5")
|
|
671
|
+
server.conversations[conversation_b]["launched_agent_profile"] = {
|
|
672
|
+
"agent_profile_id": "22222222-2222-4222-8222-222222222222",
|
|
673
|
+
"revision": 1,
|
|
674
|
+
}
|
|
675
|
+
review_a = repository.publish(_review_text(shipped.sent[conversation_a][0]))
|
|
676
|
+
review_b = repository.publish(_review_text(shipped.sent[conversation_b][0]))
|
|
677
|
+
|
|
678
|
+
# B's review is the latest one by the bot on this head, which is
|
|
679
|
+
# the review a login/head/time match would have rewritten.
|
|
680
|
+
run_a._finish_completed_review(repository.pr, TRIGGER)
|
|
681
|
+
run_b._finish_completed_review(repository.pr, TRIGGER)
|
|
682
|
+
run_a._finish_completed_review(repository.pr, TRIGGER)
|
|
683
|
+
|
|
684
|
+
assert conversation_a != conversation_b
|
|
685
|
+
assert [path for _, path, _ in repository.writes] == [
|
|
686
|
+
f"/pulls/2/reviews/{review_a['id']}",
|
|
687
|
+
f"/pulls/2/reviews/{review_b['id']}",
|
|
688
|
+
]
|
|
689
|
+
bodies = {review["id"]: review["body"] for review in repository.reviews}
|
|
690
|
+
assert bodies[review_a["id"]].endswith(
|
|
691
|
+
"LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`"
|
|
692
|
+
)
|
|
693
|
+
assert bodies[review_b["id"]].endswith(
|
|
694
|
+
"LLM profile: `review-gpt` · Model: `openai/gpt-5.5`"
|
|
695
|
+
)
|
|
696
|
+
assert "gpt" not in bodies[review_a["id"]]
|
|
697
|
+
assert "claude" not in bodies[review_b["id"]]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def test_worker_reads_the_verdict_above_the_provenance_footer(shipped, monkeypatch):
|
|
701
|
+
"""A stamped approval still hands off: the footer follows the verdict."""
|
|
702
|
+
repository = _Repository()
|
|
703
|
+
repository.publish(
|
|
704
|
+
f"{DISCLOSURE}\n\nLooks correct.\n\n✅ APPROVED\n\n"
|
|
705
|
+
"LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`"
|
|
706
|
+
)
|
|
707
|
+
run = _pr_reviewer(shipped, None, repository)
|
|
708
|
+
run.config["maintainers"] = "alice,bob"
|
|
709
|
+
handoffs = []
|
|
710
|
+
monkeypatch.setattr(
|
|
711
|
+
shipped.module,
|
|
712
|
+
"request_maintainer_review",
|
|
713
|
+
lambda _run, pr, maintainers: handoffs.append(maintainers) or "alice",
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
717
|
+
assert handoffs == [["alice", "bob"]]
|
|
718
|
+
assert repository.writes == []
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def test_a_stamped_footer_is_kept_after_the_agent_profile_changes(
|
|
722
|
+
shipped, monkeypatch
|
|
723
|
+
):
|
|
724
|
+
"""Provenance is recorded once; a later profile edit cannot rewrite it."""
|
|
725
|
+
repository = _Repository()
|
|
726
|
+
with _AgentServer() as server:
|
|
727
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
728
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
729
|
+
conversation_id = run._start_review(
|
|
730
|
+
99, repository.pr, TRIGGER, "head-2", None
|
|
731
|
+
)["conversation_id"]
|
|
732
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
733
|
+
repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
734
|
+
run._finish_completed_review(repository.pr, TRIGGER)
|
|
735
|
+
stamped = repository.reviews[0]["body"]
|
|
736
|
+
# The agent profile now points at another LLM profile, so a fresh
|
|
737
|
+
# read would name the profile `unknown`.
|
|
738
|
+
server.profiles[0].update(revision=4, llm_profile_ref="review-gpt")
|
|
739
|
+
|
|
740
|
+
run._finish_completed_review(repository.pr, TRIGGER)
|
|
741
|
+
|
|
742
|
+
assert stamped.endswith(
|
|
743
|
+
"LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`"
|
|
744
|
+
)
|
|
745
|
+
assert len(repository.writes) == 1
|
|
746
|
+
assert repository.reviews[0]["body"] == stamped
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
@pytest.mark.parametrize("footer", [
|
|
750
|
+
"LLM profile: `invented-profile` · Model: `invented-model`",
|
|
751
|
+
"LLM profile: `invented-profile` · Model: `anthropic/claude-sonnet-4-5`",
|
|
752
|
+
])
|
|
753
|
+
def test_agent_written_footer_is_replaced_by_recorded_metadata(shipped, monkeypatch, footer):
|
|
754
|
+
repository = _Repository()
|
|
755
|
+
with _AgentServer() as server:
|
|
756
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
757
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
758
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
759
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
760
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0]) + "\n\n" + footer)
|
|
761
|
+
|
|
762
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
763
|
+
|
|
764
|
+
assert review["body"].endswith("LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`")
|
|
765
|
+
assert "invented" not in review["body"]
|
|
766
|
+
assert len(repository.writes) == 1
|
|
767
|
+
assert server.paths == [f"/api/conversations/{conversation_id}", "/api/agent-profiles"]
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def test_recorded_footer_is_restored_after_review_text_is_changed(shipped, monkeypatch):
|
|
771
|
+
repository = _Repository()
|
|
772
|
+
with _AgentServer() as server:
|
|
773
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
774
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
775
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
776
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
777
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
778
|
+
run._finish_completed_review(repository.pr, TRIGGER)
|
|
779
|
+
stamped = review["body"]
|
|
780
|
+
server.profiles[0].update(revision=4, llm_profile_ref="edited-profile")
|
|
781
|
+
review["body"] = stamped.replace("review-sonnet", "invented-profile")
|
|
782
|
+
|
|
783
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
784
|
+
|
|
785
|
+
assert review["body"] == stamped
|
|
786
|
+
assert len(repository.writes) == 2
|
|
787
|
+
assert server.paths == [f"/api/conversations/{conversation_id}", "/api/agent-profiles"]
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def test_put_retry_uses_recorded_metadata_after_profile_edit(shipped, monkeypatch):
|
|
791
|
+
repository = _Repository()
|
|
792
|
+
with _AgentServer() as server:
|
|
793
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
794
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
795
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
796
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
797
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
798
|
+
original = run.gh
|
|
799
|
+
|
|
800
|
+
def rejected(method, path, body=None):
|
|
801
|
+
if method == "PUT":
|
|
802
|
+
raise RuntimeError("GitHub temporarily unavailable")
|
|
803
|
+
return original(method, path, body)
|
|
804
|
+
|
|
805
|
+
run.gh = rejected
|
|
806
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
807
|
+
assert "LLM profile:" not in review["body"]
|
|
808
|
+
server.profiles[0].update(revision=4, llm_profile_ref="edited-profile")
|
|
809
|
+
run.gh = original
|
|
810
|
+
|
|
811
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
812
|
+
|
|
813
|
+
assert review["body"].endswith("LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`")
|
|
814
|
+
assert len(repository.writes) == 1
|
|
815
|
+
assert server.paths == [f"/api/conversations/{conversation_id}", "/api/agent-profiles"]
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def test_missing_metadata_cannot_preserve_an_agent_written_footer(shipped, monkeypatch):
|
|
819
|
+
repository = _Repository()
|
|
820
|
+
with _AgentServer() as server:
|
|
821
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
822
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
823
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
824
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0])
|
|
825
|
+
+ "\n\nLLM profile: `invented-profile` · Model: `invented-model`")
|
|
826
|
+
|
|
827
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
828
|
+
|
|
829
|
+
assert review["body"].endswith("LLM profile: `unknown` · Model: `unknown`")
|
|
830
|
+
assert "invented" not in review["body"]
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def test_concurrent_collector_uses_the_first_recorded_snapshot(shipped, monkeypatch):
|
|
834
|
+
from urllib.error import HTTPError
|
|
835
|
+
|
|
836
|
+
repository = _Repository()
|
|
837
|
+
with _AgentServer() as server:
|
|
838
|
+
with _dispatcher(shipped, monkeypatch, server, "automation-a") as dispatcher:
|
|
839
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
840
|
+
conversation_id = run._start_review(99, repository.pr, TRIGGER, "head-2", None)["conversation_id"]
|
|
841
|
+
server.add(conversation_id, "anthropic/claude-sonnet-4-5")
|
|
842
|
+
profile, model = dispatcher.llm_provenance(conversation_id)
|
|
843
|
+
winner = {"conversation_id": conversation_id, "profile": profile, "model": model}
|
|
844
|
+
server.profiles[0].update(revision=4, llm_profile_ref="edited-profile")
|
|
845
|
+
review = repository.publish(_review_text(shipped.sent[conversation_id][0]))
|
|
846
|
+
client = sys.modules["agent_conversation"]
|
|
847
|
+
original = client._kv_request
|
|
848
|
+
|
|
849
|
+
def raced(key, method, value=None):
|
|
850
|
+
if method == "PUT" and key.endswith("?nx=true"):
|
|
851
|
+
original(key.partition("?")[0], "PUT", winner)
|
|
852
|
+
raise HTTPError(key, 409, "another collector recorded first", {}, None)
|
|
853
|
+
return original(key, method, value)
|
|
854
|
+
|
|
855
|
+
monkeypatch.setattr(client, "_kv_request", raced)
|
|
856
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
857
|
+
|
|
858
|
+
assert review["body"].endswith("LLM profile: `review-sonnet` · Model: `anthropic/claude-sonnet-4-5`")
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def test_metadata_error_replaces_an_untrusted_footer_without_blocking_verdict(
|
|
862
|
+
shipped, monkeypatch
|
|
863
|
+
):
|
|
864
|
+
repository = _Repository()
|
|
865
|
+
review = repository.publish(f"{DISCLOSURE}\n<!-- openhands-review-run: {CONV_A} -->\n\n"
|
|
866
|
+
"Looks correct.\n\n✅ APPROVED\n\nLLM profile: `invented` · Model: `invented`")
|
|
867
|
+
|
|
868
|
+
class Unavailable:
|
|
869
|
+
def subject_conversation(self, subject):
|
|
870
|
+
return CONV_A
|
|
871
|
+
|
|
872
|
+
def llm_provenance(self, conversation_id):
|
|
873
|
+
raise RuntimeError("agent server unavailable")
|
|
874
|
+
|
|
875
|
+
run = _pr_reviewer(shipped, Unavailable(), repository)
|
|
876
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
877
|
+
assert review["body"].endswith("LLM profile: `unknown` · Model: `unknown`")
|
|
878
|
+
assert "invented" not in review["body"]
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
@pytest.mark.parametrize("failure", ["metadata", "github"])
|
|
882
|
+
def test_one_failed_annotation_does_not_leave_later_owned_footer_untrusted(
|
|
883
|
+
shipped, monkeypatch, failure
|
|
884
|
+
):
|
|
885
|
+
from unittest.mock import Mock
|
|
886
|
+
|
|
887
|
+
repository = _Repository()
|
|
888
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
889
|
+
first = repository.publish(f"{marker}\n\nFirst assessment.\n\n✅ APPROVED")
|
|
890
|
+
second = repository.publish(
|
|
891
|
+
f"{marker}\n\nFinal assessment.\n\n✅ APPROVED\n\n"
|
|
892
|
+
"LLM profile: `invented-profile` · Model: `invented-model`"
|
|
893
|
+
)
|
|
894
|
+
|
|
895
|
+
class Dispatcher:
|
|
896
|
+
def subject_conversation(self, _subject):
|
|
897
|
+
return CONV_A
|
|
898
|
+
|
|
899
|
+
def llm_provenance(self, _conversation_id):
|
|
900
|
+
if failure == "metadata":
|
|
901
|
+
raise RuntimeError("metadata API unavailable")
|
|
902
|
+
return "actual-profile", "actual-model"
|
|
903
|
+
|
|
904
|
+
run = _pr_reviewer(shipped, Dispatcher(), repository)
|
|
905
|
+
if failure == "github":
|
|
906
|
+
original = run.gh
|
|
907
|
+
|
|
908
|
+
def rejected(method, path, body=None):
|
|
909
|
+
if method == "PUT" and path.endswith(f"/{first['id']}"):
|
|
910
|
+
raise RuntimeError("first review edit unavailable")
|
|
911
|
+
return original(method, path, body)
|
|
912
|
+
|
|
913
|
+
run.gh = rejected
|
|
914
|
+
run.config["maintainers"] = "alice"
|
|
915
|
+
handoff = Mock(return_value="alice")
|
|
916
|
+
monkeypatch.setattr(shipped.module, "request_maintainer_review", handoff)
|
|
917
|
+
|
|
918
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
919
|
+
assert handoff.call_count == 1
|
|
920
|
+
assert "invented" not in second["body"]
|
|
921
|
+
profile, model = (
|
|
922
|
+
("unknown", "unknown") if failure == "metadata"
|
|
923
|
+
else ("actual-profile", "actual-model")
|
|
924
|
+
)
|
|
925
|
+
assert second["body"].endswith(f"LLM profile: `{profile}` · Model: `{model}`")
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@pytest.mark.parametrize("footer", ["", "\n\nLLM profile: `invented` · Model: `invented`"])
|
|
929
|
+
def test_lost_kv_receipt_uses_committed_snapshot_after_a_concurrent_stamp(
|
|
930
|
+
shipped, monkeypatch, footer
|
|
931
|
+
):
|
|
932
|
+
repository = _Repository()
|
|
933
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
934
|
+
review = repository.publish(f"{marker}\n\nAssessment.\n\n✅ APPROVED{footer}")
|
|
935
|
+
|
|
936
|
+
class Dispatcher:
|
|
937
|
+
def subject_conversation(self, _subject):
|
|
938
|
+
return CONV_A
|
|
939
|
+
|
|
940
|
+
def llm_provenance(self, _conversation_id):
|
|
941
|
+
return "actual-profile", "actual-model"
|
|
942
|
+
|
|
943
|
+
run = _pr_reviewer(shipped, Dispatcher(), repository)
|
|
944
|
+
client = sys.modules["agent_conversation"]
|
|
945
|
+
original = client._kv_request
|
|
946
|
+
|
|
947
|
+
def interleaved(key, method, value=None):
|
|
948
|
+
result = original(key, method, value)
|
|
949
|
+
if method == "PUT" and key.endswith("?nx=true"):
|
|
950
|
+
# The snapshot commits, then B stamps it before A loses its receipt.
|
|
951
|
+
run._annotate_llm_provenance(repository.pr, repository.gh_pages("/pulls/2/reviews"))
|
|
952
|
+
raise TimeoutError("KV write committed but receipt was lost")
|
|
953
|
+
return result
|
|
954
|
+
|
|
955
|
+
monkeypatch.setattr(client, "_kv_request", interleaved)
|
|
956
|
+
|
|
957
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
958
|
+
assert review["body"].endswith("LLM profile: `actual-profile` · Model: `actual-model`")
|
|
959
|
+
assert "invented" not in review["body"]
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
@pytest.mark.parametrize("failure", ["missing_metadata", "metadata_error", "kv_read_error"])
|
|
963
|
+
def test_failed_lookup_recovers_a_concurrent_collectors_saved_attribution(
|
|
964
|
+
shipped, monkeypatch, failure
|
|
965
|
+
):
|
|
966
|
+
repository = _Repository()
|
|
967
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
968
|
+
review = repository.publish(
|
|
969
|
+
f"{marker}\n\nAssessment.\n\n✅ APPROVED\n\n"
|
|
970
|
+
"LLM profile: `invented-profile` · Model: `invented-model`"
|
|
971
|
+
)
|
|
972
|
+
|
|
973
|
+
def other_collector():
|
|
974
|
+
run._annotate_llm_provenance(repository.pr, repository.gh_pages("/pulls/2/reviews"))
|
|
975
|
+
|
|
976
|
+
class Dispatcher:
|
|
977
|
+
first = True
|
|
978
|
+
|
|
979
|
+
def subject_conversation(self, _subject):
|
|
980
|
+
return CONV_A
|
|
981
|
+
|
|
982
|
+
def llm_provenance(self, _conversation_id):
|
|
983
|
+
if self.first and failure != "kv_read_error":
|
|
984
|
+
self.first = False
|
|
985
|
+
# B records/stamps while A's metadata request is pending.
|
|
986
|
+
other_collector()
|
|
987
|
+
if failure == "missing_metadata":
|
|
988
|
+
return None
|
|
989
|
+
raise TimeoutError("A's metadata request timed out")
|
|
990
|
+
return "actual-profile", "actual-model"
|
|
991
|
+
|
|
992
|
+
run = _pr_reviewer(shipped, Dispatcher(), repository)
|
|
993
|
+
if failure == "kv_read_error":
|
|
994
|
+
client = sys.modules["agent_conversation"]
|
|
995
|
+
original = client._kv_request
|
|
996
|
+
first = True
|
|
997
|
+
|
|
998
|
+
def interleaved(key, method, value=None):
|
|
999
|
+
nonlocal first
|
|
1000
|
+
if first and method == "GET" and key.startswith("review-provenance-"):
|
|
1001
|
+
first = False
|
|
1002
|
+
other_collector()
|
|
1003
|
+
raise TimeoutError("A's KV read timed out")
|
|
1004
|
+
return original(key, method, value)
|
|
1005
|
+
|
|
1006
|
+
monkeypatch.setattr(client, "_kv_request", interleaved)
|
|
1007
|
+
|
|
1008
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1009
|
+
|
|
1010
|
+
snapshot = shipped.kv[f"review-provenance-{CONV_A}-{review['id']}"]
|
|
1011
|
+
assert (snapshot["profile"], snapshot["model"]) == ("actual-profile", "actual-model")
|
|
1012
|
+
assert review["body"].endswith("LLM profile: `actual-profile` · Model: `actual-model`")
|
|
1013
|
+
assert "invented" not in review["body"]
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
@pytest.mark.parametrize("failure", ["missing", "error"])
|
|
1017
|
+
def test_atomic_unknown_fallback_adopts_a_later_collectors_tuple(
|
|
1018
|
+
shipped, monkeypatch, failure
|
|
1019
|
+
):
|
|
1020
|
+
repository = _Repository()
|
|
1021
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
1022
|
+
review = repository.publish(
|
|
1023
|
+
f"{marker}\n\nAssessment.\n\n✅ APPROVED\n\n"
|
|
1024
|
+
"LLM profile: `invented` · Model: `invented`"
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
class Dispatcher:
|
|
1028
|
+
available = False
|
|
1029
|
+
|
|
1030
|
+
def subject_conversation(self, _subject):
|
|
1031
|
+
return CONV_A
|
|
1032
|
+
|
|
1033
|
+
def llm_provenance(self, _conversation_id):
|
|
1034
|
+
if self.available:
|
|
1035
|
+
return "actual-profile", "actual-model"
|
|
1036
|
+
if failure == "error":
|
|
1037
|
+
raise TimeoutError("metadata unavailable")
|
|
1038
|
+
return None
|
|
1039
|
+
|
|
1040
|
+
dispatcher = Dispatcher()
|
|
1041
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
1042
|
+
client = sys.modules["agent_conversation"]
|
|
1043
|
+
original = client._kv_request
|
|
1044
|
+
reads = 0
|
|
1045
|
+
|
|
1046
|
+
def interleaved(key, method, value=None):
|
|
1047
|
+
nonlocal reads
|
|
1048
|
+
result = original(key, method, value)
|
|
1049
|
+
if method == "GET" and key.startswith("review-provenance-"):
|
|
1050
|
+
reads += 1
|
|
1051
|
+
if reads == 3:
|
|
1052
|
+
# Even the fallback's own GET is stale: B wins after the read
|
|
1053
|
+
# is evaluated but before A receives the empty response.
|
|
1054
|
+
assert result is None
|
|
1055
|
+
dispatcher.available = True
|
|
1056
|
+
run._annotate_llm_provenance(repository.pr, repository.gh_pages("/pulls/2/reviews"))
|
|
1057
|
+
return result
|
|
1058
|
+
|
|
1059
|
+
monkeypatch.setattr(client, "_kv_request", interleaved)
|
|
1060
|
+
|
|
1061
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1062
|
+
|
|
1063
|
+
snapshot = shipped.kv[f"review-provenance-{CONV_A}-{review['id']}"]
|
|
1064
|
+
assert (snapshot["profile"], snapshot["model"]) == ("actual-profile", "actual-model")
|
|
1065
|
+
assert review["body"].endswith("LLM profile: `actual-profile` · Model: `actual-model`")
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def test_unknown_winning_tuple_is_retained_after_metadata_recovers(shipped):
|
|
1069
|
+
repository = _Repository()
|
|
1070
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
1071
|
+
review = repository.publish(
|
|
1072
|
+
f"{marker}\n\nAssessment.\n\n✅ APPROVED\n\n"
|
|
1073
|
+
"LLM profile: `invented` · Model: `invented`"
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
class Dispatcher:
|
|
1077
|
+
available = False
|
|
1078
|
+
|
|
1079
|
+
def subject_conversation(self, _subject):
|
|
1080
|
+
return CONV_A
|
|
1081
|
+
|
|
1082
|
+
def llm_provenance(self, _conversation_id):
|
|
1083
|
+
return ("actual-profile", "actual-model") if self.available else None
|
|
1084
|
+
|
|
1085
|
+
dispatcher = Dispatcher()
|
|
1086
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
1087
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1088
|
+
stamped = review["body"]
|
|
1089
|
+
dispatcher.available = True
|
|
1090
|
+
|
|
1091
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1092
|
+
|
|
1093
|
+
assert review["body"] == stamped
|
|
1094
|
+
assert stamped.endswith("LLM profile: `unknown` · Model: `unknown`")
|
|
1095
|
+
assert len(repository.writes) == 1
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def test_unavailable_kv_skips_uncoordinated_footer_edit_and_completes(
|
|
1099
|
+
shipped, monkeypatch, capsys
|
|
1100
|
+
):
|
|
1101
|
+
repository = _Repository()
|
|
1102
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
1103
|
+
review = repository.publish(
|
|
1104
|
+
f"{marker}\n\nAssessment.\n\n✅ APPROVED\n\n"
|
|
1105
|
+
"LLM profile: `invented` · Model: `invented`"
|
|
1106
|
+
)
|
|
1107
|
+
original_body = review["body"]
|
|
1108
|
+
|
|
1109
|
+
class Dispatcher:
|
|
1110
|
+
def subject_conversation(self, _subject):
|
|
1111
|
+
return CONV_A
|
|
1112
|
+
|
|
1113
|
+
def llm_provenance(self, _conversation_id):
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
client = sys.modules["agent_conversation"]
|
|
1117
|
+
original = client._kv_request
|
|
1118
|
+
|
|
1119
|
+
def rejected(key, method, value=None):
|
|
1120
|
+
if method == "PUT":
|
|
1121
|
+
raise RuntimeError("KV unavailable")
|
|
1122
|
+
return original(key, method, value)
|
|
1123
|
+
|
|
1124
|
+
monkeypatch.setattr(client, "_kv_request", rejected)
|
|
1125
|
+
run = _pr_reviewer(shipped, Dispatcher(), repository)
|
|
1126
|
+
|
|
1127
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1128
|
+
|
|
1129
|
+
assert review["body"] == original_body
|
|
1130
|
+
assert not repository.writes
|
|
1131
|
+
assert "RuntimeError: KV unavailable" in capsys.readouterr().out
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def test_uncertain_fallback_write_uses_a_concurrent_metadata_winner(shipped, monkeypatch):
|
|
1135
|
+
repository = _Repository()
|
|
1136
|
+
marker = f"<!-- openhands-review-run: {CONV_A} -->"
|
|
1137
|
+
review = repository.publish(
|
|
1138
|
+
f"{marker}\n\nAssessment.\n\n✅ APPROVED\n\n"
|
|
1139
|
+
"LLM profile: `invented` · Model: `invented`"
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
class Dispatcher:
|
|
1143
|
+
available = False
|
|
1144
|
+
|
|
1145
|
+
def subject_conversation(self, _subject):
|
|
1146
|
+
return CONV_A
|
|
1147
|
+
|
|
1148
|
+
def llm_provenance(self, _conversation_id):
|
|
1149
|
+
return ("actual-profile", "actual-model") if self.available else None
|
|
1150
|
+
|
|
1151
|
+
dispatcher = Dispatcher()
|
|
1152
|
+
run = _pr_reviewer(shipped, dispatcher, repository)
|
|
1153
|
+
client = sys.modules["agent_conversation"]
|
|
1154
|
+
original = client._kv_request
|
|
1155
|
+
|
|
1156
|
+
def interleaved(key, method, value=None):
|
|
1157
|
+
if method == "PUT" and value["model"] == "unknown":
|
|
1158
|
+
dispatcher.available = True
|
|
1159
|
+
run._annotate_llm_provenance(repository.pr, repository.gh_pages("/pulls/2/reviews"))
|
|
1160
|
+
raise TimeoutError("fallback write receipt lost")
|
|
1161
|
+
return original(key, method, value)
|
|
1162
|
+
|
|
1163
|
+
monkeypatch.setattr(client, "_kv_request", interleaved)
|
|
1164
|
+
|
|
1165
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1166
|
+
|
|
1167
|
+
assert review["body"].endswith("LLM profile: `actual-profile` · Model: `actual-model`")
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def test_a_provenance_failure_never_blocks_the_verdict_or_handoff(
|
|
1171
|
+
shipped, monkeypatch, capsys
|
|
1172
|
+
):
|
|
1173
|
+
repository = _Repository()
|
|
1174
|
+
repository.publish(
|
|
1175
|
+
f"{DISCLOSURE}\n<!-- openhands-review-run: {CONV_A} -->\n\n"
|
|
1176
|
+
"Looks correct.\n\n✅ APPROVED"
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
class Unavailable:
|
|
1180
|
+
def subject_conversation(self, subject):
|
|
1181
|
+
return CONV_A
|
|
1182
|
+
|
|
1183
|
+
def llm_provenance(self, conversation_id):
|
|
1184
|
+
raise RuntimeError("agent server unavailable")
|
|
1185
|
+
|
|
1186
|
+
run = _pr_reviewer(shipped, Unavailable(), repository)
|
|
1187
|
+
run.config["maintainers"] = "alice,bob"
|
|
1188
|
+
handoffs = []
|
|
1189
|
+
monkeypatch.setattr(
|
|
1190
|
+
shipped.module,
|
|
1191
|
+
"request_maintainer_review",
|
|
1192
|
+
lambda _run, pr, maintainers: handoffs.append(maintainers) or "alice",
|
|
1193
|
+
)
|
|
1194
|
+
|
|
1195
|
+
assert run._finish_completed_review(repository.pr, TRIGGER) is True
|
|
1196
|
+
assert handoffs == [["alice", "bob"]]
|
|
1197
|
+
assert repository.writes == []
|
|
1198
|
+
logged = [
|
|
1199
|
+
json.loads(line)
|
|
1200
|
+
for line in capsys.readouterr().out.splitlines()
|
|
1201
|
+
if "llm_provenance_error" in line
|
|
1202
|
+
]
|
|
1203
|
+
assert logged == [
|
|
1204
|
+
{
|
|
1205
|
+
"repository": "owner/repo",
|
|
1206
|
+
"pr": 2,
|
|
1207
|
+
"llm_provenance_error": "RuntimeError: agent server unavailable",
|
|
1208
|
+
}
|
|
1209
|
+
]
|