@openhands/extensions 0.29.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 (84) 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/README.md +3 -3
  10. package/apps/vscode/README.md +8 -6
  11. package/apps/vscode/canvas-extension.json +11 -3
  12. package/apps/vscode/extension.js +60 -12
  13. package/apps/vscode/scripts/package.mjs +20 -74
  14. package/automations/bundle-index.js +9 -7
  15. package/automations/catalog/github-issue-to-pr/manifest.json +1 -1
  16. package/automations/catalog/github-issue-triage/manifest.json +4 -3
  17. package/automations/catalog/github-pr-reviewer/manifest.json +5 -4
  18. package/automations/catalog/gitlab-issue-to-mr/manifest.json +3 -3
  19. package/automations/catalog/jira-issue-to-bitbucket-pr/manifest.json +4 -4
  20. package/automations/catalog/jira-issue-to-gitlab-mr/manifest.json +5 -5
  21. package/automations/catalog/jira-issue-to-pr/manifest.json +1 -1
  22. package/integrations/catalog/atlassian-rovo.json +4 -3
  23. package/integrations/catalog/bitbucket.json +16 -1
  24. package/integrations/catalog/github.json +1 -0
  25. package/integrations/catalog/gitlab.json +1 -1
  26. package/marketplaces/openhands-extensions.json +7 -5
  27. package/package.json +1 -1
  28. package/plugins/pr-review/README.md +1 -0
  29. package/plugins/pr-review/scripts/prompt.py +44 -11
  30. package/plugins/runtime-api-configs/SKILL.md +1 -1
  31. package/plugins/vulnerability-remediation/skills/github/scripts/agent_conversation.py +224 -8
  32. package/pyproject.toml +1 -1
  33. package/python/openhands_extensions/_version.py +1 -1
  34. package/skills/canvas-extension-api/references/backend-safety.md +3 -3
  35. package/skills/canvas-extension-api/references/v1-contract.md +7 -4
  36. package/skills/canvas-extension-api/scripts/validate-extension.mjs +17 -1
  37. package/skills/canvas-extension-api/scripts/validate-extension.test.mjs +49 -0
  38. package/skills/github/scripts/agent_conversation.py +224 -8
  39. package/skills/github-issue-to-pr/scripts/agent_conversation.py +224 -8
  40. package/skills/github-issue-triage/README.md +73 -14
  41. package/skills/github-issue-triage/SKILL.md +26 -46
  42. package/skills/github-issue-triage/scripts/agent_conversation.py +224 -8
  43. package/skills/github-issue-triage/scripts/triage_publication.py +293 -0
  44. package/skills/github-issue-triage/scripts/worker.py +153 -103
  45. package/skills/github-pr-reviewer/README.md +103 -1
  46. package/skills/github-pr-reviewer/SKILL.md +35 -8
  47. package/skills/github-pr-reviewer/references/state-schema.md +8 -0
  48. package/skills/github-pr-reviewer/scripts/agent_conversation.py +224 -8
  49. package/skills/github-pr-reviewer/scripts/main.py +163 -39
  50. package/skills/github-pr-reviewer/scripts/publication.py +193 -0
  51. package/skills/github-pr-reviewer/scripts/worker.py +297 -8
  52. package/skills/github-pr-reviewer/tests/test_main.py +73 -5
  53. package/skills/gitlab-issue-to-mr/README.md +10 -3
  54. package/skills/gitlab-issue-to-mr/SKILL.md +43 -9
  55. package/skills/gitlab-issue-to-mr/references/state-schema.md +4 -0
  56. package/skills/gitlab-issue-to-mr/scripts/main.py +359 -25
  57. package/skills/index.js +8 -8
  58. package/skills/jira-issue-to-pr/.claude-plugin/plugin.json +4 -1
  59. package/skills/jira-issue-to-pr/.codex-plugin/plugin.json +4 -1
  60. package/skills/jira-issue-to-pr/.plugin/plugin.json +4 -1
  61. package/skills/jira-issue-to-pr/README.md +18 -11
  62. package/skills/jira-issue-to-pr/SKILL.md +72 -23
  63. package/skills/jira-issue-to-pr/references/setup.md +43 -12
  64. package/skills/jira-issue-to-pr/scripts/main.py +136 -26
  65. package/skills/slack-channel-monitor/README.md +6 -1
  66. package/skills/slack-channel-monitor/SKILL.md +6 -3
  67. package/skills/slack-channel-monitor/scripts/main.py +36 -4
  68. package/skills/ticket-to-code-change/.claude-plugin/plugin.json +1 -2
  69. package/skills/ticket-to-code-change/.codex-plugin/plugin.json +1 -2
  70. package/skills/ticket-to-code-change/.plugin/plugin.json +1 -2
  71. package/skills/ticket-to-code-change/SKILL.md +5 -3
  72. package/skills/ticket-to-code-change/commands/ticket-to-code-change-setup.md +1 -1
  73. package/tests/fixtures/automations/github-pr-reviewer.json +9 -9
  74. package/tests/test_agent_conversation_dispatch.py +173 -6
  75. package/tests/test_automation_llm_provenance.py +1209 -0
  76. package/tests/test_github_review_publication.py +422 -0
  77. package/tests/test_github_reviewer_delivery.py +174 -21
  78. package/tests/test_github_triage_delivery.py +411 -238
  79. package/tests/test_gitlab_issue_to_mr.py +284 -0
  80. package/tests/test_jira_issue_to_pr.py +200 -6
  81. package/tests/test_pr_review_prompt_injection.py +353 -0
  82. package/tests/test_slack_channel_monitor.py +32 -1
  83. package/tests/test_vscode_app.py +8 -2
  84. package/tests/vscode-app.test.mjs +8 -2
@@ -33,6 +33,7 @@ Optional secret:
33
33
 
34
34
  import json
35
35
  import os
36
+ import re
36
37
  import sys
37
38
  import time
38
39
  import urllib.error
@@ -577,7 +578,12 @@ def _build_secrets_payload(agent_url: str, api_key: str) -> dict:
577
578
  return secrets
578
579
 
579
580
 
580
- def create_conversation(agent_url: str, api_key: str, initial_message: str) -> str:
581
+ def create_conversation(
582
+ agent_url: str,
583
+ api_key: str,
584
+ initial_message: str,
585
+ agent: dict | None = None,
586
+ ) -> str:
581
587
  """Create a conversation and return its ID.
582
588
 
583
589
  The server auto-starts the agent when initial_message is provided
@@ -597,10 +603,9 @@ def create_conversation(agent_url: str, api_key: str, initial_message: str) -> s
597
603
  workspace_dir = os.path.join(root, "slack-monitor-conversations")
598
604
  os.makedirs(workspace_dir, exist_ok=True)
599
605
 
600
- agent = _get_agent_dict(agent_url, api_key)
601
606
  payload: dict = {
602
607
  "workspace": {"working_dir": workspace_dir},
603
- "agent": agent,
608
+ "agent": agent or _get_agent_dict(agent_url, api_key),
604
609
  "initial_message": {"content": [{"text": initial_message}]},
605
610
  }
606
611
 
@@ -639,6 +644,20 @@ def conversation_final_response(agent_url: str, api_key: str, conv_id: str) -> s
639
644
  return result.get("response", "")
640
645
 
641
646
 
647
+ def _llm_provenance(profile: str, model: str) -> str:
648
+ return f"LLM profile: `{profile}` · Model: `{model}`"
649
+
650
+
651
+ def _with_llm_provenance(body: str, profile: str, model: str) -> str:
652
+ provenance = _llm_provenance(profile, model)
653
+ body = "\n".join(
654
+ line
655
+ for line in (body or "").splitlines()
656
+ if not re.fullmatch(r"LLM profile: .* · Model: .*", line.strip())
657
+ ).strip()
658
+ return f"{body}\n\n{provenance}" if body else provenance
659
+
660
+
642
661
  # ── Message filtering ──────────────────────────────────────────────────────────
643
662
 
644
663
  def _is_human_message(msg: dict, bot_user_id: str, bot_message_ts: list[str]) -> bool:
@@ -937,7 +956,10 @@ def _process_trigger_message(
937
956
  )
938
957
 
939
958
  try:
940
- conv_id = create_conversation(agent_url, api_key, initial_prompt)
959
+ agent = _get_agent_dict(agent_url, api_key)
960
+ conv_id = create_conversation(
961
+ agent_url, api_key, initial_prompt, agent=agent
962
+ )
941
963
  conv_url = f"{openhands_url}/conversations/{conv_id}"
942
964
 
943
965
  now = time.time()
@@ -946,6 +968,8 @@ def _process_trigger_message(
946
968
  "channel_id": channel_id,
947
969
  "thread_ts": thread_root,
948
970
  "status": "active",
971
+ "llm_profile": "default",
972
+ "llm_model": agent["llm"].get("model") or "unknown",
949
973
  "last_activity": now,
950
974
  "last_seen_reply_ts": msg_ts,
951
975
  "reply_poll_backoff_seconds": THREAD_REPLY_INITIAL_BACKOFF_SECONDS,
@@ -1003,6 +1027,14 @@ def _check_conversation_completion(
1003
1027
  )
1004
1028
  else:
1005
1029
  summary = final if final else "Success (no message available)."
1030
+ # The values recorded when this script started the conversation; a
1031
+ # thread follow-up reuses it, so they still describe its LLM. A record
1032
+ # written before provenance was tracked cannot name either value.
1033
+ summary = _with_llm_provenance(
1034
+ summary,
1035
+ rec.get("llm_profile") or "unknown",
1036
+ rec.get("llm_model") or "unknown",
1037
+ )
1006
1038
 
1007
1039
  ts_back = post_message(slack_token, channel_id, summary, thread_ts=thread_ts)
1008
1040
  if ts_back:
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ticket-to-code-change",
3
3
  "version": "1.0.0",
4
- "description": "Create pull or merge requests from Jira or Linear tickets across GitHub, GitLab, and Bitbucket.",
4
+ "description": "Create pull or merge requests from Linear tickets across GitHub, GitLab, and Bitbucket.",
5
5
  "author": {
6
6
  "name": "OpenHands",
7
7
  "email": "contact@all-hands.dev"
@@ -10,7 +10,6 @@
10
10
  "repository": "https://github.com/OpenHands/extensions",
11
11
  "license": "MIT",
12
12
  "keywords": [
13
- "jira",
14
13
  "linear",
15
14
  "github",
16
15
  "gitlab",
@@ -1,8 +1,9 @@
1
1
  ---
2
2
  name: ticket-to-code-change
3
3
  description: >
4
- Set up a ticket-to-code-change automation using Jira or Linear as the issue
5
- tracker and GitHub, GitLab, or Bitbucket as the source-control provider.
4
+ Set up a ticket-to-code-change automation using Linear as the issue tracker
5
+ and GitHub, GitLab, or Bitbucket as the source-control provider. The Jira
6
+ templates are served by the jira-issue-to-pr skill instead.
6
7
  Watches for implementation-ready tickets, starts an OpenHands conversation
7
8
  to implement and test the request, opens a pull or merge request, and links
8
9
  the result back to the ticket.
@@ -19,7 +20,8 @@ requests or merge requests.
19
20
 
20
21
  Ask the user for:
21
22
 
22
- 1. The issue tracker: Jira Cloud or Linear.
23
+ 1. The issue tracker: Linear. Jira automations are set up with the
24
+ `jira-issue-to-pr` skill instead, which ships a poller.
23
25
  2. The source-control provider: GitHub, GitLab, or Bitbucket Cloud.
24
26
  3. The project or team to watch and the label or workflow state that means
25
27
  "implementation ready".
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  # auto-generated by sync_extensions.py
3
- description: Set up a ticket-to-code-change automation using Jira or Linear as the issue tracker and GitHub, GitLab, or Bitbucket as the source-control provider. Watches for implementation-ready tickets, starts an OpenHands conversation to implement and test the request, opens a pull or merge request, and links the result back to the ticket.
3
+ description: Set up a ticket-to-code-change automation using Linear as the issue tracker and GitHub, GitLab, or Bitbucket as the source-control provider. The Jira templates are served by the jira-issue-to-pr skill instead. Watches for implementation-ready tickets, starts an OpenHands conversation to implement and test the request, opens a pull or merge request, and links the result back to the ticket.
4
4
  ---
5
5
 
6
6
  Read and follow the complete instructions in the SKILL.md file located in this skill's directory.
@@ -68,7 +68,7 @@
68
68
  "timeout": 300,
69
69
  "template": {
70
70
  "id": "github-pr-reviewer",
71
- "version": "1.9.2",
71
+ "version": "1.11.1",
72
72
  "config": {
73
73
  "repos": [
74
74
  "OpenHands/agent-server-gui"
@@ -108,7 +108,7 @@
108
108
  "timeout": 300,
109
109
  "template": {
110
110
  "id": "github-pr-reviewer",
111
- "version": "1.9.2",
111
+ "version": "1.11.1",
112
112
  "config": {
113
113
  "repos": [
114
114
  "OpenHands/agent-server-gui"
@@ -213,7 +213,7 @@
213
213
  "timeout": 300,
214
214
  "template": {
215
215
  "id": "github-pr-reviewer",
216
- "version": "1.9.2",
216
+ "version": "1.11.1",
217
217
  "config": {
218
218
  "repos": [
219
219
  "OpenHands/agent-server-gui",
@@ -254,7 +254,7 @@
254
254
  "timeout": 300,
255
255
  "template": {
256
256
  "id": "github-pr-reviewer",
257
- "version": "1.9.2",
257
+ "version": "1.11.1",
258
258
  "config": {
259
259
  "repos": [
260
260
  "OpenHands/agent-server-gui",
@@ -296,7 +296,7 @@
296
296
  "preset_metadata": {
297
297
  "template": {
298
298
  "id": "github-pr-reviewer",
299
- "version": "1.9.2",
299
+ "version": "1.11.1",
300
300
  "config": {
301
301
  "repos": [
302
302
  "OpenHands/agent-server-gui",
@@ -351,7 +351,7 @@
351
351
  "timeout": 300,
352
352
  "template": {
353
353
  "id": "github-pr-reviewer",
354
- "version": "1.9.2",
354
+ "version": "1.11.1",
355
355
  "config": {
356
356
  "repos": [
357
357
  "OpenHands/agent-server-gui"
@@ -444,7 +444,7 @@
444
444
  "timeout": 300,
445
445
  "template": {
446
446
  "id": "github-pr-reviewer",
447
- "version": "1.9.2",
447
+ "version": "1.11.1",
448
448
  "config": {
449
449
  "repos": [
450
450
  "OpenHands/agent-server-gui"
@@ -540,7 +540,7 @@
540
540
  "timeout": 300,
541
541
  "template": {
542
542
  "id": "github-pr-reviewer",
543
- "version": "1.9.2",
543
+ "version": "1.11.1",
544
544
  "config": {
545
545
  "repos": [
546
546
  "OpenHands/agent-server-gui"
@@ -616,7 +616,7 @@
616
616
  "timeout": 300,
617
617
  "template": {
618
618
  "id": "github-pr-reviewer",
619
- "version": "1.9.2",
619
+ "version": "1.11.1",
620
620
  "config": {
621
621
  "repos": [
622
622
  "OpenHands/automation"
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  from unittest.mock import MagicMock
3
+ from urllib.error import HTTPError
3
4
  from uuid import NAMESPACE_URL, UUID, uuid5
4
5
 
5
6
  import agent_conversation
@@ -53,19 +54,20 @@ def test_github_secret_falls_back_to_agent_server(monkeypatch):
53
54
  workspace.reset_client.assert_called_once_with()
54
55
 
55
56
 
56
- def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch):
57
+ def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch, tmp_path):
57
58
  state = {}
58
59
  _fake_kv(monkeypatch, state)
59
60
  monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
61
+ monkeypatch.setenv("WORKSPACE_BASE", str(tmp_path))
60
62
  workspace = MagicMock()
61
63
  workspace.__enter__.return_value = workspace
64
+ workspace.working_dir = str(tmp_path)
62
65
  scoped_secrets = {
63
66
  "GITHUB_TOKEN": LookupSecret(url="/api/settings/secrets/GITHUB_TOKEN")
64
67
  }
65
68
  workspace.get_secrets.return_value = scoped_secrets
66
- monkeypatch.setattr(
67
- agent_conversation, "RemoteWorkspace", lambda **kwargs: workspace
68
- )
69
+ remote_workspace = MagicMock(return_value=workspace)
70
+ monkeypatch.setattr(agent_conversation, "RemoteWorkspace", remote_workspace)
69
71
  missing = agent_conversation.httpx.HTTPStatusError(
70
72
  "missing",
71
73
  request=MagicMock(),
@@ -82,6 +84,10 @@ def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch):
82
84
  result = dispatcher.deliver("repo:issue:7", "revision-1", "work")
83
85
 
84
86
  request = create.call_args.args[1]
87
+ remote_workspace.assert_called_once_with(
88
+ host="http://agent", api_key="session", working_dir=str(tmp_path)
89
+ )
90
+ assert request.workspace.working_dir == str(tmp_path)
85
91
  assert request.agent_profile_id == UUID("11111111-1111-4111-8111-111111111111")
86
92
  assert request.secrets == scoped_secrets
87
93
  workspace.get_secrets.assert_called_once_with(
@@ -100,6 +106,32 @@ def test_new_subject_uses_selected_profile_and_persists_mapping(monkeypatch):
100
106
  }
101
107
 
102
108
 
109
+ def test_review_work_uses_configured_local_workspace(monkeypatch, tmp_path):
110
+ monkeypatch.setattr(agent_conversation, "_register_tools", lambda: None)
111
+ monkeypatch.setenv("WORKSPACE_BASE", str(tmp_path))
112
+ workspace = MagicMock()
113
+ workspace.__enter__.return_value = workspace
114
+ workspace.working_dir = str(tmp_path)
115
+ workspace.get_secrets.return_value = {}
116
+ remote_workspace = MagicMock(return_value=workspace)
117
+ monkeypatch.setattr(agent_conversation, "RemoteWorkspace", remote_workspace)
118
+ conversation = MagicMock()
119
+ create = MagicMock(return_value=conversation)
120
+ monkeypatch.setattr(agent_conversation.RemoteConversation, "create", create)
121
+
122
+ with _dispatcher(monkeypatch) as dispatcher:
123
+ dispatcher.start_review_work(
124
+ "22222222-2222-4222-8222-222222222222", "repo:pr:9", "review"
125
+ )
126
+
127
+ request = create.call_args.args[1]
128
+ remote_workspace.assert_called_once_with(
129
+ host="http://agent", api_key="session", working_dir=str(tmp_path)
130
+ )
131
+ assert request.workspace.working_dir == str(tmp_path)
132
+ conversation.close.assert_called_once_with()
133
+
134
+
103
135
  def test_known_subject_resumes_once_per_delivery(monkeypatch):
104
136
  state = {
105
137
  _state_key("repo:pr:9"): {
@@ -388,8 +420,9 @@ def test_subjects_use_independent_kv_records(monkeypatch):
388
420
  class _FakeCloud:
389
421
  """The OpenHands API's conversations, as the dispatcher uses them."""
390
422
 
391
- def __init__(self, conversation=None):
423
+ def __init__(self, conversation=None, agent_profiles=None):
392
424
  self.conversation = conversation
425
+ self.profiles = agent_profiles
393
426
  self.started = []
394
427
  self.sent = []
395
428
 
@@ -397,7 +430,14 @@ class _FakeCloud:
397
430
  return self.conversation
398
431
 
399
432
  def start(self, conversation_id, profile_id, title, prompt):
400
- self.started.append({"id": str(conversation_id), "profile": str(profile_id)})
433
+ self.started.append(
434
+ {"id": str(conversation_id), "profile": str(profile_id), "prompt": prompt}
435
+ )
436
+
437
+ def agent_profiles(self):
438
+ if self.profiles is None:
439
+ raise HTTPError("/api/agent-profiles", 403, "forbidden", {}, None)
440
+ return self.profiles
401
441
 
402
442
  def send(self, conversation, prompt):
403
443
  self.sent.append(prompt)
@@ -551,3 +591,130 @@ def test_cloud_run_stops_retrying_an_errored_delivery_after_the_bound(monkeypatc
551
591
  # Assert
552
592
  assert result["disposition"] == "deduplicated"
553
593
  assert cloud.sent == []
594
+
595
+
596
+ def test_cloud_prompt_names_the_replacement_conversation(monkeypatch):
597
+ """A prompt that names its conversation names the one actually started."""
598
+ gone = "22222222-2222-4222-8222-222222222222"
599
+ state = {
600
+ _state_key("repo:pr:7"): {
601
+ "conversation_id": gone,
602
+ "delivery": "revision-1",
603
+ "head": "sha-1",
604
+ }
605
+ }
606
+ _fake_kv(monkeypatch, state)
607
+ cloud = _FakeCloud({"sandbox_status": "MISSING", "execution_status": None})
608
+
609
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
610
+ result = dispatcher.deliver(
611
+ "repo:pr:7",
612
+ "revision-2",
613
+ lambda conversation_id: f"review as {conversation_id}",
614
+ head="sha-2",
615
+ )
616
+
617
+ [started] = cloud.started
618
+ assert started["prompt"] == f"review as {result['conversation_id']}"
619
+ assert result["conversation_id"] != gone
620
+
621
+
622
+ def test_cloud_llm_provenance_reads_the_conversation_and_its_launch_profile(
623
+ monkeypatch,
624
+ ):
625
+ launched = {
626
+ "agent_profile_id": "11111111-1111-4111-8111-111111111111",
627
+ "revision": 2,
628
+ }
629
+ profile = {
630
+ "id": launched["agent_profile_id"],
631
+ "name": "Code reviewer",
632
+ "revision": 2,
633
+ "llm_profile_ref": "review-sonnet",
634
+ }
635
+ conversation = {
636
+ "llm_model": "anthropic/claude-sonnet-4-5",
637
+ "launched_agent_profile": launched,
638
+ }
639
+
640
+ current = _cloud_dispatcher(monkeypatch, _FakeCloud(conversation, [profile]))
641
+ edited = _cloud_dispatcher(
642
+ monkeypatch, _FakeCloud(conversation, [{**profile, "revision": 3}])
643
+ )
644
+ unreadable = _cloud_dispatcher(monkeypatch, _FakeCloud(conversation, None))
645
+ gone = _cloud_dispatcher(monkeypatch, _FakeCloud(None, [profile]))
646
+
647
+ assert current.llm_provenance("conversation") == (
648
+ "review-sonnet",
649
+ "anthropic/claude-sonnet-4-5",
650
+ )
651
+ # An agent profile edited since launch, or one that cannot be read, cannot
652
+ # name the LLM profile the conversation started with; its model is known.
653
+ assert edited.llm_provenance("conversation") == (
654
+ "unknown",
655
+ "anthropic/claude-sonnet-4-5",
656
+ )
657
+ assert unreadable.llm_provenance("conversation") == (
658
+ "unknown",
659
+ "anthropic/claude-sonnet-4-5",
660
+ )
661
+ assert gone.llm_provenance("conversation") is None
662
+
663
+
664
+ def test_subject_conversation_is_this_automations_recorded_conversation(monkeypatch):
665
+ state = {
666
+ _state_key("repo:pr:7"): {
667
+ "conversation_id": "22222222-2222-4222-8222-222222222222",
668
+ }
669
+ }
670
+ _fake_kv(monkeypatch, state)
671
+ dispatcher = _dispatcher(monkeypatch)
672
+
673
+ assert (
674
+ dispatcher.subject_conversation("repo:pr:7")
675
+ == "22222222-2222-4222-8222-222222222222"
676
+ )
677
+ assert dispatcher.subject_conversation("repo:pr:8") is None
678
+
679
+
680
+ @pytest.mark.parametrize("status", [404, 500])
681
+ def test_local_llm_provenance_of_a_missing_conversation(monkeypatch, status):
682
+ def urlopen(request, timeout):
683
+ assert request.full_url.endswith("/api/conversations/conversation")
684
+ assert request.get_header("X-session-api-key") == "session"
685
+ raise HTTPError(request.full_url, status, "error", {}, None)
686
+
687
+ monkeypatch.setattr(agent_conversation, "urlopen", urlopen)
688
+ dispatcher = _dispatcher(monkeypatch)
689
+
690
+ if status == 404:
691
+ # A deleted conversation names no LLM, so nothing is attributed to it.
692
+ assert dispatcher.llm_provenance("conversation") is None
693
+ else:
694
+ with pytest.raises(HTTPError):
695
+ dispatcher.llm_provenance("conversation")
696
+
697
+
698
+ def test_cloud_error_retry_renders_the_prompt_for_its_conversation(monkeypatch):
699
+ """A retried delivery still names the conversation it is sent to."""
700
+ current = "22222222-2222-4222-8222-222222222222"
701
+ state = {
702
+ _state_key("repo:pr:7"): {
703
+ "conversation_id": current,
704
+ "delivery": "revision-1",
705
+ "head": "sha-1",
706
+ }
707
+ }
708
+ _fake_kv(monkeypatch, state)
709
+ cloud = _FakeCloud({"sandbox_status": "RUNNING", "execution_status": "error"})
710
+
711
+ with _cloud_dispatcher(monkeypatch, cloud) as dispatcher:
712
+ result = dispatcher.deliver(
713
+ "repo:pr:7",
714
+ "revision-1",
715
+ lambda conversation_id: f"review as {conversation_id}",
716
+ head="sha-1",
717
+ )
718
+
719
+ assert result["disposition"] == "retried"
720
+ assert cloud.sent == [f"review as {current}"]