codee-agent 0.6.9__tar.gz → 0.7.0__tar.gz

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 (95) hide show
  1. {codee_agent-0.6.9 → codee_agent-0.7.0}/PKG-INFO +3 -3
  2. {codee_agent-0.6.9 → codee_agent-0.7.0}/README.md +1 -1
  3. {codee_agent-0.6.9 → codee_agent-0.7.0}/pyproject.toml +3 -2
  4. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/admin.py +49 -9
  5. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/admin_service.py +12 -2
  6. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/coding_agents.py +2 -0
  7. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/executor.py +2 -0
  8. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/runs_db.py +31 -10
  9. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/test_runs_db.py +29 -7
  10. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_admin_service.py +19 -0
  11. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_coding_agents.py +10 -0
  12. codee_agent-0.7.0/src/codee_agent_opencode/__init__.py +1 -0
  13. codee_agent-0.7.0/src/codee_agent_opencode/provider.py +225 -0
  14. codee_agent-0.7.0/src/codee_agent_opencode/test.py +153 -0
  15. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_main_context/context.py +1 -0
  16. {codee_agent-0.6.9 → codee_agent-0.7.0}/LICENSE +0 -0
  17. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/.gitignore +0 -0
  18. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/__init__.py +0 -0
  19. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/admin_api.py +0 -0
  20. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/admin_cli.py +0 -0
  21. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/agent_cli.py +0 -0
  22. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/init_cli.py +0 -0
  23. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/__init__.py +0 -0
  24. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/claude_key_rotation.py +0 -0
  25. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/cron_describe.py +0 -0
  26. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/mcp_config.py +0 -0
  27. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/test_claude_key_rotation.py +0 -0
  28. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/test_mcp_config.py +0 -0
  29. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
  30. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
  31. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
  32. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/trigger_cron_skills.py +0 -0
  33. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/trigger_email_skills.py +0 -0
  34. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/lib/trigger_issue_skills.py +0 -0
  35. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/mail_server.py +0 -0
  36. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/setup_wizard.py +0 -0
  37. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/start_cli.py +0 -0
  38. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/tasks_providers.py +0 -0
  39. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/AGENTS.md +0 -0
  40. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/CLAUDE.md +0 -0
  41. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
  42. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
  43. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
  44. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
  45. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
  46. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
  47. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
  48. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
  49. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
  50. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
  51. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
  52. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
  53. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_admin_api.py +0 -0
  54. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_admin_cli.py +0 -0
  55. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_agent_cli.py +0 -0
  56. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_executor.py +0 -0
  57. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_init_cli.py +0 -0
  58. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_memory_index.py +0 -0
  59. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_setup_wizard.py +0 -0
  60. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/test_start_cli.py +0 -0
  61. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee/workflow_graph.py +0 -0
  62. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_admin/__init__.py +0 -0
  63. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_admin/codee_admin.py +0 -0
  64. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_abstract/__init__.py +0 -0
  65. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_abstract/provider.py +0 -0
  66. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/__init__.py +0 -0
  67. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/account.py +0 -0
  68. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/credentials.py +0 -0
  69. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/oauth.py +0 -0
  70. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/provider.py +0 -0
  71. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/test.py +0 -0
  72. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_claude_code/usage.py +0 -0
  73. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_codex/__init__.py +0 -0
  74. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_codex/provider.py +0 -0
  75. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_codex/test.py +0 -0
  76. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_github_copilot/__init__.py +0 -0
  77. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_github_copilot/provider.py +0 -0
  78. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_agent_github_copilot/test.py +0 -0
  79. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_database/__init__.py +0 -0
  80. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_database/claude_code_accounts.py +0 -0
  81. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_database/database.py +0 -0
  82. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_database/oauth_tokens.py +0 -0
  83. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_main_context/__init__.py +0 -0
  84. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_main_context/logging.py +0 -0
  85. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_main_context/test_context.py +0 -0
  86. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_main_context/test_logging.py +0 -0
  87. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_abstract/__init__.py +0 -0
  88. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_abstract/provider.py +0 -0
  89. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
  90. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
  91. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/provider.py +0 -0
  92. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/test.py +0 -0
  93. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_jira/__init__.py +0 -0
  94. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_jira/provider.py +0 -0
  95. {codee_agent-0.6.9 → codee_agent-0.7.0}/src/codee_tasks_jira/test.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codee-agent
3
- Version: 0.6.9
3
+ Version: 0.7.0
4
4
  Summary: A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents.
5
- Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,automation
5
+ Keywords: ai,agent,jira,azure-devops,claude-code,github-copilot,codex,opencode,automation
6
6
  Author: Denis Kibalko
7
7
  Author-email: Denis Kibalko <denis.kibalko@nimbusweb.me>
8
8
  License-Expression: MIT
@@ -39,7 +39,7 @@ The goal of this project is to provide an assistant that will integrate into you
39
39
 
40
40
  Codee works with Jira and Azure DevOps as tasks providers, new providers are quite easy to create, PRs are welcome.
41
41
 
42
- As agents it currently works with Claude Code, Github Copilot and Codex.
42
+ As agents it currently works with Claude Code, GitHub Copilot, Codex and OpenCode.
43
43
 
44
44
  ## Documentation
45
45
 
@@ -4,7 +4,7 @@ The goal of this project is to provide an assistant that will integrate into you
4
4
 
5
5
  Codee works with Jira and Azure DevOps as tasks providers, new providers are quite easy to create, PRs are welcome.
6
6
 
7
- As agents it currently works with Claude Code, Github Copilot and Codex.
7
+ As agents it currently works with Claude Code, GitHub Copilot, Codex and OpenCode.
8
8
 
9
9
  ## Documentation
10
10
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "codee-agent"
3
- version = "0.6.9"
3
+ version = "0.7.0"
4
4
  description = "A virtual co-worker that picks up Jira and Azure DevOps tasks and solves them with coding agents."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -9,7 +9,7 @@ requires-python = ">=3.12"
9
9
  authors = [
10
10
  { name = "Denis Kibalko", email = "denis.kibalko@nimbusweb.me" },
11
11
  ]
12
- keywords = ["ai", "agent", "jira", "azure-devops", "claude-code", "github-copilot", "codex", "automation"]
12
+ keywords = ["ai", "agent", "jira", "azure-devops", "claude-code", "github-copilot", "codex", "opencode", "automation"]
13
13
  classifiers = [
14
14
  "Development Status :: 3 - Alpha",
15
15
  "Environment :: Console",
@@ -77,6 +77,7 @@ module-name = [
77
77
  "codee_agent_claude_code",
78
78
  "codee_agent_github_copilot",
79
79
  "codee_agent_codex",
80
+ "codee_agent_opencode",
80
81
  "codee_tasks_abstract",
81
82
  "codee_tasks_jira",
82
83
  "codee_tasks_azure_devops",
@@ -270,6 +270,8 @@ class RunRecord(BaseModel):
270
270
  status: str
271
271
  error: str
272
272
  started_at: str
273
+ ended_at: str
274
+ duration_label: str
273
275
  session_id: str
274
276
  message: str
275
277
  user_message: str
@@ -334,6 +336,8 @@ class AdminState(rx.State):
334
336
  runs: list[RunRecord] = []
335
337
  runs_has_more: bool = False
336
338
  runs_loading: bool = False
339
+ runs_query: str = ""
340
+ runs_watch: int = 0
337
341
  session_viewer: str = SERVICE.session_viewer
338
342
 
339
343
  workflow_sections: list[WorkflowSection] = []
@@ -841,12 +845,14 @@ class AdminState(rx.State):
841
845
  for account in measured]
842
846
  await asyncio.sleep(USAGE_POLL_INTERVAL)
843
847
 
844
- def _fetch_runs_page(self, offset: int) -> list[RunRecord]:
848
+ def _fetch_runs_page(self, offset: int,
849
+ limit: int = RUNS_PAGE_SIZE) -> list[RunRecord]:
845
850
  """One page of runs. Reads one row past the page to learn whether more exist."""
846
- rows = SERVICE.recent_runs(RUNS_PAGE_SIZE + 1, offset)
847
- self.runs_has_more = len(rows) > RUNS_PAGE_SIZE
851
+ rows = SERVICE.recent_runs(
852
+ limit + 1, offset, self.runs_query)
853
+ self.runs_has_more = len(rows) > limit
848
854
  records = []
849
- for run in rows[:RUNS_PAGE_SIZE]:
855
+ for run in rows[:limit]:
850
856
  message = (run.get("message") or "").strip()
851
857
  preview = message.splitlines()[0] if message else "No message"
852
858
  records.append(RunRecord(
@@ -855,6 +861,8 @@ class AdminState(rx.State):
855
861
  status=run["status"],
856
862
  error=run.get("error") or "",
857
863
  started_at=run["started_at"],
864
+ ended_at=run["ended_at"],
865
+ duration_label=run["duration_label"],
858
866
  session_id=run.get("session_id") or "",
859
867
  message=message,
860
868
  user_message=(run.get("user_message") or message).strip(),
@@ -871,6 +879,11 @@ class AdminState(rx.State):
871
879
  self.runs_loading = False
872
880
  self.runs = self._fetch_runs_page(0)
873
881
 
882
+ def search_runs(self, value: str) -> None:
883
+ self.runs_query = value
884
+ self.runs_loading = False
885
+ self.runs = self._fetch_runs_page(0)
886
+
874
887
  def load_more_runs(self) -> None:
875
888
  if self.runs_loading or not self.runs_has_more:
876
889
  return
@@ -880,6 +893,23 @@ class AdminState(rx.State):
880
893
  finally:
881
894
  self.runs_loading = False
882
895
 
896
+ @rx.event(background=True)
897
+ async def poll_runs(self) -> None:
898
+ """Keep recent runs current while the Runs page is on screen."""
899
+ async with self:
900
+ self.runs_watch += 1
901
+ watch = self.runs_watch
902
+ while True:
903
+ async with self:
904
+ if self.runs_watch != watch or self.active_route != "/runs":
905
+ return
906
+ try:
907
+ visible_count = max(RUNS_PAGE_SIZE, len(self.runs))
908
+ self.runs = self._fetch_runs_page(0, visible_count)
909
+ except Exception as error:
910
+ print(f"[admin] Failed to refresh runs: {error}")
911
+ await asyncio.sleep(1)
912
+
883
913
  @rx.event(background=True)
884
914
  async def load_workflow(self, force: bool = False) -> None:
885
915
  """Show the workflow, attaching to a generation already under way.
@@ -2542,7 +2572,10 @@ def run_row(run: RunRecord) -> rx.Component:
2542
2572
  rx.flex(
2543
2573
  rx.vstack(rx.hstack(rx.text(run.skill_name, font_weight="600"),
2544
2574
  rx.badge(run.status, color_scheme=rx.cond(run.status == "succeeded", "green", "red"))),
2545
- rx.text(local_datetime(run.started_at), color=MUTED,
2575
+ rx.text(local_datetime(run.ended_at), color=MUTED,
2576
+ font_size="0.8rem",
2577
+ font_family="IBM Plex Mono, monospace"),
2578
+ rx.text("Duration: ", run.duration_label, color=MUTED,
2546
2579
  font_size="0.8rem",
2547
2580
  font_family="IBM Plex Mono, monospace"),
2548
2581
  rx.text("Thread ID: ", run.session_id, color=MUTED,
@@ -2570,7 +2603,7 @@ def run_row(run: RunRecord) -> rx.Component:
2570
2603
  rx.text(run.debug_logs, white_space="pre-wrap",
2571
2604
  font_family="IBM Plex Mono, monospace",
2572
2605
  font_size="0.8rem"))),
2573
- spacing="2", align="start", width="100%"), value=run.started_at),
2606
+ spacing="2", align="start", width="100%"), value=run.ended_at),
2574
2607
  collapsible=True, width="100%")),
2575
2608
  padding="1rem", background=SURFACE, border=BORDER, width="100%")
2576
2609
 
@@ -2586,8 +2619,15 @@ def runs_page() -> rx.Component:
2586
2619
  on_click=AdminState.load_more_runs)),
2587
2620
  spacing="3", width="100%")
2588
2621
  return shell(rx.vstack(page_header("Runs", "Recent trigger executions and outcomes."),
2622
+ rx.input(placeholder="Search prompts and LLM responses",
2623
+ type="search", value=AdminState.runs_query,
2624
+ on_change=AdminState.search_runs,
2625
+ debounce_timeout=300, width="100%"),
2589
2626
  rx.cond(AdminState.runs.length() > 0, listing,
2590
- empty_state("history", "No runs recorded yet.")),
2627
+ rx.cond(AdminState.runs_query != "",
2628
+ empty_state(
2629
+ "search-x", "No runs match your search."),
2630
+ empty_state("history", "No runs recorded yet."))),
2591
2631
  align="start", width="100%"))
2592
2632
 
2593
2633
 
@@ -3545,7 +3585,7 @@ def settings_page() -> rx.Component:
3545
3585
  field("Default agent",
3546
3586
  rx.grid(
3547
3587
  rx.select(
3548
- ["claude_code", "github_copilot", "codex"],
3588
+ list(AGENT_NAMES),
3549
3589
  value=AdminState.coding_agent,
3550
3590
  on_change=AdminState.set_coding_agent,
3551
3591
  width="100%"),
@@ -3800,7 +3840,7 @@ app.add_page(repositories_page, route="/repositories",
3800
3840
  title="Repositories | Codee",
3801
3841
  on_load=AdminState.load_repositories)
3802
3842
  app.add_page(runs_page, route="/runs", title="Runs | Codee",
3803
- on_load=AdminState.load_runs)
3843
+ on_load=AdminState.poll_runs)
3804
3844
  app.add_page(sessions_page, route="/sessions",
3805
3845
  title="Sessions | Codee", on_load=AdminState.load_settings)
3806
3846
  app.add_page(settings_page, route="/settings",
@@ -2046,8 +2046,18 @@ class AdminService:
2046
2046
 
2047
2047
  return url
2048
2048
 
2049
- def recent_runs(self, limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
2050
- return runs_db.recent_runs(limit, offset, main_context=self.context)
2049
+ def recent_runs(self, limit: int = 100, offset: int = 0,
2050
+ search: str = "") -> list[dict[str, Any]]:
2051
+ rows = runs_db.recent_runs(
2052
+ limit, offset, search=search, main_context=self.context)
2053
+ for row in rows:
2054
+ try:
2055
+ elapsed = (datetime.fromisoformat(row["ended_at"])
2056
+ - datetime.fromisoformat(row["started_at"])).total_seconds()
2057
+ except (TypeError, ValueError):
2058
+ elapsed = 0
2059
+ row["duration_label"] = runs_db.fmt_elapsed(max(int(elapsed), 0))
2060
+ return rows
2051
2061
 
2052
2062
  def load_settings(self) -> Settings:
2053
2063
  self.context.settings = load_settings(self.data_dir)
@@ -11,6 +11,7 @@ from codee_agent_abstract.provider import AbstractCodingAgent
11
11
  from codee_agent_claude_code.provider import ClaudeCodeAgent
12
12
  from codee_agent_codex.provider import CodexAgent
13
13
  from codee_agent_github_copilot.provider import GitHubCopilotAgent
14
+ from codee_agent_opencode.provider import OpenCodeAgent
14
15
  from codee_main_context.context import CodingAgent, Settings
15
16
 
16
17
  # Concrete coding agents, keyed by the agent selected in settings.
@@ -18,6 +19,7 @@ CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
18
19
  CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
19
20
  CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
20
21
  CodingAgent.CODEX: CodexAgent,
22
+ CodingAgent.OPENCODE: OpenCodeAgent,
21
23
  }
22
24
 
23
25
 
@@ -12,6 +12,7 @@ from codee_agent_claude_code.provider import ClaudeCodeAgent
12
12
  from codee_agent_codex.provider import CodexAgent
13
13
  from codee_agent_github_copilot.provider import (
14
14
  COPILOT_DEBUG_ENV_VAR, GitHubCopilotAgent)
15
+ from codee_agent_opencode.provider import OpenCodeAgent
15
16
  from codee_main_context.context import (
16
17
  CodeeMainContext, CodingAgent, Settings, codee_issue_types, data_dir,
17
18
  load_settings, project_root)
@@ -39,6 +40,7 @@ _CODING_AGENTS: dict[CodingAgent, type[AbstractCodingAgent]] = {
39
40
  CodingAgent.CLAUDE_CODE: ClaudeCodeAgent,
40
41
  CodingAgent.GITHUB_COPILOT: GitHubCopilotAgent,
41
42
  CodingAgent.CODEX: CodexAgent,
43
+ CodingAgent.OPENCODE: OpenCodeAgent,
42
44
  }
43
45
 
44
46
  POLL_INTERVAL = 60 # 1 minute
@@ -11,7 +11,8 @@ from codee_main_context.context import CodeeMainContext
11
11
  from codee_database.database import get_db_connection
12
12
 
13
13
  _COLUMNS = ("id", "skill_name", "trigger_type", "session_id", "status", "error",
14
- "started_at", "message", "user_message", "response", "debug_logs")
14
+ "started_at", "ended_at", "message", "user_message", "response",
15
+ "debug_logs")
15
16
 
16
17
  # Codee names a session before the agent runs, but not every agent runs under
17
18
  # the name it was given: Codex mints its own thread id and reports it back mid
@@ -50,6 +51,7 @@ def init(main_context: CodeeMainContext) -> None:
50
51
  status TEXT NOT NULL,
51
52
  error TEXT,
52
53
  started_at TEXT NOT NULL,
54
+ ended_at TEXT NOT NULL,
53
55
  message TEXT,
54
56
  user_message TEXT,
55
57
  response TEXT,
@@ -68,6 +70,12 @@ def init(main_context: CodeeMainContext) -> None:
68
70
  conn.execute("ALTER TABLE runs ADD COLUMN user_message TEXT")
69
71
  if "debug_logs" not in cols:
70
72
  conn.execute("ALTER TABLE runs ADD COLUMN debug_logs TEXT")
73
+ if "ended_at" not in cols:
74
+ conn.execute("ALTER TABLE runs ADD COLUMN ended_at TEXT")
75
+ conn.execute(
76
+ "UPDATE runs SET ended_at = started_at WHERE ended_at IS NULL")
77
+ conn.execute(
78
+ "CREATE INDEX IF NOT EXISTS idx_runs_ended_at ON runs(ended_at DESC)")
71
79
  # In-flight claude runs; a row lives only while its subprocess is running.
72
80
  conn.execute(
73
81
  """CREATE TABLE IF NOT EXISTS active_jobs (
@@ -90,7 +98,7 @@ def init(main_context: CodeeMainContext) -> None:
90
98
 
91
99
  def record_run(skill_name, trigger_type, session_id, status, error=None, started_at=None,
92
100
  message=None, user_message=None, response=None, debug_logs=None,
93
- *, main_context: CodeeMainContext) -> None:
101
+ ended_at=None, *, main_context: CodeeMainContext) -> None:
94
102
  """Insert one run row. Never raises to the caller (FR-009)."""
95
103
  try:
96
104
  init(main_context)
@@ -99,30 +107,43 @@ def record_run(skill_name, trigger_type, session_id, status, error=None, started
99
107
  debug_logs = getattr(response, "debug_logs", None)
100
108
  if started_at is None:
101
109
  started_at = datetime.now(timezone.utc).isoformat()
110
+ if ended_at is None:
111
+ ended_at = datetime.now(timezone.utc).isoformat()
102
112
  with get_db_connection(main_context) as conn:
103
113
  conn.execute(
104
114
  "INSERT INTO runs (skill_name, trigger_type, session_id, status, error,"
105
- " started_at, message, user_message, response, debug_logs)"
106
- " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
115
+ " started_at, ended_at, message, user_message, response, debug_logs)"
116
+ " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
107
117
  (skill_name, trigger_type, session_id,
108
- status, error, started_at, message, user_message, response,
118
+ status, error, started_at, ended_at, message, user_message, response,
109
119
  debug_logs),
110
120
  )
111
121
  except Exception as exc: # ponytail: a logging miss must never abort the skill run
112
122
  print(f"[runs_db] Failed to record run for {skill_name}: {exc}")
113
123
 
114
124
 
115
- def recent_runs(limit: int = 100, offset: int = 0, *,
125
+ def recent_runs(limit: int = 100, offset: int = 0, *, search: str = "",
116
126
  main_context: CodeeMainContext) -> list[dict]:
117
- """Most recent runs newest-first as dicts, skipping `offset` rows; [] on empty/missing DB (FR-006)."""
127
+ """Return matching runs newest-first; [] on an empty or missing DB."""
118
128
  try:
119
129
  init(main_context)
130
+ query = search.strip()
131
+ where = ""
132
+ params: list = []
133
+ if query:
134
+ where = (
135
+ " WHERE COALESCE(message, '') LIKE ?"
136
+ " OR COALESCE(user_message, '') LIKE ?"
137
+ " OR COALESCE(response, '') LIKE ?"
138
+ )
139
+ params.extend([f"%{query}%"] * 3)
140
+ params.extend((limit, max(offset, 0)))
120
141
  with get_db_connection(main_context) as conn:
121
142
  rows = conn.execute(
122
143
  "SELECT id, skill_name, trigger_type, session_id, status, error,"
123
- " started_at, message, user_message, response, debug_logs"
124
- " FROM runs ORDER BY started_at DESC, id DESC LIMIT ? OFFSET ?",
125
- (limit, max(offset, 0)),
144
+ " started_at, ended_at, message, user_message, response, debug_logs"
145
+ f" FROM runs{where} ORDER BY ended_at DESC, id DESC LIMIT ? OFFSET ?",
146
+ params,
126
147
  ).fetchall()
127
148
  return [dict(zip(_COLUMNS, row)) for row in rows]
128
149
  except Exception as exc:
@@ -25,17 +25,20 @@ def _db_file(data_dir: Path) -> Path:
25
25
  def test_round_trip_newest_first(tmp_path):
26
26
  ctx = _ctx(tmp_path)
27
27
  runs_db.record_run("skill-a", "cron", "sid-1", "succeeded",
28
- started_at="2026-06-27T10:00:00+00:00", main_context=ctx)
28
+ started_at="2026-06-27T10:00:00+00:00",
29
+ ended_at="2026-06-27T12:00:00+00:00", main_context=ctx)
29
30
  runs_db.record_run("skill-b", "email", "sid-2", "failed", error="boom",
30
- started_at="2026-06-27T11:00:00+00:00", main_context=ctx)
31
+ started_at="2026-06-27T11:00:00+00:00",
32
+ ended_at="2026-06-27T11:30:00+00:00", main_context=ctx)
31
33
 
32
34
  rows = runs_db.recent_runs(main_context=ctx)
33
35
  assert [r["skill_name"] for r in rows] == [
34
- "skill-b", "skill-a"] # newest first
35
- assert rows[0]["trigger_type"] == "email"
36
- assert rows[0]["status"] == "failed"
37
- assert rows[0]["error"] == "boom"
38
- assert rows[0]["session_id"] == "sid-2"
36
+ "skill-a", "skill-b"] # latest completion first
37
+ assert rows[0]["ended_at"] == "2026-06-27T12:00:00+00:00"
38
+ assert rows[1]["trigger_type"] == "email"
39
+ assert rows[1]["status"] == "failed"
40
+ assert rows[1]["error"] == "boom"
41
+ assert rows[1]["session_id"] == "sid-2"
39
42
 
40
43
 
41
44
  def test_empty_db_returns_empty_list(tmp_path):
@@ -58,6 +61,24 @@ def test_limit_and_offset_page_through_runs(tmp_path):
58
61
  assert runs_db.recent_runs(2, 5, main_context=ctx) == []
59
62
 
60
63
 
64
+ def test_search_matches_prompt_and_response_before_pagination(tmp_path):
65
+ ctx = _ctx(tmp_path)
66
+ runs_db.record_run("a", "cron", "sid-a", "succeeded",
67
+ message="Summarize invoices", response="Nothing relevant",
68
+ ended_at="2026-06-27T10:00:00+00:00", main_context=ctx)
69
+ runs_db.record_run("b", "cron", "sid-b", "succeeded",
70
+ user_message="Unrelated prompt", response="Invoice summary ready",
71
+ ended_at="2026-06-27T11:00:00+00:00", main_context=ctx)
72
+ runs_db.record_run("c", "cron", "sid-c", "succeeded",
73
+ message="Unrelated", response="No match",
74
+ ended_at="2026-06-27T12:00:00+00:00", main_context=ctx)
75
+
76
+ rows = runs_db.recent_runs(1, search="invoice", main_context=ctx)
77
+ assert [row["session_id"] for row in rows] == ["sid-b"]
78
+ rows = runs_db.recent_runs(1, 1, search="invoice", main_context=ctx)
79
+ assert [row["session_id"] for row in rows] == ["sid-a"]
80
+
81
+
61
82
  def test_record_run_never_raises_on_bad_path(tmp_path):
62
83
  # An unopenable db path must be swallowed (FR-009).
63
84
  runs_db.record_run("skill", "cron", "sid", "succeeded",
@@ -272,6 +293,7 @@ def test_migration_adds_run_detail_columns_without_data_loss(tmp_path):
272
293
  assert rows[0]["message"] is None # pre-feature row reads as NULL
273
294
  assert rows[0]["user_message"] is None
274
295
  assert rows[0]["response"] is None
296
+ assert rows[0]["ended_at"] == rows[0]["started_at"]
275
297
 
276
298
  # New writes carry both details; the upgraded DB round-trips them.
277
299
  runs_db.record_run("new", "cron", "sid-new", "succeeded", message="m",
@@ -60,6 +60,24 @@ def _write_issue_skill(root: Path) -> Path:
60
60
  return skills_dir
61
61
 
62
62
 
63
+ class RecentRunsTest(unittest.TestCase):
64
+ def test_formats_duration_from_start_and_end_times(self) -> None:
65
+ with tempfile.TemporaryDirectory() as directory:
66
+ context = CodeeMainContext(data_dir=Path(directory))
67
+ runs_db.record_run(
68
+ "skill", "cron", "sid", "succeeded",
69
+ started_at="2026-06-27T10:00:00+00:00",
70
+ ended_at="2026-06-27T10:02:05+00:00",
71
+ main_context=context,
72
+ )
73
+ service = AdminService.__new__(AdminService)
74
+ service.context = context
75
+
76
+ run, = service.recent_runs()
77
+
78
+ self.assertEqual(run["duration_label"], "2m 5s")
79
+
80
+
63
81
  class NormalizeWorkItemsTest(unittest.TestCase):
64
82
  """What the settings form's mapping rows have to satisfy before they save."""
65
83
 
@@ -2575,6 +2593,7 @@ class AdminServiceAgentModelsTest(unittest.TestCase):
2575
2593
  {"code": "claude_code", "name": "Claude Code"},
2576
2594
  {"code": "github_copilot", "name": "GitHub Copilot"},
2577
2595
  {"code": "codex", "name": "Codex"},
2596
+ {"code": "opencode", "name": "OpenCode"},
2578
2597
  ])
2579
2598
 
2580
2599
  def test_an_agent_that_cannot_be_asked_yields_an_empty_list(self) -> None:
@@ -2,6 +2,7 @@ import unittest
2
2
 
3
3
  from codee_agent_claude_code.provider import ClaudeCodeAgent
4
4
  from codee_agent_codex.provider import CodexAgent
5
+ from codee_agent_opencode.provider import OpenCodeAgent
5
6
  from codee_main_context.context import CodingAgent, Settings
6
7
 
7
8
  from codee.coding_agents import (
@@ -15,6 +16,7 @@ class ResolveAgentCodeTest(unittest.TestCase):
15
16
  self.assertEqual(resolve_agent_code("codex"), CodingAgent.CODEX)
16
17
  self.assertEqual(resolve_agent_code("github_copilot"),
17
18
  CodingAgent.GITHUB_COPILOT)
19
+ self.assertEqual(resolve_agent_code("opencode"), CodingAgent.OPENCODE)
18
20
 
19
21
  def test_a_hand_written_code_resolves_whatever_its_shape(self) -> None:
20
22
  # The field is written by hand as often as by the admin UI.
@@ -46,12 +48,20 @@ class BuildCodingAgentTest(unittest.TestCase):
46
48
 
47
49
  self.assertIsInstance(agent, CodexAgent)
48
50
 
51
+ def test_opencode_can_be_built(self) -> None:
52
+ settings = Settings(coding_agent=CodingAgent.OPENCODE)
53
+
54
+ agent = build_coding_agent(settings, cwd=None)
55
+
56
+ self.assertIsInstance(agent, OpenCodeAgent)
57
+
49
58
 
50
59
  class AgentLabelTest(unittest.TestCase):
51
60
  def test_agents_are_labelled_by_their_display_name(self) -> None:
52
61
  self.assertEqual(agent_label(CodingAgent.CLAUDE_CODE), "Claude Code")
53
62
  self.assertEqual(agent_label(CodingAgent.GITHUB_COPILOT),
54
63
  "GitHub Copilot")
64
+ self.assertEqual(agent_label(CodingAgent.OPENCODE), "OpenCode")
55
65
 
56
66
 
57
67
  if __name__ == "__main__":
@@ -0,0 +1 @@
1
+ """OpenCode coding-agent integration."""
@@ -0,0 +1,225 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import threading
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ from codee_agent_abstract.provider import AbstractCodingAgent, AgentModel
9
+ from codee_main_context.context import Settings
10
+ from codee_main_context.logging import get_logger
11
+
12
+ from codee.lib.mcp_config import read_mcp_servers
13
+
14
+
15
+ log = get_logger(__name__)
16
+
17
+
18
+ class OpenCodeAgent(AbstractCodingAgent):
19
+ """Runs the ``opencode`` CLI non-interactively in a fresh session."""
20
+
21
+ DISPLAY_NAME = "OpenCode"
22
+ CLI_COMMAND = "opencode"
23
+ TIMEOUT_SECONDS = 7200 # 2 hours
24
+
25
+ def __init__(self, settings: Settings, cwd: Path):
26
+ super().__init__(settings, cwd)
27
+
28
+ @classmethod
29
+ def list_models(cls) -> list[AgentModel]:
30
+ try:
31
+ result = subprocess.run(
32
+ [cls.CLI_COMMAND, "models"],
33
+ capture_output=True,
34
+ text=True,
35
+ encoding="utf-8",
36
+ errors="replace",
37
+ timeout=60,
38
+ )
39
+ if result.returncode != 0:
40
+ raise RuntimeError(result.stderr.strip())
41
+ models = []
42
+ for line in result.stdout.splitlines():
43
+ model = line.strip()
44
+ if model:
45
+ models.append(AgentModel(model, model))
46
+ return models
47
+ except Exception as exc:
48
+ log.warning("Could not read the opencode model catalog: %s", exc)
49
+ return []
50
+
51
+ def skill_prompt(self, slug: str, path: Path, argument: str = "",
52
+ argument_name: str = "") -> str:
53
+ prompt = f"Read {_relative(path, self._cwd)} and follow its instructions exactly."
54
+ if argument:
55
+ prompt += f" {argument_name or 'ARGUMENT'} = {argument}"
56
+ return prompt
57
+
58
+ def run(self, user_message: str, session_id: str, model: str = "",
59
+ on_session_id: Callable[[str], None] | None = None) -> str:
60
+ return self._run(user_message, model, on_session_id)
61
+
62
+ def continue_conversation(
63
+ self,
64
+ user_message: str,
65
+ session_id: str,
66
+ model: str = "",
67
+ on_session_id: Callable[[str], None] | None = None,
68
+ ) -> str:
69
+ return self._run(user_message, model, on_session_id, session_id)
70
+
71
+ def _run(self, user_message: str, model: str,
72
+ on_session_id: Callable[[str], None] | None,
73
+ resume_session_id: str = "") -> str:
74
+ cmd = [self.CLI_COMMAND, "run", "--format", "json", "--auto"]
75
+ if resume_session_id:
76
+ cmd += ["--session", resume_session_id]
77
+ if model:
78
+ cmd += ["--model", model]
79
+ cmd += ["--", user_message]
80
+
81
+ log.info("Running opencode with message: %s", user_message)
82
+ log.debug("cwd=%s cmd=%s", self._cwd, " ".join(cmd))
83
+ process = subprocess.Popen(
84
+ cmd,
85
+ stdout=subprocess.PIPE,
86
+ stderr=subprocess.PIPE,
87
+ text=True,
88
+ encoding="utf-8",
89
+ errors="replace",
90
+ bufsize=1,
91
+ cwd=self._cwd,
92
+ stdin=subprocess.DEVNULL,
93
+ env=_environment(self._cwd),
94
+ )
95
+ out: list[str] = []
96
+ err: list[str] = []
97
+ readers = [
98
+ threading.Thread(target=_collect, daemon=True,
99
+ args=(process.stdout, out, on_session_id)),
100
+ threading.Thread(target=_collect, daemon=True,
101
+ args=(process.stderr, err, None)),
102
+ ]
103
+ for reader in readers:
104
+ reader.start()
105
+ try:
106
+ process.wait(timeout=self.TIMEOUT_SECONDS)
107
+ except subprocess.TimeoutExpired:
108
+ process.kill()
109
+ process.wait()
110
+ raise RuntimeError("OpenCode CLI timed out after 2 hours")
111
+ finally:
112
+ for reader in readers:
113
+ reader.join(timeout=5)
114
+
115
+ result = subprocess.CompletedProcess(
116
+ cmd, process.returncode, "".join(out), "".join(err))
117
+
118
+ reply, opened_session, errors, recognized = _parse_events(
119
+ result.stdout)
120
+ detail = "; ".join(errors) or result.stderr.strip()[:500]
121
+ if result.returncode != 0:
122
+ raise RuntimeError(
123
+ f"OpenCode CLI exited {result.returncode}: {detail}")
124
+ if errors:
125
+ raise RuntimeError(f"OpenCode run errored: {detail}")
126
+ if not recognized:
127
+ log.warning(
128
+ "opencode produced no recognized events; returning raw output")
129
+ return result.stdout
130
+ if not reply:
131
+ raise RuntimeError(
132
+ f"OpenCode run produced no response: {detail or 'no detail'}")
133
+ return reply
134
+
135
+
136
+ def _collect(stream, lines: list[str],
137
+ on_session_id: Callable[[str], None] | None) -> None:
138
+ reported = False
139
+ for line in stream:
140
+ lines.append(line)
141
+ if reported or on_session_id is None:
142
+ continue
143
+ _, session_id, _, recognized = _parse_events(line)
144
+ if recognized and session_id:
145
+ reported = True
146
+ try:
147
+ on_session_id(session_id)
148
+ except Exception as exc:
149
+ log.warning("Could not report opencode session %s: %s",
150
+ session_id, exc)
151
+ stream.close()
152
+
153
+
154
+ def _relative(path: Path, cwd: Path) -> str:
155
+ try:
156
+ return str(path.relative_to(cwd))
157
+ except ValueError:
158
+ return str(path)
159
+
160
+
161
+ def _environment(root: Path) -> dict[str, str]:
162
+ """Process environment with the project's MCP servers translated for OpenCode."""
163
+ environment = os.environ.copy()
164
+ raw = environment.get("OPENCODE_CONFIG_CONTENT", "").strip()
165
+ try:
166
+ config = json.loads(raw) if raw else {}
167
+ except json.JSONDecodeError as exc:
168
+ raise RuntimeError(
169
+ "OPENCODE_CONFIG_CONTENT is not valid JSON") from exc
170
+ if not isinstance(config, dict):
171
+ raise RuntimeError("OPENCODE_CONFIG_CONTENT must hold a JSON object")
172
+
173
+ servers = read_mcp_servers(root)
174
+ if servers:
175
+ mcp = config.get("mcp") or {}
176
+ if not isinstance(mcp, dict):
177
+ raise RuntimeError(
178
+ "OPENCODE_CONFIG_CONTENT.mcp must hold a JSON object")
179
+ for name, server in servers.items():
180
+ entry = {
181
+ "type": "local",
182
+ "command": [server["command"], *server["args"]],
183
+ "enabled": True,
184
+ }
185
+ if server["env"]:
186
+ entry["environment"] = server["env"]
187
+ mcp[name] = entry
188
+ config["mcp"] = mcp
189
+ environment["OPENCODE_CONFIG_CONTENT"] = json.dumps(config)
190
+ return environment
191
+
192
+
193
+ def _parse_events(output: str) -> tuple[str, str, list[str], bool]:
194
+ replies: list[str] = []
195
+ errors: list[str] = []
196
+ session_id = ""
197
+ recognized = False
198
+ for line in output.splitlines():
199
+ try:
200
+ event = json.loads(line)
201
+ except json.JSONDecodeError:
202
+ continue
203
+ if not isinstance(event, dict):
204
+ continue
205
+ event_type = event.get("type")
206
+ if event_type not in {"text", "error", "tool_use", "step_start",
207
+ "step_finish", "reasoning"}:
208
+ continue
209
+ recognized = True
210
+ session_id = session_id or str(event.get("sessionID", "")).strip()
211
+ if event_type == "text":
212
+ part = event.get("part") or {}
213
+ text = str(part.get("text", "")).strip(
214
+ ) if isinstance(part, dict) else ""
215
+ if text:
216
+ replies.append(text)
217
+ elif event_type == "error":
218
+ error = event.get("error")
219
+ if isinstance(error, dict):
220
+ data = error.get("data") or {}
221
+ error = data.get("message") if isinstance(
222
+ data, dict) else error
223
+ if error:
224
+ errors.append(str(error))
225
+ return "\n\n".join(replies), session_id, errors, recognized
@@ -0,0 +1,153 @@
1
+ import io
2
+ import json
3
+ import subprocess
4
+ import tempfile
5
+ import unittest
6
+ from pathlib import Path
7
+ from unittest.mock import Mock, patch
8
+
9
+ from codee_agent_opencode.provider import OpenCodeAgent, _environment
10
+ from codee_main_context.context import Settings
11
+
12
+
13
+ SESSION = "ses_01k123"
14
+
15
+
16
+ def _event(kind: str, **fields) -> str:
17
+ return json.dumps({"type": kind, "sessionID": SESSION, **fields})
18
+
19
+
20
+ class _FakeProcess:
21
+ def __init__(self, stdout: str, stderr: str, returncode: int,
22
+ timeout: bool = False):
23
+ self.stdout = io.StringIO(stdout)
24
+ self.stderr = io.StringIO(stderr)
25
+ self.returncode = returncode
26
+ self.timeout = timeout
27
+
28
+ def wait(self, timeout=None):
29
+ if self.timeout:
30
+ raise subprocess.TimeoutExpired("opencode", timeout or 0)
31
+ return self.returncode
32
+
33
+ def kill(self):
34
+ self.timeout = False
35
+
36
+
37
+ def _completed(stdout: str = "", stderr: str = "", returncode: int = 0,
38
+ timeout: bool = False) -> _FakeProcess:
39
+ return _FakeProcess(stdout, stderr, returncode, timeout)
40
+
41
+
42
+ class OpenCodeRunTest(unittest.TestCase):
43
+ def setUp(self) -> None:
44
+ self.root = Path("/repo")
45
+ self.agent = OpenCodeAgent(Settings(), self.root)
46
+
47
+ def _run(self, result: Mock, model: str = "") -> str:
48
+ self.reported = []
49
+ with patch("subprocess.Popen", return_value=result) as run:
50
+ self.captured = run
51
+ return self.agent.run("/do-it CORE-1", "unused", model,
52
+ self.reported.append)
53
+
54
+ def test_returns_text_and_reports_the_opened_session(self) -> None:
55
+ stdout = "\n".join([
56
+ _event("step_start", part={"type": "step-start"}),
57
+ _event("text", part={"type": "text", "text": "Done."}),
58
+ ])
59
+
60
+ self.assertEqual(self._run(_completed(stdout)), "Done.")
61
+ self.assertEqual(self.reported, [SESSION])
62
+
63
+ def test_runs_headless_with_permissions_and_model(self) -> None:
64
+ self._run(_completed(_event(
65
+ "text", part={"type": "text", "text": "Done."})),
66
+ "anthropic/claude-opus-4-1")
67
+
68
+ cmd = self.captured.call_args.args[0]
69
+ self.assertEqual(cmd[:2], ["opencode", "run"])
70
+ self.assertIn("--auto", cmd)
71
+ self.assertEqual(cmd[cmd.index("--format") + 1], "json")
72
+ self.assertEqual(cmd[cmd.index("--model") + 1],
73
+ "anthropic/claude-opus-4-1")
74
+ self.assertEqual(cmd[-2:], ["--", "/do-it CORE-1"])
75
+ self.assertEqual(self.captured.call_args.kwargs["cwd"], self.root)
76
+ self.assertEqual(self.captured.call_args.kwargs["stdin"],
77
+ subprocess.DEVNULL)
78
+
79
+ def test_project_mcp_servers_are_translated_into_inline_config(self) -> None:
80
+ with tempfile.TemporaryDirectory() as directory:
81
+ root = Path(directory)
82
+ (root / ".mcp.json").write_text(json.dumps({
83
+ "mcpServers": {
84
+ "jira": {
85
+ "command": "uvx",
86
+ "args": ["mcp-atlassian"],
87
+ "env": {"JIRA_URL": "https://acme.example"},
88
+ },
89
+ },
90
+ }))
91
+ with patch.dict("os.environ", {
92
+ "OPENCODE_CONFIG_CONTENT": '{"model":"openai/gpt-5"}'},
93
+ clear=True):
94
+ config = json.loads(_environment(root)[
95
+ "OPENCODE_CONFIG_CONTENT"])
96
+
97
+ self.assertEqual(config["model"], "openai/gpt-5")
98
+ self.assertEqual(config["mcp"]["jira"], {
99
+ "type": "local",
100
+ "command": ["uvx", "mcp-atlassian"],
101
+ "enabled": True,
102
+ "environment": {"JIRA_URL": "https://acme.example"},
103
+ })
104
+
105
+ def test_continuing_resumes_the_opencode_session(self) -> None:
106
+ result = _completed(_event(
107
+ "text", part={"type": "text", "text": "Continued."}))
108
+ with patch("subprocess.Popen", return_value=result) as run:
109
+ response = self.agent.continue_conversation("And now?", SESSION)
110
+
111
+ self.assertEqual(response, "Continued.")
112
+ cmd = run.call_args.args[0]
113
+ self.assertEqual(cmd[cmd.index("--session") + 1], SESSION)
114
+
115
+ def test_cli_and_stream_errors_raise(self) -> None:
116
+ error = _event("error", error={"data": {"message": "bad model"}})
117
+ with self.assertRaisesRegex(RuntimeError, "bad model"):
118
+ self._run(_completed(error, returncode=1))
119
+ with self.assertRaisesRegex(RuntimeError, "bad model"):
120
+ self._run(_completed(error))
121
+
122
+ def test_plain_successful_output_is_returned(self) -> None:
123
+ self.assertEqual(self._run(_completed(
124
+ "plain output\n")), "plain output\n")
125
+
126
+ def test_timeout_raises(self) -> None:
127
+ with patch("subprocess.Popen", return_value=_completed(timeout=True)):
128
+ with self.assertRaisesRegex(RuntimeError, "timed out"):
129
+ self.agent.run("do it", "unused")
130
+
131
+ def test_skill_prompt_names_the_skill_file(self) -> None:
132
+ prompt = self.agent.skill_prompt(
133
+ "task-developer", self.root / ".claude/skills/task-developer/SKILL.md",
134
+ "CORE-1", "TASK_ID")
135
+
136
+ self.assertEqual(
137
+ prompt,
138
+ "Read .claude/skills/task-developer/SKILL.md and follow its "
139
+ "instructions exactly. TASK_ID = CORE-1",
140
+ )
141
+
142
+ def test_models_come_from_the_cli(self) -> None:
143
+ result = Mock(returncode=0, stderr="", stdout=(
144
+ "anthropic/claude-opus-4-1\nopenai/gpt-5\n"))
145
+ with patch("subprocess.run", return_value=result):
146
+ models = OpenCodeAgent.list_models()
147
+
148
+ self.assertEqual([model.id for model in models], [
149
+ "anthropic/claude-opus-4-1", "openai/gpt-5"])
150
+
151
+
152
+ if __name__ == "__main__":
153
+ unittest.main()
@@ -40,6 +40,7 @@ class CodingAgent(str, Enum):
40
40
  CLAUDE_CODE = "claude_code"
41
41
  GITHUB_COPILOT = "github_copilot"
42
42
  CODEX = "codex"
43
+ OPENCODE = "opencode"
43
44
 
44
45
 
45
46
  @dataclass(frozen=True)
File without changes