codee-agent 0.6.8__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.
- {codee_agent-0.6.8 → codee_agent-0.7.0}/PKG-INFO +3 -3
- {codee_agent-0.6.8 → codee_agent-0.7.0}/README.md +1 -1
- {codee_agent-0.6.8 → codee_agent-0.7.0}/pyproject.toml +3 -2
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/admin.py +49 -9
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/admin_service.py +12 -2
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/coding_agents.py +2 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/executor.py +9 -1
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/runs_db.py +31 -10
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/test_runs_db.py +29 -7
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_admin_service.py +19 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_coding_agents.py +10 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_executor.py +34 -0
- codee_agent-0.7.0/src/codee_agent_opencode/__init__.py +1 -0
- codee_agent-0.7.0/src/codee_agent_opencode/provider.py +225 -0
- codee_agent-0.7.0/src/codee_agent_opencode/test.py +153 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_main_context/context.py +1 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/LICENSE +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/.gitignore +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/admin_api.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/admin_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/agent_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/init_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/claude_key_rotation.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/cron_describe.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/mcp_config.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/test_claude_key_rotation.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/test_mcp_config.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/test_trigger_cron_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/test_trigger_issue_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/trigger_aws_sqs_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/trigger_cron_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/trigger_email_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/lib/trigger_issue_skills.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/mail_server.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/setup_wizard.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/start_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/tasks_providers.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/AGENTS.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/CLAUDE.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-developer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-planner/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-planner/assets/readme-template.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-qa/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-security-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-code-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-developer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-qa/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-security-reviewer/SKILL.md +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_admin_api.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_admin_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_agent_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_init_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_memory_index.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_setup_wizard.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/test_start_cli.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/workflow_graph.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_admin/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_admin/codee_admin.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_abstract/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_abstract/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/account.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/credentials.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/oauth.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/test.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_claude_code/usage.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_codex/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_codex/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_codex/test.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_github_copilot/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_github_copilot/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_agent_github_copilot/test.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_database/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_database/claude_code_accounts.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_database/database.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_database/oauth_tokens.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_main_context/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_main_context/logging.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_main_context/test_context.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_main_context/test_logging.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_abstract/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_abstract/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/oauth.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/provider.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_azure_devops/test.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_jira/__init__.py +0 -0
- {codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee_tasks_jira/provider.py +0 -0
- {codee_agent-0.6.8 → 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.
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
|
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(
|
|
847
|
-
|
|
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[:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
2050
|
-
|
|
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
|
|
@@ -180,7 +182,13 @@ def _refresh_config() -> None:
|
|
|
180
182
|
else:
|
|
181
183
|
log.info("Reloaded tasks provider: %s", tasks_provider.describe())
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
coding_agent_changed = settings.coding_agent != previous.coding_agent
|
|
186
|
+
copilot_settings_changed = (
|
|
187
|
+
settings.coding_agent == CodingAgent.GITHUB_COPILOT
|
|
188
|
+
and settings.github_copilot_prompt_prefix
|
|
189
|
+
!= previous.github_copilot_prompt_prefix
|
|
190
|
+
)
|
|
191
|
+
if coding_agent_changed or copilot_settings_changed:
|
|
184
192
|
try:
|
|
185
193
|
coding_agent = _build_coding_agent(settings)
|
|
186
194
|
except Exception as exc:
|
|
@@ -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",
|
|
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
|
-
"""
|
|
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
|
|
125
|
-
|
|
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",
|
|
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",
|
|
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-
|
|
35
|
-
assert rows[0]["
|
|
36
|
-
assert rows[
|
|
37
|
-
assert rows[
|
|
38
|
-
assert rows[
|
|
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__":
|
|
@@ -167,6 +167,40 @@ class RefreshConfigTest(unittest.TestCase):
|
|
|
167
167
|
self.assertEqual(executor.context.settings.coding_agent,
|
|
168
168
|
CodingAgent.GITHUB_COPILOT)
|
|
169
169
|
|
|
170
|
+
def test_changing_copilot_prompt_prefix_rebuilds_the_default_agent(self) -> None:
|
|
171
|
+
self._save(coding_agent=CodingAgent.GITHUB_COPILOT)
|
|
172
|
+
executor._refresh_config()
|
|
173
|
+
original = executor.coding_agent
|
|
174
|
+
|
|
175
|
+
self._save(
|
|
176
|
+
coding_agent=CodingAgent.GITHUB_COPILOT,
|
|
177
|
+
github_copilot_prompt_prefix="Follow repository policy.",
|
|
178
|
+
)
|
|
179
|
+
executor._refresh_config()
|
|
180
|
+
|
|
181
|
+
self.assertIsNot(executor.coding_agent, original)
|
|
182
|
+
completed = Mock(
|
|
183
|
+
returncode=0,
|
|
184
|
+
stdout=(
|
|
185
|
+
'{"type":"assistant.message","data":{"content":"ok"}}\n'
|
|
186
|
+
'{"type":"result","exitCode":0}\n'
|
|
187
|
+
),
|
|
188
|
+
stderr="",
|
|
189
|
+
)
|
|
190
|
+
issue_prompt = (
|
|
191
|
+
"Read .claude/skills/story-qa/SKILL.md and follow its "
|
|
192
|
+
"instructions exactly. STORY_ID = 92146"
|
|
193
|
+
)
|
|
194
|
+
with patch("codee_agent_github_copilot.provider.subprocess.run",
|
|
195
|
+
return_value=completed) as run:
|
|
196
|
+
executor._agent_for_skill("").run(issue_prompt, "session-1")
|
|
197
|
+
|
|
198
|
+
command = run.call_args.args[0]
|
|
199
|
+
self.assertEqual(
|
|
200
|
+
command[command.index("-p") + 1],
|
|
201
|
+
f"Follow repository policy.\n\n{issue_prompt}",
|
|
202
|
+
)
|
|
203
|
+
|
|
170
204
|
def test_broken_coding_agent_settings_keep_polling_with_the_old_one(self) -> None:
|
|
171
205
|
self._save(coding_agent=CodingAgent.CLAUDE_CODE)
|
|
172
206
|
executor._refresh_config()
|
|
@@ -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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/aws-sqs-alarm-response/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/cron-research-5xx-errors/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/story-security-reviewer/SKILL.md
RENAMED
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-code-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{codee_agent-0.6.8 → codee_agent-0.7.0}/src/codee/templates/skills/task-security-reviewer/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|