commit 4ed4d66f2fa0f5f3749c4a3ec0137d881eac9cd0
parent e47fe19d210798935b02e6cbb61ef0c5e58d8e29
Author: Andrew Laack <andrew@laack.co>
Date: Mon, 9 Mar 2026 21:48:35 -0500
Merge pull request #181 from imbue-ai/andrew/opencode
Add --agent-harness opencode support
Diffstat:
15 files changed, 777 insertions(+), 8 deletions(-)
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
@@ -37,6 +37,7 @@ Without this Claude Code will not be installed in the image.
./dev/vet.sh "check for bugs" --base-commit main
./dev/vet.sh --base-commit main --agentic --agent-harness codex
./dev/vet.sh --base-commit main --agentic --agent-harness claude # requires I_CHOOSE_CONVENIENCE_OVER_FREEDOM=true
+./dev/vet.sh --base-commit main --agentic --agent-harness opencode
```
The image is built automatically on each run. This process should be fast due to layer caching.
diff --git a/README.md b/README.md
@@ -113,7 +113,7 @@ Compare against a base ref/commit:
vet "Refactor storage layer" --base-commit main
```
-Use Claude Code or Codex instead of LLM APIs (`--agent-harness`: `claude`, `codex`):
+Use Claude Code, Codex, or OpenCode instead of LLM APIs (`--agent-harness`: `claude`, `codex`, `opencode`):
```bash
vet "Implement X without breaking Y" --agentic --agent-harness claude
diff --git a/dev/Containerfile b/dev/Containerfile
@@ -2,8 +2,7 @@ FROM debian:bookworm-slim
ARG INSTALL_CLAUDE=false
-# OpenCode is included for in-container development.
-# It is not currently supported when doing agentic verification.
+# OpenCode is included for in-container development and agentic verification.
RUN apt-get update \
&& apt-get install -y npm git curl \
diff --git a/skills/vet/SKILL.md b/skills/vet/SKILL.md
@@ -95,8 +95,8 @@ Vet analyzes the full git diff from the base commit. This may include changes fr
- `--confidence-threshold N`: Minimum confidence 0.0-1.0 (default: 0.8)
- `--output-format FORMAT`: Output as `text`, `json`, or `github`
- `--quiet`: Suppress status messages and 'No issues found.'
-- `--agentic`: Mode that routes analysis through the locally installed Claude Code or Codex CLI instead of calling the API directly. Try this if vet fails due to missing API keys. This is slower so it is not the default, but it often results in higher precision issue identification. `--model` is forwarded to the harness but not validated by vet, as vet doesn't know which models each harness supports.
-- `--agent-harness`: The two options for this are `codex` and `claude`. Claude Code is the default.
+- `--agentic`: Mode that routes analysis through the locally installed Claude Code, Codex, or OpenCode CLI instead of calling the API directly. Try this if vet fails due to missing API keys. This is slower so it is not the default, but it often results in higher precision issue identification. `--model` is forwarded to the harness but not validated by vet, as vet doesn't know which models each harness supports.
+- `--agent-harness`: The three options for this are `codex`, `claude`, and `opencode`. Claude Code is the default.
- `--help`: Show comprehensive list of options
diff --git a/uv.lock b/uv.lock
@@ -1494,7 +1494,7 @@ wheels = [
[[package]]
name = "verify-everything"
-version = "0.2.5"
+version = "0.2.6"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
diff --git a/vet/cli/main.py b/vet/cli/main.py
@@ -279,6 +279,7 @@ def list_issue_codes() -> None:
_HARNESS_ISSUE_URLS: dict[AgentHarnessType, str] = {
AgentHarnessType.CLAUDE: "https://github.com/anthropics/claude-code/issues",
AgentHarnessType.CODEX: "https://github.com/openai/codex/issues",
+ AgentHarnessType.OPENCODE: "https://github.com/sst/opencode/issues",
}
@@ -661,7 +662,7 @@ def main(argv: list[str] | None = None) -> int:
except Exception as e:
print(f"vet: {e}", file=sys.stderr)
print(
- "hint: If you have a Claude or Codex subscription, try --agentic to use your\n"
+ "hint: If you have a Claude, Codex, or OpenCode subscription, try --agentic to use your\n"
" locally installed coding agent CLI instead.",
file=sys.stderr,
)
@@ -727,7 +728,7 @@ def main(argv: list[str] | None = None) -> int:
except MissingAPIKeyError as e:
print(f"vet: {e}", file=sys.stderr)
print(
- "hint: If you have a Claude or Codex subscription, try --agentic to use your\n"
+ "hint: If you have a Claude, Codex, or OpenCode subscription, try --agentic to use your\n"
" locally installed coding agent CLI instead.",
file=sys.stderr,
)
diff --git a/vet/imbue_core/agents/agent_api/api.py b/vet/imbue_core/agents/agent_api/api.py
@@ -15,6 +15,8 @@ from vet.imbue_core.agents.agent_api.client import CachedAgentClient
from vet.imbue_core.agents.agent_api.codex.client import CodexClient
from vet.imbue_core.agents.agent_api.codex.data_types import CodexOptions
from vet.imbue_core.agents.agent_api.data_types import AgentOptions
+from vet.imbue_core.agents.agent_api.opencode.client import OpenCodeClient
+from vet.imbue_core.agents.agent_api.opencode.data_types import OpenCodeOptions
@singledispatch
@@ -35,6 +37,11 @@ def _(options: CodexOptions) -> ContextManager[AgentClient[CodexOptions]]:
return CodexClient.build(options)
+@_build_client_from_options.register
+def _(options: OpenCodeOptions) -> ContextManager[AgentClient[OpenCodeOptions]]:
+ return OpenCodeClient.build(options)
+
+
@contextmanager
def get_agent_client(
*,
diff --git a/vet/imbue_core/agents/agent_api/opencode/__init__.py b/vet/imbue_core/agents/agent_api/opencode/__init__.py
diff --git a/vet/imbue_core/agents/agent_api/opencode/client.py b/vet/imbue_core/agents/agent_api/opencode/client.py
@@ -0,0 +1,105 @@
+import shutil
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Generator
+from typing import Iterator
+from typing import Self
+
+from loguru import logger
+
+from vet.imbue_core.agents.agent_api.client import RealAgentClient
+from vet.imbue_core.agents.agent_api.data_types import AgentMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentResultMessage
+from vet.imbue_core.agents.agent_api.errors import AgentCLINotFoundError
+from vet.imbue_core.agents.agent_api.opencode.data_types import OpenCodeOptions
+from vet.imbue_core.agents.agent_api.opencode.message_parser import parse_opencode_event
+from vet.imbue_core.agents.agent_api.transport import AgentSubprocessCLITransport
+from vet.imbue_core.agents.agent_api.transport import AgentSubprocessCLITransportOptions
+
+
+class OpenCodeClient(RealAgentClient[OpenCodeOptions]):
+ def __init__(self, options: OpenCodeOptions) -> None:
+ super().__init__(options=options)
+
+ @classmethod
+ @contextmanager
+ def build(cls, options: OpenCodeOptions) -> Generator[Self, None, None]:
+ yield cls(options=options)
+
+ def process_query(self, prompt: str) -> Iterator[AgentMessage]:
+ logger.trace(
+ "{client_name}: calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+
+ cmd = self._build_cli_cmd(self._options)
+ with AgentSubprocessCLITransport.build(
+ AgentSubprocessCLITransportOptions(cmd=cmd, cwd=self._options.cwd)
+ ) as transport:
+ transport.write_stdin(prompt)
+
+ for data in transport.receive_messages():
+ logger.trace(
+ "{client_name}: received raw JSON message={data}",
+ client_name=type(self).__name__,
+ data=data,
+ )
+
+ message = parse_opencode_event(data)
+ if message:
+ yield message
+
+ if isinstance(message, AgentResultMessage):
+ break
+
+ logger.trace(
+ "{client_name}: finished calling agent with prompt={prompt}",
+ client_name=type(self).__name__,
+ prompt=prompt,
+ )
+
+ @staticmethod
+ def _find_cli() -> str:
+ cli = shutil.which("opencode")
+ if cli:
+ return cli
+
+ locations = [
+ Path("/usr/local/bin/opencode"),
+ Path.home() / ".local/bin/opencode",
+ Path.home() / "node_modules/.bin/opencode",
+ Path.home() / ".npm-global/bin/opencode",
+ ]
+
+ for path in locations:
+ if path.exists() and path.is_file():
+ return str(path)
+
+ node_installed = shutil.which("node") is not None
+
+ if not node_installed:
+ raise AgentCLINotFoundError("OpenCode CLI not found. Node.js is required but not installed.")
+
+ raise AgentCLINotFoundError(
+ "OpenCode CLI not found. Ensure it is installed and available on your PATH, or specify a different harness with --agent-harness."
+ )
+
+ @classmethod
+ def _build_cli_cmd(cls, options: OpenCodeOptions) -> list[str]:
+ if options.is_cached:
+ cmd = ["CACHED_OPENCODE_EXEC_PLACEHOLDER"]
+ return cmd
+ cli_path = str(options.cli_path) if options.cli_path is not None else cls._find_cli()
+ cmd = [cli_path, "run", "--format", "json"]
+ cmd.extend(cls._build_cli_args(options))
+ return cmd
+
+ @staticmethod
+ def _build_cli_args(options: OpenCodeOptions) -> list[str]:
+ args: list[str] = []
+ if options.model:
+ args.extend(["--model", options.model])
+ if options.cwd:
+ args.extend(["--dir", str(options.cwd)])
+ return args
diff --git a/vet/imbue_core/agents/agent_api/opencode/client_test.py b/vet/imbue_core/agents/agent_api/opencode/client_test.py
@@ -0,0 +1,168 @@
+import json
+from pathlib import Path
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+import pytest
+
+from vet.imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentResultMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentTextBlock
+from vet.imbue_core.agents.agent_api.errors import AgentCLINotFoundError
+from vet.imbue_core.agents.agent_api.opencode.client import OpenCodeClient
+from vet.imbue_core.agents.agent_api.opencode.data_types import OpenCodeOptions
+
+
+class TestFindCli:
+ def test_finds_via_which(self) -> None:
+ with patch("shutil.which", return_value="/usr/bin/opencode"):
+ assert OpenCodeClient._find_cli() == "/usr/bin/opencode"
+
+ def test_finds_via_known_paths(self, tmp_path: Path) -> None:
+ fake_home = tmp_path / "home"
+ fake_cli = fake_home / ".local/bin/opencode"
+ fake_cli.parent.mkdir(parents=True)
+ fake_cli.touch()
+
+ with (
+ patch("shutil.which", return_value=None),
+ patch(
+ "vet.imbue_core.agents.agent_api.opencode.client.Path.home",
+ return_value=fake_home,
+ ),
+ ):
+ assert OpenCodeClient._find_cli() == str(fake_cli)
+
+ def test_raises_when_not_found_no_node(self) -> None:
+ with patch("shutil.which", return_value=None):
+ with pytest.raises(AgentCLINotFoundError, match="Node.js is required"):
+ OpenCodeClient._find_cli()
+
+ def test_raises_when_not_found_with_node(self) -> None:
+ def which_side_effect(name: str) -> str | None:
+ if name == "node":
+ return "/usr/bin/node"
+ return None
+
+ with patch("shutil.which", side_effect=which_side_effect):
+ with pytest.raises(AgentCLINotFoundError, match="Ensure it is installed"):
+ OpenCodeClient._find_cli()
+
+
+class TestBuildCliCmd:
+ def test_basic_command(self) -> None:
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"))
+ cmd = OpenCodeClient._build_cli_cmd(options)
+ assert cmd == ["/usr/bin/opencode", "run", "--format", "json"]
+
+ def test_with_model(self) -> None:
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"), model="anthropic/claude-opus-4-6")
+ cmd = OpenCodeClient._build_cli_cmd(options)
+ assert "--model" in cmd
+ assert "anthropic/claude-opus-4-6" in cmd
+
+ def test_with_cwd(self) -> None:
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"), cwd="/my/project")
+ cmd = OpenCodeClient._build_cli_cmd(options)
+ assert "--dir" in cmd
+ assert "/my/project" in cmd
+
+ def test_cached_placeholder(self) -> None:
+ options = OpenCodeOptions(is_cached=True)
+ cmd = OpenCodeClient._build_cli_cmd(options)
+ assert cmd == ["CACHED_OPENCODE_EXEC_PLACEHOLDER"]
+
+
+class TestProcessQuery:
+ def test_process_query_yields_messages(self) -> None:
+ text_event = {
+ "type": "text",
+ "timestamp": 1,
+ "sessionID": "ses_test",
+ "part": {
+ "id": "prt_1",
+ "sessionID": "ses_test",
+ "messageID": "msg_1",
+ "type": "text",
+ "text": "Hello world",
+ },
+ }
+ result_event = {
+ "type": "step_finish",
+ "timestamp": 2,
+ "sessionID": "ses_test",
+ "part": {
+ "id": "prt_2",
+ "sessionID": "ses_test",
+ "messageID": "msg_1",
+ "type": "step-finish",
+ "reason": "stop",
+ "cost": 0.01,
+ "tokens": {
+ "total": 100,
+ "input": 50,
+ "output": 50,
+ "reasoning": 0,
+ "cache": {"read": 0, "write": 0},
+ },
+ },
+ }
+
+ mock_transport = MagicMock()
+ mock_transport.receive_messages.return_value = iter([text_event, result_event])
+ mock_transport.write_stdin = MagicMock()
+ mock_transport.__enter__ = MagicMock(return_value=mock_transport)
+ mock_transport.__exit__ = MagicMock(return_value=False)
+
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"))
+
+ with patch(
+ "vet.imbue_core.agents.agent_api.opencode.client.AgentSubprocessCLITransport.build",
+ return_value=mock_transport,
+ ):
+ client = OpenCodeClient(options)
+ messages = list(client.process_query("test prompt"))
+
+ assert len(messages) == 2
+ assert isinstance(messages[0], AgentAssistantMessage)
+ assert isinstance(messages[0].content[0], AgentTextBlock)
+ assert messages[0].content[0].text == "Hello world"
+ assert isinstance(messages[1], AgentResultMessage)
+ assert messages[1].is_error is False
+
+ def test_process_query_error_event(self) -> None:
+ error_event = {
+ "type": "error",
+ "timestamp": 1,
+ "sessionID": "ses_test",
+ "part": {
+ "message": "Rate limit exceeded",
+ },
+ }
+
+ mock_transport = MagicMock()
+ mock_transport.receive_messages.return_value = iter([error_event])
+ mock_transport.write_stdin = MagicMock()
+ mock_transport.__enter__ = MagicMock(return_value=mock_transport)
+ mock_transport.__exit__ = MagicMock(return_value=False)
+
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"))
+
+ with patch(
+ "vet.imbue_core.agents.agent_api.opencode.client.AgentSubprocessCLITransport.build",
+ return_value=mock_transport,
+ ):
+ client = OpenCodeClient(options)
+ messages = list(client.process_query("test prompt"))
+
+ assert len(messages) == 1
+ assert isinstance(messages[0], AgentResultMessage)
+ assert messages[0].is_error is True
+ assert messages[0].error == "Rate limit exceeded"
+
+
+class TestBuildContextManager:
+ def test_build_yields_client(self) -> None:
+ options = OpenCodeOptions(cli_path=Path("/usr/bin/opencode"))
+ with OpenCodeClient.build(options) as client:
+ assert isinstance(client, OpenCodeClient)
diff --git a/vet/imbue_core/agents/agent_api/opencode/data_types.py b/vet/imbue_core/agents/agent_api/opencode/data_types.py
@@ -0,0 +1,31 @@
+from pathlib import Path
+from typing import Literal
+
+from vet.imbue_core.agents.agent_api.data_types import AgentOptions
+from vet.imbue_core.agents.agent_api.data_types import AgentToolName
+
+
+class OpenCodeOptions(AgentOptions):
+ object_type: Literal["OpenCodeOptions"] = "OpenCodeOptions"
+
+ model: str | None = None
+ cli_path: Path | None = None
+ is_cached: bool = False
+
+
+OPENCODE_TOOLS = (
+ AgentToolName.READ,
+ AgentToolName.WRITE,
+ AgentToolName.EDIT,
+ AgentToolName.MULTI_EDIT,
+ AgentToolName.GLOB,
+ AgentToolName.GREP,
+ AgentToolName.LS,
+ AgentToolName.BASH,
+ AgentToolName.WEB_SEARCH,
+ AgentToolName.WEB_FETCH,
+ AgentToolName.TASK,
+ AgentToolName.TODO_READ,
+ AgentToolName.TODO_WRITE,
+ AgentToolName.OTHER,
+)
diff --git a/vet/imbue_core/agents/agent_api/opencode/message_parser.py b/vet/imbue_core/agents/agent_api/opencode/message_parser.py
@@ -0,0 +1,126 @@
+from typing import Any
+
+from vet.imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentContentBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentResultMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentSystemEventType
+from vet.imbue_core.agents.agent_api.data_types import AgentSystemMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentTextBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentThinkingBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentToolResultBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentToolUseBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentUnknownMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentUsage
+
+
+def parse_opencode_event(data: dict[str, Any]) -> AgentMessage | None:
+ event_type = data.get("type", "")
+ part = data.get("part", {})
+ session_id = data.get("sessionID", "")
+
+ match event_type:
+ case "step_start":
+ return AgentSystemMessage(
+ event_type=AgentSystemEventType.SESSION_STARTED,
+ session_id=session_id,
+ original_message=data,
+ )
+
+ case "text":
+ text = part.get("text", "")
+ if not text:
+ return None
+ return AgentAssistantMessage(
+ content=[AgentTextBlock(text=text)],
+ original_message=data,
+ )
+
+ case "tool_use":
+ content_blocks = _parse_tool_use_part(part)
+ if not content_blocks:
+ return None
+ return AgentAssistantMessage(
+ content=content_blocks,
+ original_message=data,
+ )
+
+ case "thinking":
+ thinking_text = part.get("text", "")
+ if not thinking_text:
+ return None
+ return AgentAssistantMessage(
+ content=[AgentThinkingBlock(content=thinking_text)],
+ original_message=data,
+ )
+
+ case "step_finish":
+ reason = part.get("reason", "")
+ if reason != "stop":
+ return None
+
+ usage = None
+ tokens_data = part.get("tokens")
+ if tokens_data:
+ cache_data = tokens_data.get("cache", {})
+ usage = AgentUsage(
+ input_tokens=tokens_data.get("input", 0),
+ output_tokens=tokens_data.get("output", 0),
+ cached_tokens=cache_data.get("read", 0),
+ total_tokens=tokens_data.get("total", 0),
+ total_cost_usd=part.get("cost"),
+ )
+
+ return AgentResultMessage(
+ session_id=session_id,
+ is_error=False,
+ usage=usage,
+ original_message=data,
+ )
+
+ case "error":
+ error_msg = part.get("message", data.get("message", "unknown error"))
+ return AgentResultMessage(
+ session_id=session_id,
+ is_error=True,
+ error=error_msg,
+ usage=None,
+ original_message=data,
+ )
+
+ case _:
+ return AgentUnknownMessage(raw=data, original_message=data)
+
+
+def _parse_tool_use_part(part: dict[str, Any]) -> list[AgentContentBlock]:
+ call_id = part.get("callID", part.get("id", ""))
+ tool_name = part.get("tool", "")
+ state = part.get("state", {})
+ status = state.get("status", "")
+ tool_input = state.get("input", {})
+ tool_output = state.get("output", "")
+
+ if isinstance(tool_input, str):
+ tool_input = {"input": tool_input}
+
+ blocks: list[AgentContentBlock] = [
+ AgentToolUseBlock(
+ id=call_id,
+ name=tool_name,
+ input=tool_input,
+ )
+ ]
+
+ if status == "completed":
+ metadata = part.get("metadata", {}) or {}
+ exit_code = metadata.get("exit")
+ blocks.append(
+ AgentToolResultBlock(
+ tool_use_id=call_id,
+ content=tool_output,
+ is_error=exit_code is not None and exit_code != 0,
+ exit_code=exit_code,
+ )
+ )
+
+ return blocks
diff --git a/vet/imbue_core/agents/agent_api/opencode/message_parser_test.py b/vet/imbue_core/agents/agent_api/opencode/message_parser_test.py
@@ -0,0 +1,324 @@
+from vet.imbue_core.agents.agent_api.data_types import AgentAssistantMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentResultMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentSystemEventType
+from vet.imbue_core.agents.agent_api.data_types import AgentSystemMessage
+from vet.imbue_core.agents.agent_api.data_types import AgentTextBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentThinkingBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentToolResultBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentToolUseBlock
+from vet.imbue_core.agents.agent_api.data_types import AgentUnknownMessage
+from vet.imbue_core.agents.agent_api.opencode.message_parser import parse_opencode_event
+
+
+class TestParseStepStart:
+ def test_step_start_returns_system_message(self) -> None:
+ data = {
+ "type": "step_start",
+ "timestamp": 1773096529551,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_1",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "step-start",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentSystemMessage)
+ assert message.event_type == AgentSystemEventType.SESSION_STARTED
+ assert message.session_id == "ses_abc123"
+
+ def test_step_start_with_snapshot(self) -> None:
+ data = {
+ "type": "step_start",
+ "timestamp": 1773096529551,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_1",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "step-start",
+ "snapshot": "abc123hash",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentSystemMessage)
+ assert message.session_id == "ses_abc123"
+
+
+class TestParseText:
+ def test_text_returns_assistant_message(self) -> None:
+ data = {
+ "type": "text",
+ "timestamp": 1773096520559,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_2",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "text",
+ "text": "The answer is 4",
+ "time": {"start": 1773096520559, "end": 1773096520559},
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ assert len(message.content) == 1
+ assert isinstance(message.content[0], AgentTextBlock)
+ assert message.content[0].text == "The answer is 4"
+
+ def test_empty_text_returns_none(self) -> None:
+ data = {
+ "type": "text",
+ "timestamp": 1773096520559,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_2",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "text",
+ "text": "",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert message is None
+
+
+class TestParseToolUse:
+ def test_completed_tool_use_returns_use_and_result(self) -> None:
+ data = {
+ "type": "tool_use",
+ "timestamp": 1773096529615,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_3",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "tool",
+ "callID": "toolu_01TPK",
+ "tool": "bash",
+ "state": {
+ "status": "completed",
+ "input": {"command": "ls -la", "description": "List files"},
+ "output": "file1.txt\nfile2.txt",
+ },
+ "metadata": {"output": "file1.txt\nfile2.txt", "exit": 0},
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ assert len(message.content) == 2
+
+ tool_use = message.content[0]
+ assert isinstance(tool_use, AgentToolUseBlock)
+ assert tool_use.id == "toolu_01TPK"
+ assert tool_use.name == "bash"
+ assert tool_use.input == {"command": "ls -la", "description": "List files"}
+
+ tool_result = message.content[1]
+ assert isinstance(tool_result, AgentToolResultBlock)
+ assert tool_result.tool_use_id == "toolu_01TPK"
+ assert tool_result.content == "file1.txt\nfile2.txt"
+ assert tool_result.is_error is False
+ assert tool_result.exit_code == 0
+
+ def test_failed_tool_use_marks_error(self) -> None:
+ data = {
+ "type": "tool_use",
+ "timestamp": 1773096529615,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_3",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "tool",
+ "callID": "toolu_02XYZ",
+ "tool": "bash",
+ "state": {
+ "status": "completed",
+ "input": {"command": "false"},
+ "output": "",
+ },
+ "metadata": {"exit": 1},
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ tool_result = message.content[1]
+ assert isinstance(tool_result, AgentToolResultBlock)
+ assert tool_result.is_error is True
+ assert tool_result.exit_code == 1
+
+ def test_in_progress_tool_use_no_result(self) -> None:
+ data = {
+ "type": "tool_use",
+ "timestamp": 1773096529615,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_3",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "tool",
+ "callID": "toolu_03ABC",
+ "tool": "bash",
+ "state": {
+ "status": "pending",
+ "input": {"command": "sleep 10"},
+ "output": "",
+ },
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ assert len(message.content) == 1
+ assert isinstance(message.content[0], AgentToolUseBlock)
+
+ def test_tool_use_with_string_input(self) -> None:
+ data = {
+ "type": "tool_use",
+ "timestamp": 1773096529615,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_3",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "tool",
+ "callID": "toolu_04DEF",
+ "tool": "read",
+ "state": {
+ "status": "completed",
+ "input": "/path/to/file.py",
+ "output": "file contents",
+ },
+ "metadata": {},
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ tool_use = message.content[0]
+ assert isinstance(tool_use, AgentToolUseBlock)
+ assert tool_use.input == {"input": "/path/to/file.py"}
+
+
+class TestParseThinking:
+ def test_thinking_returns_thinking_block(self) -> None:
+ data = {
+ "type": "thinking",
+ "timestamp": 1773096520559,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_4",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "thinking",
+ "text": "Let me analyze this...",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentAssistantMessage)
+ assert len(message.content) == 1
+ assert isinstance(message.content[0], AgentThinkingBlock)
+ assert message.content[0].content == "Let me analyze this..."
+
+ def test_empty_thinking_returns_none(self) -> None:
+ data = {
+ "type": "thinking",
+ "timestamp": 1773096520559,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_4",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "thinking",
+ "text": "",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert message is None
+
+
+class TestParseStepFinish:
+ def test_stop_reason_returns_result_message(self) -> None:
+ data = {
+ "type": "step_finish",
+ "timestamp": 1773096520590,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_5",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "step-finish",
+ "reason": "stop",
+ "cost": 0.07321,
+ "tokens": {
+ "total": 11699,
+ "input": 2,
+ "output": 5,
+ "reasoning": 0,
+ "cache": {"read": 0, "write": 11692},
+ },
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentResultMessage)
+ assert message.session_id == "ses_abc123"
+ assert message.is_error is False
+ assert message.usage is not None
+ assert message.usage.input_tokens == 2
+ assert message.usage.output_tokens == 5
+ assert message.usage.total_tokens == 11699
+ assert message.usage.cached_tokens == 0
+ assert message.usage.total_cost_usd == 0.07321
+
+ def test_tool_calls_reason_returns_none(self) -> None:
+ data = {
+ "type": "step_finish",
+ "timestamp": 1773096520590,
+ "sessionID": "ses_abc123",
+ "part": {
+ "id": "prt_5",
+ "sessionID": "ses_abc123",
+ "messageID": "msg_1",
+ "type": "step-finish",
+ "reason": "tool-calls",
+ "cost": 0.07481625,
+ "tokens": {
+ "total": 11746,
+ "input": 2,
+ "output": 75,
+ "reasoning": 0,
+ "cache": {"read": 0, "write": 11669},
+ },
+ },
+ }
+ message = parse_opencode_event(data)
+ assert message is None
+
+
+class TestParseError:
+ def test_error_returns_error_result(self) -> None:
+ data = {
+ "type": "error",
+ "timestamp": 1773096520590,
+ "sessionID": "ses_abc123",
+ "part": {
+ "message": "Rate limit exceeded",
+ },
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentResultMessage)
+ assert message.is_error is True
+ assert message.error == "Rate limit exceeded"
+
+
+class TestParseUnknown:
+ def test_unknown_type_returns_unknown_message(self) -> None:
+ data = {
+ "type": "some_future_event",
+ "timestamp": 1773096520590,
+ "sessionID": "ses_abc123",
+ "part": {"foo": "bar"},
+ }
+ message = parse_opencode_event(data)
+ assert isinstance(message, AgentUnknownMessage)
+ assert message.raw == data
diff --git a/vet/imbue_core/data_types.py b/vet/imbue_core/data_types.py
@@ -170,6 +170,7 @@ class AgenticPhase(StrEnum):
class AgentHarnessType(StrEnum):
CLAUDE = "claude"
CODEX = "codex"
+ OPENCODE = "opencode"
class IssueIdentifierType(StrEnum):
diff --git a/vet/issue_identifiers/common.py b/vet/issue_identifiers/common.py
@@ -23,6 +23,7 @@ from vet.imbue_core.agents.agent_api.data_types import AgentToolName
from vet.imbue_core.agents.agent_api.data_types import READ_ONLY_TOOLS
from vet.imbue_core.agents.agent_api.errors import AgentCLINotFoundError
from vet.imbue_core.agents.agent_api.errors import AgentProcessError
+from vet.imbue_core.agents.agent_api.opencode.data_types import OpenCodeOptions
from vet.imbue_core.agents.llm_apis.anthropic_data_types import AnthropicCachingInfo
from vet.imbue_core.agents.llm_apis.data_types import CostedLanguageModelResponse
from vet.imbue_core.async_monkey_patches import log_exception
@@ -210,6 +211,11 @@ def get_agent_options(cwd: Path | None, model_name: str | None, agent_harness_ty
model=model_name,
sandbox_mode="read-only",
)
+ if agent_harness_type == AgentHarnessType.OPENCODE:
+ return OpenCodeOptions(
+ cwd=cwd,
+ model=model_name,
+ )
return ClaudeCodeOptions(
cwd=cwd,
permission_mode="dontAsk",