From 40df61c3208896287e94296ebfb897e9872ba495 Mon Sep 17 00:00:00 2001 From: cafitac Date: Fri, 1 May 2026 21:47:14 +0900 Subject: [PATCH] refactor: wrap Claude and Codex setup adapters --- .../pr-04-claude-codex-adapter-wrappers.md | 29 ++++ .../orchestrator-adapter-architecture.md | 2 + .dev/refactor/roadmap.md | 2 +- CHANGELOG.md | 1 + hermit_agent/orchestrators/__init__.py | 4 + hermit_agent/orchestrators/claude.py | 106 ++++++++++++++ hermit_agent/orchestrators/codex.py | 136 ++++++++++++++++++ tests/test_claude_orchestrator_adapter.py | 84 +++++++++++ tests/test_codex_orchestrator_adapter.py | 116 +++++++++++++++ 9 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 hermit_agent/orchestrators/claude.py create mode 100644 hermit_agent/orchestrators/codex.py create mode 100644 tests/test_claude_orchestrator_adapter.py create mode 100644 tests/test_codex_orchestrator_adapter.py diff --git a/.dev/refactor/hermes-executor-readiness/pr-04-claude-codex-adapter-wrappers.md b/.dev/refactor/hermes-executor-readiness/pr-04-claude-codex-adapter-wrappers.md index 69b394c..9f95837 100644 --- a/.dev/refactor/hermes-executor-readiness/pr-04-claude-codex-adapter-wrappers.md +++ b/.dev/refactor/hermes-executor-readiness/pr-04-claude-codex-adapter-wrappers.md @@ -144,6 +144,35 @@ Expected: ## Acceptance criteria +Status: implemented in PR #75. + +Implemented wrapper scope: +- `hermit_agent/orchestrators/claude.py` adds `ClaudeCodeMcpAdapter`. +- `hermit_agent/orchestrators/codex.py` adds `CodexAdapter`. +- `hermit_agent/orchestrators/__init__.py` exports both wrappers. +- `tests/test_claude_orchestrator_adapter.py` and `tests/test_codex_orchestrator_adapter.py` cover setup/health DTO mapping and explicit unsupported lifecycle methods. + +Status mapping implemented: +- Claude print-only returns `AdapterInstallStatus.PRINTED` from existing config snippet generation. +- Claude fix maps `ensure_claude_mcp_registered()` outcomes to `REGISTERED`, `UNCHANGED`, or `FAILED`. +- Claude health maps `is_claude_mcp_registered()` to `PASS` or `WARN`. +- Codex print-only returns `AdapterInstallStatus.SKIPPED` with actionable details. +- Codex fix delegates to `ensure_codex_channels_ready()`, `ensure_codex_marketplace_registered()`, `ensure_codex_mcp_registered()`, and `remove_codex_reply_hook()`, then maps outcomes to `REGISTERED`, `UNCHANGED`, or `FAILED`. +- Codex health maps `get_codex_runtime_version()` to `PASS` or `WARN`. + +Runtime note: +- This is wrapper-only. CLI dispatch, MCP server runtime, Claude channel notification behavior, codex-channels runtime delivery, and task lifecycle handling remain unchanged. + +Validation commands: + +```bash +.venv/bin/python -m pytest tests/test_claude_orchestrator_adapter.py tests/test_codex_orchestrator_adapter.py -q +.venv/bin/python -m pytest tests/test_claude_orchestrator_adapter.py tests/test_codex_orchestrator_adapter.py tests/test_hermes_orchestrator_adapter.py tests/test_orchestrator_contracts.py tests/test_codex_channels_adapter.py tests/test_codex_channels_mcp_wiring.py tests/test_mcp_server.py -q +.venv/bin/python -m pytest tests/ -q +``` + +Original acceptance criteria: + - Claude, Codex, and Hermes can all be described through setup/health adapter DTOs. - Runtime behavior remains unchanged. - Unsupported lifecycle methods fail explicitly. diff --git a/.dev/refactor/orchestrator-adapter-architecture.md b/.dev/refactor/orchestrator-adapter-architecture.md index b8cc610..91d48ba 100644 --- a/.dev/refactor/orchestrator-adapter-architecture.md +++ b/.dev/refactor/orchestrator-adapter-architecture.md @@ -125,6 +125,8 @@ DTOs should stay orchestrator-neutral: Current test anchor: - `tests/test_orchestrator_contracts.py` verifies immutable DTO shape, stable status/event enum values, and the structural lifecycle shape of `OrchestratorAdapter`. - `tests/test_hermes_orchestrator_adapter.py` verifies `HermesMcpAdapter` maps existing Hermes install, live-smoke, and doctor helpers into `AdapterInstallResult` / `AdapterHealth` without mutating behavior. +- `tests/test_claude_orchestrator_adapter.py` verifies `ClaudeCodeMcpAdapter` maps existing Claude MCP print/fix/registration-health helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior. +- `tests/test_codex_orchestrator_adapter.py` verifies `CodexAdapter` maps existing codex-channels, Codex marketplace, Codex MCP, and legacy reply-hook helpers into `AdapterInstallResult` / `AdapterHealth` without mutating runtime delivery behavior. - `tests/test_orchestrator_prompt_mapping.py` verifies current runtime prompts can round-trip through the neutral `InteractivePrompt` / `PromptReply` DTOs while preserving method/request/thread metadata. - `tests/test_orchestrator_event_mapping.py` verifies current Gateway SSE events, channel actions, and task status payloads can map into neutral `TaskEvent` DTOs without rewiring delivery behavior. diff --git a/.dev/refactor/roadmap.md b/.dev/refactor/roadmap.md index bac37d9..8c225a4 100644 --- a/.dev/refactor/roadmap.md +++ b/.dev/refactor/roadmap.md @@ -97,7 +97,7 @@ Acceptance: ### R2.2 Move orchestrator-specific code behind adapters -Status: first Hermes wrapper added as `HermesMcpAdapter`; it maps existing install/doctor/live-smoke helpers to adapter DTOs but does not yet change CLI dispatch or MCP task runtime paths. Prompt mapping helpers now round-trip current runtime `InteractivePrompt` values through the neutral `InteractivePrompt` / `PromptReply` DTOs. Task event mapping helpers now convert current Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs as another behavior-preserving extraction step. +Status: first Hermes wrapper added as `HermesMcpAdapter`; it maps existing install/doctor/live-smoke helpers to adapter DTOs but does not yet change CLI dispatch or MCP task runtime paths. Claude Code and Codex now also have thin setup/health wrappers (`ClaudeCodeMcpAdapter`, `CodexAdapter`) that map existing registration/runtime helpers into neutral `AdapterInstallResult` / `AdapterHealth` DTOs without rewiring runtime delivery. Prompt mapping helpers now round-trip current runtime `InteractivePrompt` values through the neutral `InteractivePrompt` / `PromptReply` DTOs. Task event mapping helpers now convert current Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs as another behavior-preserving extraction step. Likely areas: - `hermit_agent/mcp_channel.py` diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d84d92..70b9349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ - Added a thin `HermesMcpAdapter` wrapper that maps existing Hermes print/fix/live-smoke and doctor helpers onto the shared adapter DTOs without rewiring current CLI or MCP runtime behavior. - Added adapter prompt mapping helpers that round-trip the current runtime `InteractivePrompt` shape through the neutral `InteractivePrompt` / `PromptReply` DTOs without changing interactive delivery behavior. - Added pure task event mapping helpers that convert existing Gateway SSE events, channel actions, and task status payloads into neutral `TaskEvent` DTOs without changing MCP/channel delivery behavior. +- Added thin Claude Code and Codex adapter wrappers that map existing setup and health helpers onto the shared adapter DTOs without changing the current MCP/channel runtime delivery paths. - Started tracking the long-term refactor roadmap under `.dev/refactor/**` while leaving other `.dev/*` scratch files ignored. - Removed warning-producing test mocks for setup coroutines so the suite no longer emits unawaited `init_db` / `create_api_key` runtime warnings. - Aligned contributor and harness docs on `.venv/bin/python -m pytest tests/` and added a repo hygiene regression so stale `.venv/bin/pytest` shebangs are not reintroduced as the documented path. diff --git a/hermit_agent/orchestrators/__init__.py b/hermit_agent/orchestrators/__init__.py index 5065c5f..2a000cb 100644 --- a/hermit_agent/orchestrators/__init__.py +++ b/hermit_agent/orchestrators/__init__.py @@ -1,5 +1,7 @@ """Orchestrator adapter contracts for Hermit executor integrations.""" +from .claude import ClaudeCodeMcpAdapter +from .codex import CodexAdapter from .contracts import ( AdapterHealth, AdapterHealthStatus, @@ -30,6 +32,8 @@ "AdapterHealthStatus", "AdapterInstallResult", "AdapterInstallStatus", + "ClaudeCodeMcpAdapter", + "CodexAdapter", "HermesMcpAdapter", "InteractivePrompt", "OrchestratorAdapter", diff --git a/hermit_agent/orchestrators/claude.py b/hermit_agent/orchestrators/claude.py new file mode 100644 index 0000000..2a3b13e --- /dev/null +++ b/hermit_agent/orchestrators/claude.py @@ -0,0 +1,106 @@ +"""Claude Code adapter wrappers around existing install helpers. + +This module is intentionally thin: it maps Claude MCP setup/health helpers +into orchestrator-neutral DTOs without rewiring the MCP runtime path. +""" + +from __future__ import annotations + +from .contracts import ( + AdapterHealth, + AdapterHealthStatus, + AdapterInstallResult, + AdapterInstallStatus, + InteractivePrompt, + PromptReply, + TaskEvent, + TaskHandle, + TaskRequest, +) +from ..install_flow import inspect_claude_mcp_registration, register_claude_mcp, resolve_hermit_mcp_stdio_entry + + +class ClaudeCodeMcpAdapter: + """Adapter DTO wrapper for Claude Code's Hermit MCP integration.""" + + name = "claude-code" + + def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult: + entry = resolve_hermit_mcp_stdio_entry(cwd=cwd) + command = str(entry["command"]) + args = [str(arg) for arg in entry.get("args", [])] if isinstance(entry.get("args"), list) else [] + if not fix: + details = ( + "print-only Claude Code MCP registration instructions", + "Add `hermit-channel` under ~/.claude.json mcpServers.", + f"transport: {command} {' '.join(args)}".strip(), + ) + return AdapterInstallResult( + name=self.name, + status=AdapterInstallStatus.PRINTED, + message="print-only Claude Code MCP registration instructions", + details=details, + changed=False, + ) + + try: + status, path, backup = register_claude_mcp(entry=entry) + except Exception as exc: + return AdapterInstallResult( + name=self.name, + status=AdapterInstallStatus.FAILED, + message=f"failed ({exc})", + changed=False, + ) + return _install_result_from_status(status, path=path, backup=backup) + + def health(self, *, cwd: str) -> AdapterHealth: + status = inspect_claude_mcp_registration(entry=resolve_hermit_mcp_stdio_entry(cwd=cwd)) + return _health_from_registration_status(status) + + def submit_task(self, request: TaskRequest) -> TaskHandle: + raise NotImplementedError("Claude Code task submission still uses the MCP server path directly") + + def emit_event(self, task_id: str, event: TaskEvent) -> None: + raise NotImplementedError("Claude Code event delivery still uses the MCP server path directly") + + def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: + raise NotImplementedError("Claude Code reply delivery still uses the MCP server path directly") + + def cancel(self, task_id: str) -> None: + raise NotImplementedError("Claude Code cancellation still uses the MCP server path directly") + + +def _install_result_from_status(status: str, *, path: object, backup: object | None) -> AdapterInstallResult: + if status == "registered": + install_status = AdapterInstallStatus.REGISTERED + changed = True + elif status == "unchanged": + install_status = AdapterInstallStatus.UNCHANGED + changed = False + else: + install_status = AdapterInstallStatus.FAILED + changed = False + details = [f"path: {path}"] + if backup is not None: + details.append(f"backup: {backup}") + return AdapterInstallResult( + name=ClaudeCodeMcpAdapter.name, + status=install_status, + message=status, + details=tuple(details), + changed=changed, + ) + + +def _health_from_registration_status(status: str) -> AdapterHealth: + if status == "registered": + health_status = AdapterHealthStatus.PASS + message = "hermit-channel registered for Claude Code" + elif status == "invalid": + health_status = AdapterHealthStatus.FAIL + message = "Claude MCP registration invalid" + else: + health_status = AdapterHealthStatus.WARN + message = f"Claude MCP registration {status}" + return AdapterHealth(name=ClaudeCodeMcpAdapter.name, status=health_status, message=message) diff --git a/hermit_agent/orchestrators/codex.py b/hermit_agent/orchestrators/codex.py new file mode 100644 index 0000000..a98ec00 --- /dev/null +++ b/hermit_agent/orchestrators/codex.py @@ -0,0 +1,136 @@ +"""Codex adapter wrappers around existing install helpers. + +This module maps Codex integration setup/health helpers into the neutral adapter +DTO layer without changing codex-channels, MCP, or runtime delivery behavior. +""" + +from __future__ import annotations + +from .contracts import ( + AdapterHealth, + AdapterHealthStatus, + AdapterInstallResult, + AdapterInstallStatus, + InteractivePrompt, + PromptReply, + TaskEvent, + TaskHandle, + TaskRequest, +) +from ..install_flow import ( + ensure_codex_channels_ready, + ensure_codex_marketplace_registered, + ensure_codex_mcp_registered, + get_codex_runtime_version, + remove_codex_reply_hook, +) + + +class CodexAdapter: + """Adapter DTO wrapper for Hermit's Codex integration setup surface.""" + + name = "codex" + + def __init__(self, *, codex_command: str = "codex", scope: str = "user") -> None: + self.codex_command = codex_command + self.scope = scope + + def install_or_print_instructions(self, *, cwd: str, fix: bool) -> AdapterInstallResult: + if not fix: + return AdapterInstallResult( + name=self.name, + status=AdapterInstallStatus.SKIPPED, + message="Codex setup not changed from print-only adapter path", + details=( + "Use fix=True to install/refresh codex-channels and Codex MCP registration.", + "Existing setup path registers Codex marketplace and codex mcp hermit-channel.", + ), + changed=False, + ) + + try: + runtime_status, runtime_details, runtime_version = ensure_codex_channels_ready( + cwd=cwd, + codex_command=self.codex_command, + scope=self.scope, + ) + marketplace_status = ensure_codex_marketplace_registered( + cwd=cwd, + codex_command=self.codex_command, + scope=self.scope, + ) + mcp_status = ensure_codex_mcp_registered(cwd=cwd, codex_command=self.codex_command) + hook_status = remove_codex_reply_hook(cwd=cwd) + except Exception as exc: + return AdapterInstallResult( + name=self.name, + status=AdapterInstallStatus.FAILED, + message=f"failed ({exc})", + changed=False, + ) + + details: list[str] = [] + if runtime_version: + details.append(f"runtime version: {runtime_version}") + details.extend( + [ + f"marketplace: {marketplace_status}", + f"mcp registration: {mcp_status}", + f"legacy reply hook: {hook_status}", + ] + ) + details.extend(runtime_details) + return AdapterInstallResult( + name=self.name, + status=_install_status_from_codex_statuses(runtime_status, marketplace_status, mcp_status, hook_status), + message=runtime_status, + details=tuple(details), + changed=_codex_statuses_changed(runtime_status, marketplace_status, mcp_status, hook_status), + ) + + def health(self, *, cwd: str) -> AdapterHealth: + version = get_codex_runtime_version(cwd=cwd) + if version: + return AdapterHealth( + name=self.name, + status=AdapterHealthStatus.PASS, + message="codex-channels runtime installed", + details=(f"runtime version: {version}",), + ) + return AdapterHealth( + name=self.name, + status=AdapterHealthStatus.WARN, + message="codex-channels runtime missing — run `hermit install` or use fix=True adapter setup", + ) + + def submit_task(self, request: TaskRequest) -> TaskHandle: + raise NotImplementedError("Codex task submission still uses the existing Codex channel/MCP runtime paths") + + def emit_event(self, task_id: str, event: TaskEvent) -> None: + raise NotImplementedError("Codex event delivery still uses the existing Codex channel/MCP runtime paths") + + def wait_for_reply(self, task_id: str, prompt: InteractivePrompt) -> PromptReply | None: + raise NotImplementedError("Codex reply delivery still uses the existing Codex channel/MCP runtime paths") + + def cancel(self, task_id: str) -> None: + raise NotImplementedError("Codex cancellation still uses the existing Codex channel/MCP runtime paths") + + +def _codex_statuses_changed(runtime_status: str, marketplace_status: str, mcp_status: str, hook_status: str) -> bool: + return any( + status in {"installed", "registered", "removed"} + for status in (runtime_status, marketplace_status, mcp_status, hook_status) + ) + + +def _install_status_from_codex_statuses( + runtime_status: str, + marketplace_status: str, + mcp_status: str, + hook_status: str, +) -> AdapterInstallStatus: + if any(status.startswith("failed") for status in (runtime_status, marketplace_status, mcp_status, hook_status)): + return AdapterInstallStatus.FAILED + if _codex_statuses_changed(runtime_status, marketplace_status, mcp_status, hook_status): + return AdapterInstallStatus.REGISTERED + return AdapterInstallStatus.UNCHANGED diff --git a/tests/test_claude_orchestrator_adapter.py b/tests/test_claude_orchestrator_adapter.py new file mode 100644 index 0000000..861b87c --- /dev/null +++ b/tests/test_claude_orchestrator_adapter.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import pytest + +from hermit_agent.orchestrators import ( + AdapterHealthStatus, + AdapterInstallStatus, + ClaudeCodeMcpAdapter, + InteractivePrompt, + TaskEvent, + TaskEventKind, + TaskRequest, +) + + +def test_claude_adapter_print_only_returns_instructions_without_registering(monkeypatch): + calls: list[object] = [] + + def fail_register(**kwargs): + calls.append(kwargs) + raise AssertionError("print-only path must not register Claude MCP") + + monkeypatch.setattr("hermit_agent.orchestrators.claude.register_claude_mcp", fail_register) + + result = ClaudeCodeMcpAdapter().install_or_print_instructions(cwd="/repo", fix=False) + + assert result.name == "claude-code" + assert result.status == AdapterInstallStatus.PRINTED + assert result.changed is False + assert any("hermit-channel" in detail for detail in result.details) + assert any("hermit mcp-server" in detail for detail in result.details) + assert calls == [] + + +def test_claude_adapter_fix_maps_registration_statuses(monkeypatch): + monkeypatch.setattr( + "hermit_agent.orchestrators.claude.register_claude_mcp", + lambda **kwargs: ("registered", "/tmp/.claude.json", "/tmp/.claude.json.backup"), + ) + + registered = ClaudeCodeMcpAdapter().install_or_print_instructions(cwd="/repo", fix=True) + + assert registered.status == AdapterInstallStatus.REGISTERED + assert registered.changed is True + assert registered.details == ("path: /tmp/.claude.json", "backup: /tmp/.claude.json.backup") + + monkeypatch.setattr( + "hermit_agent.orchestrators.claude.register_claude_mcp", + lambda **kwargs: ("unchanged", "/tmp/.claude.json", None), + ) + + unchanged = ClaudeCodeMcpAdapter().install_or_print_instructions(cwd="/repo", fix=True) + + assert unchanged.status == AdapterInstallStatus.UNCHANGED + assert unchanged.changed is False + assert unchanged.details == ("path: /tmp/.claude.json",) + + +def test_claude_adapter_health_maps_registration_status(monkeypatch): + monkeypatch.setattr("hermit_agent.orchestrators.claude.inspect_claude_mcp_registration", lambda **kwargs: "registered") + assert ClaudeCodeMcpAdapter().health(cwd="/repo").status == AdapterHealthStatus.PASS + + monkeypatch.setattr("hermit_agent.orchestrators.claude.inspect_claude_mcp_registration", lambda **kwargs: "missing") + missing = ClaudeCodeMcpAdapter().health(cwd="/repo") + assert missing.status == AdapterHealthStatus.WARN + assert "missing" in missing.message + + monkeypatch.setattr("hermit_agent.orchestrators.claude.inspect_claude_mcp_registration", lambda **kwargs: "invalid") + invalid = ClaudeCodeMcpAdapter().health(cwd="/repo") + assert invalid.status == AdapterHealthStatus.FAIL + assert "invalid" in invalid.message + + +def test_claude_adapter_lifecycle_methods_are_explicitly_unsupported(): + adapter = ClaudeCodeMcpAdapter() + + with pytest.raises(NotImplementedError, match="MCP server path"): + adapter.submit_task(TaskRequest(task="do it", cwd="/repo")) + with pytest.raises(NotImplementedError, match="MCP server path"): + adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING)) + with pytest.raises(NotImplementedError, match="MCP server path"): + adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?")) + with pytest.raises(NotImplementedError, match="MCP server path"): + adapter.cancel("task-1") diff --git a/tests/test_codex_orchestrator_adapter.py b/tests/test_codex_orchestrator_adapter.py new file mode 100644 index 0000000..0ed5201 --- /dev/null +++ b/tests/test_codex_orchestrator_adapter.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import pytest + +from hermit_agent.orchestrators import ( + AdapterHealthStatus, + AdapterInstallStatus, + CodexAdapter, + InteractivePrompt, + TaskEvent, + TaskEventKind, + TaskRequest, +) + + +def test_codex_adapter_print_only_returns_actionable_skipped_result_without_installing(monkeypatch): + calls: list[object] = [] + + def fail_install(**kwargs): + calls.append(kwargs) + raise AssertionError("print-only path must not install Codex integration") + + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_channels_ready", fail_install) + + result = CodexAdapter().install_or_print_instructions(cwd="/repo", fix=False) + + assert result.name == "codex" + assert result.status == AdapterInstallStatus.SKIPPED + assert result.changed is False + assert any("fix=True" in detail for detail in result.details) + assert any("codex mcp" in detail.casefold() for detail in result.details) + assert calls == [] + + +def test_codex_adapter_fix_maps_install_and_registration_statuses(monkeypatch): + calls: list[tuple[str, dict[str, object]]] = [] + + def fake_channels(**kwargs): + calls.append(("channels", kwargs)) + return "installed", ["runtime dir: /tmp/codex", "settings updated: /tmp/settings.json"], "1.2.3" + + def fake_marketplace(**kwargs): + calls.append(("marketplace", kwargs)) + return "registered" + + def fake_mcp(**kwargs): + calls.append(("mcp", kwargs)) + return "unchanged" + + def fake_hook(**kwargs): + calls.append(("hook", kwargs)) + return "absent" + + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_channels_ready", fake_channels) + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_marketplace_registered", fake_marketplace) + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_mcp_registered", fake_mcp) + monkeypatch.setattr("hermit_agent.orchestrators.codex.remove_codex_reply_hook", fake_hook) + + result = CodexAdapter(codex_command="codex-dev", scope="workspace").install_or_print_instructions(cwd="/repo", fix=True) + + assert result.status == AdapterInstallStatus.REGISTERED + assert result.changed is True + assert result.message == "installed" + assert result.details == ( + "runtime version: 1.2.3", + "marketplace: registered", + "mcp registration: unchanged", + "legacy reply hook: absent", + "runtime dir: /tmp/codex", + "settings updated: /tmp/settings.json", + ) + assert calls[0] == ("channels", {"cwd": "/repo", "codex_command": "codex-dev", "scope": "workspace"}) + assert calls[1] == ("marketplace", {"cwd": "/repo", "codex_command": "codex-dev", "scope": "workspace"}) + assert calls[2] == ("mcp", {"cwd": "/repo", "codex_command": "codex-dev"}) + assert calls[3] == ("hook", {"cwd": "/repo"}) + + +def test_codex_adapter_fix_maps_healthy_runtime_to_unchanged(monkeypatch): + monkeypatch.setattr( + "hermit_agent.orchestrators.codex.ensure_codex_channels_ready", + lambda **kwargs: ("healthy", ["runtime version: 1.2.3"], "1.2.3"), + ) + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_marketplace_registered", lambda **kwargs: "unchanged") + monkeypatch.setattr("hermit_agent.orchestrators.codex.ensure_codex_mcp_registered", lambda **kwargs: "unchanged") + monkeypatch.setattr("hermit_agent.orchestrators.codex.remove_codex_reply_hook", lambda **kwargs: "absent") + + result = CodexAdapter().install_or_print_instructions(cwd="/repo", fix=True) + + assert result.status == AdapterInstallStatus.UNCHANGED + assert result.changed is False + + +def test_codex_adapter_health_uses_runtime_version(monkeypatch): + monkeypatch.setattr("hermit_agent.orchestrators.codex.get_codex_runtime_version", lambda **kwargs: "1.2.3") + healthy = CodexAdapter().health(cwd="/repo") + assert healthy.status == AdapterHealthStatus.PASS + assert healthy.message == "codex-channels runtime installed" + assert healthy.details == ("runtime version: 1.2.3",) + + monkeypatch.setattr("hermit_agent.orchestrators.codex.get_codex_runtime_version", lambda **kwargs: None) + missing = CodexAdapter().health(cwd="/repo") + assert missing.status == AdapterHealthStatus.WARN + assert "missing" in missing.message + + +def test_codex_adapter_lifecycle_methods_are_explicitly_unsupported(): + adapter = CodexAdapter() + + with pytest.raises(NotImplementedError, match="existing Codex channel"): + adapter.submit_task(TaskRequest(task="do it", cwd="/repo")) + with pytest.raises(NotImplementedError, match="existing Codex channel"): + adapter.emit_event("task-1", TaskEvent(task_id="task-1", kind=TaskEventKind.RUNNING)) + with pytest.raises(NotImplementedError, match="existing Codex channel"): + adapter.wait_for_reply("task-1", InteractivePrompt(task_id="task-1", question="Continue?")) + with pytest.raises(NotImplementedError, match="existing Codex channel"): + adapter.cancel("task-1")