Introduce explicit repro-fix, inspect, cold-start, and review-eval modes across the MCP server, CLI, and host helpers, with canonical mode-to-tool mappings, narrowed schemas, and mode-specific tool descriptions on top of the existing workspace runtime. Reposition the docs, host onramps, and use-case recipes so named modes are the primary user-facing startup story while the generic no-mode workspace-core path remains the escape hatch, and update the shared smoke runner to validate repro-fix and cold-start through mode-backed servers. Validation: UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache uv run pytest --no-cov tests/test_api.py tests/test_server.py tests/test_host_helpers.py tests/test_public_contract.py tests/test_cli.py tests/test_workspace_use_case_smokes.py; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check; UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check; real guest-backed make smoke-repro-fix-loop smoke-cold-start-validation outside the sandbox.
501 lines
16 KiB
Python
501 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
from subprocess import CompletedProcess
|
|
|
|
import pytest
|
|
|
|
import pyro_mcp.host_helpers as host_helpers
|
|
from pyro_mcp.host_helpers import (
|
|
DEFAULT_OPENCODE_CONFIG_PATH,
|
|
HostServerConfig,
|
|
_canonical_server_command,
|
|
_command_matches,
|
|
_repair_command,
|
|
connect_cli_host,
|
|
doctor_hosts,
|
|
print_or_write_opencode_config,
|
|
repair_host,
|
|
)
|
|
|
|
|
|
def _install_fake_mcp_cli(tmp_path: Path, name: str) -> tuple[Path, Path]:
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir(parents=True)
|
|
state_path = tmp_path / f"{name}-state.json"
|
|
script_path = bin_dir / name
|
|
script_path.write_text(
|
|
"\n".join(
|
|
[
|
|
f"#!{sys.executable}",
|
|
"import json",
|
|
"import shlex",
|
|
"import sys",
|
|
f"STATE_PATH = {str(state_path)!r}",
|
|
"try:",
|
|
" with open(STATE_PATH, 'r', encoding='utf-8') as handle:",
|
|
" state = json.load(handle)",
|
|
"except FileNotFoundError:",
|
|
" state = {}",
|
|
"args = sys.argv[1:]",
|
|
"if args[:2] == ['mcp', 'add']:",
|
|
" name = args[2]",
|
|
" marker = args.index('--')",
|
|
" state[name] = args[marker + 1:]",
|
|
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
|
" json.dump(state, handle)",
|
|
" print(f'added {name}')",
|
|
" raise SystemExit(0)",
|
|
"if args[:2] == ['mcp', 'remove']:",
|
|
" name = args[2]",
|
|
" if name in state:",
|
|
" del state[name]",
|
|
" with open(STATE_PATH, 'w', encoding='utf-8') as handle:",
|
|
" json.dump(state, handle)",
|
|
" print(f'removed {name}')",
|
|
" raise SystemExit(0)",
|
|
" print('not found', file=sys.stderr)",
|
|
" raise SystemExit(1)",
|
|
"if args[:2] == ['mcp', 'get']:",
|
|
" name = args[2]",
|
|
" if name not in state:",
|
|
" print('not found', file=sys.stderr)",
|
|
" raise SystemExit(1)",
|
|
" print(f'{name}: {shlex.join(state[name])}')",
|
|
" raise SystemExit(0)",
|
|
"if args[:2] == ['mcp', 'list']:",
|
|
" for item in sorted(state):",
|
|
" print(item)",
|
|
" raise SystemExit(0)",
|
|
"print('unsupported', file=sys.stderr)",
|
|
"raise SystemExit(2)",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
script_path.chmod(0o755)
|
|
return bin_dir, state_path
|
|
|
|
|
|
def test_connect_cli_host_replaces_existing_entry(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
bin_dir, state_path = _install_fake_mcp_cli(tmp_path, "codex")
|
|
state_path.write_text(json.dumps({"pyro": ["old", "command"]}), encoding="utf-8")
|
|
monkeypatch.setenv("PATH", str(bin_dir))
|
|
|
|
payload = connect_cli_host("codex", config=HostServerConfig())
|
|
|
|
assert payload["host"] == "codex"
|
|
assert payload["server_command"] == ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
|
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
|
"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]
|
|
}
|
|
|
|
|
|
def test_canonical_server_command_validates_and_renders_variants() -> None:
|
|
assert _canonical_server_command(HostServerConfig(installed_package=True)) == [
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
]
|
|
assert _canonical_server_command(
|
|
HostServerConfig(profile="workspace-full", project_path="/repo")
|
|
) == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--profile",
|
|
"workspace-full",
|
|
"--project-path",
|
|
"/repo",
|
|
]
|
|
assert _canonical_server_command(
|
|
HostServerConfig(repo_url="https://example.com/repo.git", repo_ref="main")
|
|
) == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--repo-url",
|
|
"https://example.com/repo.git",
|
|
"--repo-ref",
|
|
"main",
|
|
]
|
|
assert _canonical_server_command(HostServerConfig(mode="repro-fix")) == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--mode",
|
|
"repro-fix",
|
|
]
|
|
assert _canonical_server_command(HostServerConfig(no_project_source=True)) == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--no-project-source",
|
|
]
|
|
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
_canonical_server_command(
|
|
HostServerConfig(project_path="/repo", repo_url="https://example.com/repo.git")
|
|
)
|
|
with pytest.raises(ValueError, match="cannot be combined"):
|
|
_canonical_server_command(HostServerConfig(project_path="/repo", no_project_source=True))
|
|
with pytest.raises(ValueError, match="requires --repo-url"):
|
|
_canonical_server_command(HostServerConfig(repo_ref="main"))
|
|
with pytest.raises(ValueError, match="mutually exclusive"):
|
|
_canonical_server_command(
|
|
HostServerConfig(profile="workspace-full", mode="repro-fix")
|
|
)
|
|
|
|
|
|
def test_repair_command_and_command_matches_cover_edge_cases() -> None:
|
|
assert _repair_command("codex", HostServerConfig()) == "pyro host repair codex"
|
|
assert _repair_command("codex", HostServerConfig(project_path="/repo")) == (
|
|
"pyro host repair codex --project-path /repo"
|
|
)
|
|
assert _repair_command(
|
|
"opencode",
|
|
HostServerConfig(installed_package=True, profile="workspace-full", repo_url="file:///repo"),
|
|
config_path=Path("/tmp/opencode.json"),
|
|
) == (
|
|
"pyro host repair opencode --installed-package --profile workspace-full "
|
|
"--repo-url file:///repo --config-path /tmp/opencode.json"
|
|
)
|
|
assert _repair_command("codex", HostServerConfig(no_project_source=True)) == (
|
|
"pyro host repair codex --no-project-source"
|
|
)
|
|
assert _repair_command("codex", HostServerConfig(mode="inspect")) == (
|
|
"pyro host repair codex --mode inspect"
|
|
)
|
|
assert _command_matches(
|
|
"pyro: uvx --from pyro-mcp pyro mcp serve",
|
|
["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
|
)
|
|
assert _command_matches(
|
|
'"uvx --from pyro-mcp pyro mcp serve',
|
|
['"uvx', "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
|
)
|
|
assert not _command_matches("pyro: uvx --from pyro-mcp pyro mcp serve --profile vm-run", [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
])
|
|
|
|
|
|
def test_connect_cli_host_reports_missing_cli_and_add_failure(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
with pytest.raises(ValueError, match="unsupported CLI host"):
|
|
connect_cli_host("unsupported", config=HostServerConfig())
|
|
|
|
monkeypatch.setenv("PATH", "")
|
|
with pytest.raises(RuntimeError, match="codex CLI is not installed"):
|
|
connect_cli_host("codex", config=HostServerConfig())
|
|
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
script_path = bin_dir / "codex"
|
|
script_path.write_text(
|
|
"\n".join(
|
|
[
|
|
f"#!{sys.executable}",
|
|
"import sys",
|
|
"raise SystemExit(1 if sys.argv[1:3] == ['mcp', 'add'] else 0)",
|
|
]
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
script_path.chmod(0o755)
|
|
monkeypatch.setenv("PATH", str(bin_dir))
|
|
|
|
with pytest.raises(RuntimeError, match="codex mcp add failed"):
|
|
connect_cli_host("codex", config=HostServerConfig())
|
|
|
|
|
|
def test_doctor_hosts_reports_ok_and_drifted(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
codex_bin, codex_state = _install_fake_mcp_cli(tmp_path / "codex", "codex")
|
|
claude_bin, claude_state = _install_fake_mcp_cli(tmp_path / "claude", "claude")
|
|
combined_path = str(codex_bin) + ":" + str(claude_bin)
|
|
monkeypatch.setenv("PATH", combined_path)
|
|
|
|
codex_state.write_text(
|
|
json.dumps({"pyro": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"]}),
|
|
encoding="utf-8",
|
|
)
|
|
claude_state.write_text(
|
|
json.dumps(
|
|
{
|
|
"pyro": [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--profile",
|
|
"workspace-full",
|
|
]
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
opencode_config = tmp_path / "opencode.json"
|
|
opencode_config.write_text(
|
|
json.dumps(
|
|
{
|
|
"mcp": {
|
|
"pyro": {
|
|
"type": "local",
|
|
"enabled": True,
|
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
|
}
|
|
}
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
entries = doctor_hosts(config=HostServerConfig(), config_path=opencode_config)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
|
|
assert by_host["codex"].status == "ok"
|
|
assert by_host["codex"].configured is True
|
|
assert by_host["claude-code"].status == "drifted"
|
|
assert by_host["claude-code"].configured is True
|
|
assert by_host["opencode"].status == "ok"
|
|
assert by_host["opencode"].configured is True
|
|
|
|
|
|
def test_doctor_hosts_reports_missing_and_drifted_opencode_shapes(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("PATH", "")
|
|
config_path = tmp_path / "opencode.json"
|
|
|
|
config_path.write_text("[]", encoding="utf-8")
|
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
assert by_host["opencode"].status == "unavailable"
|
|
assert "JSON object" in by_host["opencode"].details
|
|
|
|
config_path.write_text(json.dumps({"mcp": {}}), encoding="utf-8")
|
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
assert by_host["opencode"].status == "unavailable"
|
|
assert "missing mcp.pyro" in by_host["opencode"].details
|
|
|
|
config_path.write_text(
|
|
json.dumps({"mcp": {"pyro": {"type": "local", "enabled": False, "command": ["wrong"]}}}),
|
|
encoding="utf-8",
|
|
)
|
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
assert by_host["opencode"].status == "drifted"
|
|
assert by_host["opencode"].configured is True
|
|
|
|
|
|
def test_doctor_hosts_reports_invalid_json_for_installed_opencode(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
config_path = tmp_path / "opencode.json"
|
|
config_path.write_text("{invalid", encoding="utf-8")
|
|
monkeypatch.setattr(
|
|
shutil,
|
|
"which",
|
|
lambda name: "/usr/bin/opencode" if name == "opencode" else None,
|
|
)
|
|
|
|
entries = doctor_hosts(config=HostServerConfig(), config_path=config_path)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
|
|
assert by_host["opencode"].status == "drifted"
|
|
assert "invalid JSON" in by_host["opencode"].details
|
|
|
|
|
|
def test_repair_opencode_preserves_unrelated_keys(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("PATH", "")
|
|
config_path = tmp_path / "opencode.json"
|
|
config_path.write_text(
|
|
json.dumps({"theme": "light", "mcp": {"other": {"type": "local"}}}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
|
|
|
assert payload["config_path"] == str(config_path.resolve())
|
|
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert repaired["theme"] == "light"
|
|
assert repaired["mcp"]["other"] == {"type": "local"}
|
|
assert repaired["mcp"]["pyro"] == {
|
|
"type": "local",
|
|
"enabled": True,
|
|
"command": ["uvx", "--from", "pyro-mcp", "pyro", "mcp", "serve"],
|
|
}
|
|
|
|
|
|
def test_repair_opencode_backs_up_non_object_json(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("PATH", "")
|
|
config_path = tmp_path / "opencode.json"
|
|
config_path.write_text("[]", encoding="utf-8")
|
|
|
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
|
|
|
backup_path = Path(str(payload["backup_path"]))
|
|
assert backup_path.exists()
|
|
assert backup_path.read_text(encoding="utf-8") == "[]"
|
|
|
|
|
|
def test_repair_opencode_backs_up_invalid_json(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("PATH", "")
|
|
config_path = tmp_path / "opencode.json"
|
|
config_path.write_text("{invalid", encoding="utf-8")
|
|
|
|
payload = repair_host("opencode", config=HostServerConfig(), config_path=config_path)
|
|
|
|
backup_path = Path(str(payload["backup_path"]))
|
|
assert backup_path.exists()
|
|
assert backup_path.read_text(encoding="utf-8") == "{invalid"
|
|
repaired = json.loads(config_path.read_text(encoding="utf-8"))
|
|
assert repaired["mcp"]["pyro"]["command"] == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
]
|
|
|
|
|
|
def test_print_or_write_opencode_config_writes_json(tmp_path: Path) -> None:
|
|
output_path = tmp_path / "opencode.json"
|
|
payload = print_or_write_opencode_config(
|
|
config=HostServerConfig(project_path="/repo"),
|
|
output_path=output_path,
|
|
)
|
|
|
|
assert payload["output_path"] == str(output_path)
|
|
rendered = json.loads(output_path.read_text(encoding="utf-8"))
|
|
assert rendered == {
|
|
"mcp": {
|
|
"pyro": {
|
|
"type": "local",
|
|
"enabled": True,
|
|
"command": [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--project-path",
|
|
"/repo",
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
def test_print_or_write_opencode_config_returns_rendered_text() -> None:
|
|
payload = print_or_write_opencode_config(config=HostServerConfig(profile="vm-run"))
|
|
|
|
assert payload["host"] == "opencode"
|
|
assert payload["server_command"] == [
|
|
"uvx",
|
|
"--from",
|
|
"pyro-mcp",
|
|
"pyro",
|
|
"mcp",
|
|
"serve",
|
|
"--profile",
|
|
"vm-run",
|
|
]
|
|
rendered = str(payload["rendered_config"])
|
|
assert '"type": "local"' in rendered
|
|
assert '"command": [' in rendered
|
|
|
|
|
|
def test_doctor_reports_opencode_missing_when_config_absent(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
monkeypatch.setenv("PATH", "")
|
|
|
|
entries = doctor_hosts(
|
|
config=HostServerConfig(),
|
|
config_path=tmp_path / "missing-opencode.json",
|
|
)
|
|
by_host = {entry.host: entry for entry in entries}
|
|
|
|
assert by_host["opencode"].status == "unavailable"
|
|
assert str(DEFAULT_OPENCODE_CONFIG_PATH) not in by_host["opencode"].details
|
|
|
|
|
|
def test_repair_host_delegates_non_opencode_and_doctor_handles_list_only_configured(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
host_helpers,
|
|
"connect_cli_host",
|
|
lambda host, *, config: {"host": host, "profile": config.profile},
|
|
)
|
|
assert repair_host("codex", config=HostServerConfig(profile="vm-run")) == {
|
|
"host": "codex",
|
|
"profile": "vm-run",
|
|
}
|
|
|
|
commands: list[list[str]] = []
|
|
|
|
def _fake_run_command(command: list[str]) -> CompletedProcess[str]:
|
|
commands.append(command)
|
|
if command[:3] == ["codex", "mcp", "get"]:
|
|
return CompletedProcess(command, 1, "", "not found")
|
|
if command[:3] == ["codex", "mcp", "list"]:
|
|
return CompletedProcess(command, 0, "pyro\n", "")
|
|
raise AssertionError(command)
|
|
|
|
monkeypatch.setattr(
|
|
shutil,
|
|
"which",
|
|
lambda name: "/usr/bin/codex" if name == "codex" else None,
|
|
)
|
|
monkeypatch.setattr(host_helpers, "_run_command", _fake_run_command)
|
|
|
|
entry = host_helpers._doctor_cli_host("codex", config=HostServerConfig())
|
|
assert entry.status == "drifted"
|
|
assert entry.configured is True
|
|
assert commands == [["codex", "mcp", "get", "pyro"], ["codex", "mcp", "list"]]
|