562 lines
19 KiB
Python
562 lines
19 KiB
Python
"""Tests for native Kiro CLI orchestration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from click import ClickException
|
|
|
|
from omnigent._wrapper_labels import KIRO_NATIVE_WRAPPER_VALUE, WRAPPER_LABEL_KEY
|
|
from omnigent.kiro_native import (
|
|
_KIRO_PATH_ENV,
|
|
LaunchedKiroTerminal,
|
|
PreparedKiroTerminal,
|
|
_attach_terminal_resource,
|
|
_create_kiro_session,
|
|
_direct_tmux_unavailable_reason,
|
|
_ensure_kiro_terminal_on_runner,
|
|
_fetch_kiro_session,
|
|
_find_running_kiro_terminal,
|
|
_launched_kiro_terminal_from_payload,
|
|
_materialize_kiro_agent_spec,
|
|
_preflight_local_tools,
|
|
_resolve_session_id_for_resume,
|
|
_tmux_attach_env,
|
|
_update_startup_progress,
|
|
_wait_for_kiro_terminal_ready,
|
|
build_kiro_launch,
|
|
kiro_base_model_options,
|
|
kiro_terminal_resource_id,
|
|
resolve_kiro_executable,
|
|
run_kiro_native,
|
|
)
|
|
|
|
_NO_JSON = object()
|
|
|
|
|
|
class _FakeResponse:
|
|
"""Minimal stand-in for ``httpx.Response`` used by the async helpers."""
|
|
|
|
def __init__(self, status_code: int, *, json_body: object = _NO_JSON, text: str = "") -> None:
|
|
self.status_code = status_code
|
|
self._json_body = json_body
|
|
self.text = text
|
|
|
|
def json(self) -> object:
|
|
if self._json_body is _NO_JSON:
|
|
raise ValueError("no JSON body")
|
|
return self._json_body
|
|
|
|
|
|
class _FakeClient:
|
|
"""Records calls and replays queued ``_FakeResponse`` objects per method."""
|
|
|
|
def __init__(self) -> None:
|
|
self.calls: list[tuple[str, str, dict]] = []
|
|
self._queued: dict[str, list[_FakeResponse]] = {}
|
|
|
|
def queue(self, method: str, *responses: _FakeResponse) -> None:
|
|
self._queued.setdefault(method, []).extend(responses)
|
|
|
|
async def _replay(self, method: str, url: str, kwargs: dict) -> _FakeResponse:
|
|
self.calls.append((method, url, kwargs))
|
|
return self._queued[method].pop(0)
|
|
|
|
async def get(self, url: str, **kwargs: object) -> _FakeResponse:
|
|
return await self._replay("get", url, kwargs)
|
|
|
|
async def post(self, url: str, **kwargs: object) -> _FakeResponse:
|
|
return await self._replay("post", url, kwargs)
|
|
|
|
async def patch(self, url: str, **kwargs: object) -> _FakeResponse:
|
|
return await self._replay("patch", url, kwargs)
|
|
|
|
|
|
def test_materialize_kiro_agent_spec_uses_native_identity(tmp_path: Path) -> None:
|
|
"""The generated wrapper spec targets ``kiro-native`` and terminal-first labels."""
|
|
path = _materialize_kiro_agent_spec(tmp_path, model="auto")
|
|
|
|
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
|
|
assert raw["name"] == "kiro-native-ui"
|
|
assert raw["executor"] == {"harness": "kiro-native", "model": "auto"}
|
|
assert raw["spawn"] is True
|
|
|
|
|
|
def test_materialized_kiro_agent_spec_passes_current_validator(tmp_path: Path) -> None:
|
|
"""``omnigent kiro`` must not be rejected as an unknown harness at upload."""
|
|
from omnigent.spec._omnigent_compat import load_omnigent_yaml
|
|
|
|
path = _materialize_kiro_agent_spec(tmp_path, model=None)
|
|
|
|
spec = load_omnigent_yaml(path)
|
|
|
|
assert spec.executor.config["harness"] == "kiro-native"
|
|
|
|
|
|
def test_launched_kiro_terminal_decodes_tmux_metadata() -> None:
|
|
"""Runner terminal metadata is converted into attach details."""
|
|
terminal = _launched_kiro_terminal_from_payload(
|
|
{
|
|
"id": "terminal_kiro_main",
|
|
"metadata": {
|
|
"tmux_socket": "/tmp/kiro.sock",
|
|
"tmux_target": "main",
|
|
},
|
|
}
|
|
)
|
|
|
|
assert terminal.terminal_id == "terminal_kiro_main"
|
|
assert terminal.tmux_socket == Path("/tmp/kiro.sock")
|
|
assert terminal.tmux_target == "main"
|
|
|
|
|
|
def test_build_kiro_launch_includes_resume_id() -> None:
|
|
"""Cold resume launches Kiro against the captured native session id."""
|
|
launch = build_kiro_launch(
|
|
["--effort", "high"],
|
|
resume_id="kiro-session-123",
|
|
env={},
|
|
which=lambda _cmd: "/usr/bin/kiro-cli",
|
|
)
|
|
|
|
assert launch.argv == [
|
|
"/usr/bin/kiro-cli",
|
|
"chat",
|
|
"--tui",
|
|
"--resume-id",
|
|
"kiro-session-123",
|
|
"--effort",
|
|
"high",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_attach_terminal_resource_requires_tmux_metadata() -> None:
|
|
"""A runner response without tmux attach metadata fails clearly."""
|
|
prepared = PreparedKiroTerminal(
|
|
session_id="conv_abc",
|
|
terminal_id="terminal_kiro_main",
|
|
tmux_socket=None,
|
|
tmux_target=None,
|
|
reattached=False,
|
|
)
|
|
|
|
with pytest.raises(ClickException, match="Runner-owned Kiro terminal"):
|
|
await _attach_terminal_resource(prepared)
|
|
|
|
|
|
def test_session_labels_use_kiro_wrapper_value() -> None:
|
|
"""Kiro wrapper sessions stamp the centralized wrapper label."""
|
|
from omnigent.kiro_native import _SESSION_LABELS
|
|
|
|
assert _SESSION_LABELS[WRAPPER_LABEL_KEY] == KIRO_NATIVE_WRAPPER_VALUE
|
|
|
|
|
|
def test_live_kiro_cli_binary_reports_version_when_installed() -> None:
|
|
"""Skippable smoke test for the native Kiro binary expected by the harness."""
|
|
binary = shutil.which("kiro-cli")
|
|
if binary is None:
|
|
pytest.skip("kiro-cli is not installed on PATH")
|
|
|
|
result = subprocess.run(
|
|
[binary, "--version"],
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=15,
|
|
)
|
|
|
|
assert result.returncode == 0
|
|
assert "kiro-cli" in result.stdout.lower()
|
|
|
|
|
|
def test_resolve_kiro_executable_errors_when_missing() -> None:
|
|
"""A missing kiro-cli yields an actionable install/login hint."""
|
|
with pytest.raises(ClickException, match="kiro-cli"):
|
|
resolve_kiro_executable(env={}, which=lambda _cmd: None)
|
|
|
|
|
|
def test_resolve_kiro_executable_honors_path_override() -> None:
|
|
"""``OMNIGENT_KIRO_PATH`` selects the executable to resolve."""
|
|
seen: list[str] = []
|
|
|
|
def _which(command: str) -> str:
|
|
seen.append(command)
|
|
return f"/opt/{command}"
|
|
|
|
resolved = resolve_kiro_executable(env={_KIRO_PATH_ENV: "custom-kiro"}, which=_which)
|
|
|
|
assert resolved == "/opt/custom-kiro"
|
|
assert seen == ["custom-kiro"]
|
|
|
|
|
|
def test_build_kiro_launch_appends_model_then_prompt() -> None:
|
|
"""Model flag precedes passthrough args; a prompt is the final argv token."""
|
|
launch = build_kiro_launch(
|
|
["--foo"],
|
|
model="claude",
|
|
prompt="hello world",
|
|
env={},
|
|
which=lambda _cmd: "/usr/bin/kiro-cli",
|
|
)
|
|
|
|
assert launch.argv == [
|
|
"/usr/bin/kiro-cli",
|
|
"chat",
|
|
"--tui",
|
|
"--model",
|
|
"claude",
|
|
"--foo",
|
|
"hello world",
|
|
]
|
|
|
|
|
|
def test_launched_kiro_terminal_rejects_non_object_payload() -> None:
|
|
"""A non-dict runner payload is reported as malformed."""
|
|
with pytest.raises(ClickException, match="non-object JSON"):
|
|
_launched_kiro_terminal_from_payload(["not", "a", "dict"])
|
|
|
|
|
|
def test_launched_kiro_terminal_requires_terminal_id() -> None:
|
|
"""A payload without an id cannot be turned into attach details."""
|
|
with pytest.raises(ClickException, match="terminal id"):
|
|
_launched_kiro_terminal_from_payload({"metadata": {}})
|
|
|
|
|
|
def test_launched_kiro_terminal_without_metadata_has_no_tmux() -> None:
|
|
"""Missing tmux metadata leaves the attach fields unset (cold terminal)."""
|
|
terminal = _launched_kiro_terminal_from_payload({"id": "terminal_kiro_main"})
|
|
|
|
assert terminal.terminal_id == "terminal_kiro_main"
|
|
assert terminal.tmux_socket is None
|
|
assert terminal.tmux_target is None
|
|
|
|
|
|
def test_tmux_attach_env_filters_to_allowlist(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Only allowlisted, set environment keys reach the tmux attach process."""
|
|
monkeypatch.setenv("TERM", "xterm-256color")
|
|
monkeypatch.setenv("OMNIGENT_UNLISTED_VAR", "present")
|
|
|
|
env = _tmux_attach_env()
|
|
|
|
assert env["TERM"] == "xterm-256color"
|
|
assert "OMNIGENT_UNLISTED_VAR" not in env
|
|
|
|
|
|
def test_direct_tmux_unavailable_reason_reports_each_gap(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Each missing prerequisite yields a distinct, specific reason."""
|
|
|
|
def _prepared(socket: Path | None, target: str | None) -> PreparedKiroTerminal:
|
|
return PreparedKiroTerminal(
|
|
session_id="conv",
|
|
terminal_id="terminal_kiro_main",
|
|
tmux_socket=socket,
|
|
tmux_target=target,
|
|
reattached=False,
|
|
)
|
|
|
|
assert "tmux socket path" in (_direct_tmux_unavailable_reason(_prepared(None, "main")) or "")
|
|
assert "tmux target" in (
|
|
_direct_tmux_unavailable_reason(_prepared(tmp_path / "s.sock", None)) or ""
|
|
)
|
|
assert "not reachable" in (
|
|
_direct_tmux_unavailable_reason(_prepared(tmp_path / "missing.sock", "main")) or ""
|
|
)
|
|
|
|
socket = tmp_path / "live.sock"
|
|
socket.touch()
|
|
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
|
|
assert "tmux is not available" in (
|
|
_direct_tmux_unavailable_reason(_prepared(socket, "main")) or ""
|
|
)
|
|
|
|
monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/tmux")
|
|
assert _direct_tmux_unavailable_reason(_prepared(socket, "main")) is None
|
|
|
|
|
|
def test_update_startup_progress_is_a_noop_without_renderer() -> None:
|
|
"""A ``None`` progress renderer is tolerated silently."""
|
|
_update_startup_progress(None, "anything")
|
|
|
|
|
|
def test_update_startup_progress_forwards_to_renderer() -> None:
|
|
"""An active renderer receives the milestone message verbatim."""
|
|
seen: list[str] = []
|
|
|
|
class _Progress:
|
|
def update(self, message: str) -> None:
|
|
seen.append(message)
|
|
|
|
_update_startup_progress(_Progress(), "Starting Kiro terminal...")
|
|
|
|
assert seen == ["Starting Kiro terminal..."]
|
|
|
|
|
|
def test_preflight_local_tools_requires_tmux(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""The native wrapper refuses to start without a local tmux."""
|
|
monkeypatch.setattr(shutil, "which", lambda _cmd: None)
|
|
|
|
with pytest.raises(ClickException, match="tmux was not found"):
|
|
_preflight_local_tools()
|
|
|
|
|
|
def test_preflight_local_tools_passes_with_tmux(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A present tmux satisfies the preflight check."""
|
|
monkeypatch.setattr(shutil, "which", lambda _cmd: "/usr/bin/tmux")
|
|
|
|
_preflight_local_tools()
|
|
|
|
|
|
def test_kiro_terminal_resource_id_is_deterministic() -> None:
|
|
"""The terminal resource id is stable across calls."""
|
|
assert kiro_terminal_resource_id() == kiro_terminal_resource_id()
|
|
assert isinstance(kiro_terminal_resource_id(), str)
|
|
|
|
|
|
def test_resolve_session_id_for_resume_passthrough() -> None:
|
|
"""An explicit session id is returned without touching the network."""
|
|
resolved = _resolve_session_id_for_resume(
|
|
base_url="http://server",
|
|
headers={},
|
|
session_id="conv_explicit",
|
|
resume_picker=False,
|
|
)
|
|
|
|
assert resolved == "conv_explicit"
|
|
|
|
|
|
def test_resolve_session_id_for_resume_no_picker_returns_none() -> None:
|
|
"""Without a session id or picker there is nothing to resume."""
|
|
resolved = _resolve_session_id_for_resume(
|
|
base_url="http://server",
|
|
headers={},
|
|
session_id=None,
|
|
resume_picker=False,
|
|
)
|
|
|
|
assert resolved is None
|
|
|
|
|
|
def test_run_kiro_native_requires_server(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A missing server URL is a programming error surfaced as a clear message."""
|
|
monkeypatch.setattr("omnigent.kiro_native._preflight_local_tools", lambda: None)
|
|
|
|
with pytest.raises(ClickException, match="resolved Omnigent server URL"):
|
|
run_kiro_native(server=None, session_id=None, kiro_args=())
|
|
|
|
|
|
def test_run_kiro_native_materializes_spec_and_delegates(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The launcher writes a spec and hands a trimmed base URL to the server path."""
|
|
monkeypatch.setattr("omnigent.kiro_native._preflight_local_tools", lambda: None)
|
|
captured: dict[str, object] = {}
|
|
|
|
def _fake_remote(base_url: str, spec_path: Path, **kwargs: object) -> None:
|
|
captured["base_url"] = base_url
|
|
captured["spec_exists"] = spec_path.exists()
|
|
captured["kwargs"] = kwargs
|
|
|
|
monkeypatch.setattr("omnigent.kiro_native._run_with_remote_server", _fake_remote)
|
|
|
|
run_kiro_native(
|
|
server="http://server/",
|
|
session_id=None,
|
|
kiro_args=("--foo",),
|
|
model="claude",
|
|
prompt="hi",
|
|
)
|
|
|
|
assert captured["base_url"] == "http://server"
|
|
assert captured["spec_exists"] is True
|
|
assert captured["kwargs"]["model"] == "claude"
|
|
|
|
|
|
async def test_create_kiro_session_returns_id_and_persists_args() -> None:
|
|
"""A successful create returns the new id and forwards launch args as metadata."""
|
|
client = _FakeClient()
|
|
client.queue("post", _FakeResponse(200, json_body={"session_id": "conv_new"}))
|
|
|
|
session_id = await _create_kiro_session(
|
|
client, b"bundle-bytes", terminal_launch_args=["--foo"]
|
|
)
|
|
|
|
assert session_id == "conv_new"
|
|
_method, _url, kwargs = client.calls[0]
|
|
metadata = json.loads(kwargs["data"]["metadata"])
|
|
assert metadata["terminal_launch_args"] == ["--foo"]
|
|
assert WRAPPER_LABEL_KEY in metadata["labels"]
|
|
|
|
|
|
async def test_create_kiro_session_raises_on_error_status() -> None:
|
|
"""A 4xx/5xx create response is surfaced with the status code."""
|
|
client = _FakeClient()
|
|
client.queue("post", _FakeResponse(500, json_body={"error": "boom"}))
|
|
|
|
with pytest.raises(ClickException, match="creation failed \\(500\\)"):
|
|
await _create_kiro_session(client, b"bundle")
|
|
|
|
|
|
async def test_create_kiro_session_requires_session_id_in_body() -> None:
|
|
"""A success body lacking a session id is treated as malformed."""
|
|
client = _FakeClient()
|
|
client.queue("post", _FakeResponse(200, json_body={}))
|
|
|
|
with pytest.raises(ClickException, match="did not include session_id"):
|
|
await _create_kiro_session(client, b"bundle")
|
|
|
|
|
|
async def test_fetch_kiro_session_maps_404_to_not_found() -> None:
|
|
"""A 404 fetch is reported as a missing conversation."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(404))
|
|
|
|
with pytest.raises(ClickException, match="not found"):
|
|
await _fetch_kiro_session(client, "conv_missing")
|
|
|
|
|
|
async def test_fetch_kiro_session_raises_on_error_status() -> None:
|
|
"""A non-404 error fetch is surfaced with the status code."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(500, json_body={"error": "boom"}))
|
|
|
|
with pytest.raises(ClickException, match="Failed to fetch conversation"):
|
|
await _fetch_kiro_session(client, "conv")
|
|
|
|
|
|
async def test_fetch_kiro_session_rejects_non_object_payload() -> None:
|
|
"""A non-object fetch payload is malformed."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(200, json_body=["unexpected"]))
|
|
|
|
with pytest.raises(ClickException, match="non-object JSON"):
|
|
await _fetch_kiro_session(client, "conv")
|
|
|
|
|
|
async def test_fetch_kiro_session_returns_payload() -> None:
|
|
"""A healthy fetch returns the decoded session object."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(200, json_body={"labels": {"x": "y"}}))
|
|
|
|
payload = await _fetch_kiro_session(client, "conv")
|
|
|
|
assert payload == {"labels": {"x": "y"}}
|
|
|
|
|
|
async def test_ensure_kiro_terminal_on_runner_posts_native_flag() -> None:
|
|
"""Ensuring the terminal asks the runner for a native terminal."""
|
|
client = _FakeClient()
|
|
client.queue("post", _FakeResponse(200, json_body={}))
|
|
|
|
await _ensure_kiro_terminal_on_runner(client, "conv")
|
|
|
|
_method, _url, kwargs = client.calls[0]
|
|
assert kwargs["json"]["ensure_native_terminal"] is True
|
|
|
|
|
|
async def test_ensure_kiro_terminal_on_runner_raises_on_error() -> None:
|
|
"""A failed ensure surfaces the status code."""
|
|
client = _FakeClient()
|
|
client.queue("post", _FakeResponse(503, json_body={"error": "no runner"}))
|
|
|
|
with pytest.raises(ClickException, match="ensure failed \\(503\\)"):
|
|
await _ensure_kiro_terminal_on_runner(client, "conv")
|
|
|
|
|
|
async def test_find_running_kiro_terminal_absent_is_none() -> None:
|
|
"""A 404 terminal lookup means no running terminal yet."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(404))
|
|
|
|
assert await _find_running_kiro_terminal(client, "conv") is None
|
|
|
|
|
|
async def test_find_running_kiro_terminal_unbound_runner_is_none() -> None:
|
|
"""A transient 'not bound to a runner' is treated as not-yet-running."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(409, text="session not bound to a runner"))
|
|
|
|
assert await _find_running_kiro_terminal(client, "conv") is None
|
|
|
|
|
|
async def test_find_running_kiro_terminal_hard_error_raises() -> None:
|
|
"""An unexpected error status is surfaced rather than swallowed."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(500, json_body={"error": "boom"}))
|
|
|
|
with pytest.raises(ClickException, match="Failed to fetch Kiro terminal"):
|
|
await _find_running_kiro_terminal(client, "conv")
|
|
|
|
|
|
async def test_find_running_kiro_terminal_not_running_metadata_is_none() -> None:
|
|
"""A terminal explicitly flagged not-running is ignored."""
|
|
client = _FakeClient()
|
|
client.queue(
|
|
"get",
|
|
_FakeResponse(200, json_body={"id": "terminal_kiro_main", "metadata": {"running": False}}),
|
|
)
|
|
|
|
assert await _find_running_kiro_terminal(client, "conv") is None
|
|
|
|
|
|
async def test_find_running_kiro_terminal_returns_attach_details() -> None:
|
|
"""A live terminal payload is decoded into attach details."""
|
|
client = _FakeClient()
|
|
client.queue(
|
|
"get",
|
|
_FakeResponse(
|
|
200,
|
|
json_body={
|
|
"id": "terminal_kiro_main",
|
|
"metadata": {"tmux_socket": "/tmp/k.sock", "tmux_target": "main"},
|
|
},
|
|
),
|
|
)
|
|
|
|
terminal = await _find_running_kiro_terminal(client, "conv")
|
|
|
|
assert isinstance(terminal, LaunchedKiroTerminal)
|
|
assert terminal.tmux_socket == Path("/tmp/k.sock")
|
|
|
|
|
|
async def test_wait_for_kiro_terminal_ready_returns_first_hit() -> None:
|
|
"""The poll loop returns as soon as the terminal resource appears."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(200, json_body={"id": "terminal_kiro_main"}))
|
|
|
|
terminal = await _wait_for_kiro_terminal_ready(client, "conv", timeout_s=1.0)
|
|
|
|
assert terminal.terminal_id == "terminal_kiro_main"
|
|
|
|
|
|
async def test_wait_for_kiro_terminal_ready_times_out() -> None:
|
|
"""An absent terminal eventually fails with a timeout message."""
|
|
client = _FakeClient()
|
|
client.queue("get", _FakeResponse(404))
|
|
|
|
with pytest.raises(ClickException, match="did not create the Kiro terminal"):
|
|
await _wait_for_kiro_terminal_ready(client, "conv", timeout_s=0.05)
|
|
|
|
|
|
def test_kiro_base_model_options_shape_and_default() -> None:
|
|
"""The curated kiro catalog exposes picker option dicts with one default."""
|
|
options = kiro_base_model_options()
|
|
ids = [o["id"] for o in options]
|
|
|
|
# Canonical ids confirmed against ``kiro-cli --list-models`` (2.10.0).
|
|
assert ids[0] == "auto"
|
|
assert "claude-haiku-4.5" in ids and "glm-5" in ids
|
|
# Exactly one default, and every option carries the picker fields.
|
|
assert [o["id"] for o in options if o["isDefault"]] == ["auto"]
|
|
for option in options:
|
|
assert set(option) == {"id", "displayName", "isDefault", "isCurrent"}
|
|
assert option["isCurrent"] is False
|