@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.
Files changed (89) hide show
  1. package/.agents/skills/custom-codereview-guide.md +44 -1
  2. package/.claude-plugin/marketplace.json +7 -5
  3. package/.codex-plugin/marketplace.json +7 -5
  4. package/.github/workflows/build-vscode-app.yml +3 -3
  5. package/.github/workflows/check-extensions.yml +1 -1
  6. package/.github/workflows/tests.yml +1 -1
  7. package/.plugin/marketplace.json +7 -5
  8. package/.release-please-manifest.json +1 -1
  9. package/AGENTS.md +7 -2
  10. package/README.md +3 -3
  11. package/apps/vscode/README.md +8 -6
  12. package/apps/vscode/canvas-extension.json +11 -3
  13. package/apps/vscode/extension.js +60 -12
  14. package/apps/vscode/scripts/package.mjs +20 -74
  15. package/automations/bundle-index.js +9 -7
  16. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  17. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  18. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  19. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  20. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  21. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  22. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  23. package/integrations/catalog/atlassian-rovo.json +4 -3
  24. package/integrations/catalog/bitbucket.json +16 -1
  25. package/integrations/catalog/github.json +1 -0
  26. package/integrations/catalog/gitlab.json +1 -1
  27. package/marketplaces/openhands-extensions.json +7 -5
  28. package/package.json +1 -1
  29. package/plugins/openhands/skills/openhands-automation/references/custom-automation.md +76 -8
  30. package/plugins/pr-review/README.md +1 -0
  31. package/plugins/pr-review/scripts/prompt.py +44 -11
  32. package/plugins/runtime-api-configs/SKILL.md +1 -1
  33. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  34. package/pyproject.toml +1 -1
  35. package/python/openhands_extensions/_version.py +1 -1
  36. package/skills/canvas-extension-api/SKILL.md +4 -1
  37. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  38. package/skills/canvas-extension-api/references/native-design.md +250 -0
  39. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  40. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  41. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  42. package/skills/github/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  44. package/skills/github-issue-triage/README.md +73 -14
  45. package/skills/github-issue-triage/SKILL.md +26 -46
  46. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  47. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  48. package/skills/github-issue-triage/scripts/worker.py +153 -103
  49. package/skills/github-pr-reviewer/README.md +103 -1
  50. package/skills/github-pr-reviewer/SKILL.md +35 -8
  51. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  52. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  53. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  54. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  55. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  56. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  57. package/skills/gitlab-issue-to-mr/README.md +10 -3
  58. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  59. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  60. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  61. package/skills/index.js +10 -10
  62. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  63. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  64. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  65. package/skills/jira-issue-to-pr/README.md +18 -11
  66. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  67. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  68. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  69. package/skills/openhands-automation/references/custom-automation.md +76 -8
  70. package/skills/slack-channel-monitor/README.md +6 -1
  71. package/skills/slack-channel-monitor/SKILL.md +6 -3
  72. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  73. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  74. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  75. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  76. package/skills/ticket-to-code-change/SKILL.md +5 -3
  77. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  78. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  79. package/tests/test_agent_conversation_dispatch.py +173 -6
  80. package/tests/test_automation_llm_provenance.py +1209 -0
  81. package/tests/test_github_review_publication.py +422 -0
  82. package/tests/test_github_reviewer_delivery.py +174 -21
  83. package/tests/test_github_triage_delivery.py +411 -238
  84. package/tests/test_gitlab_issue_to_mr.py +284 -0
  85. package/tests/test_jira_issue_to_pr.py +200 -6
  86. package/tests/test_pr_review_prompt_injection.py +353 -0
  87. package/tests/test_slack_channel_monitor.py +32 -1
  88. package/tests/test_vscode_app.py +8 -2
  89. 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
+ ]