c3af15235b
## Related issue
N/A
## Summary
- Native-harness sessions (`omnigent claude`/`codex`/`pi`/etc.) previously
always opened bash for "+ New shell"; they now open the user's login shell.
- `omnigent/_platform.py`: add `default_interactive_shell()` (basename of
`$SHELL` when it names a known shell on PATH, else bash) and
`installed_interactive_shells()` (that default first, then any of
bash/zsh/fish on PATH; always non-empty).
- `omnigent/native_coding_agents.py`: `native_shell_terminal_spec()` now
declares one unsandboxed caller-process terminal per installed shell, keyed
and commanded by the shell basename, `$SHELL` first. The 11 native wrappers
call this shared helper instead of a hardcoded `{"shell": {"command": "bash"}}`
block.
- `web/src/shell/NewTerminalButton.tsx`: branch on
`useTerminalFirst().isNativeWrapper` — native sessions with multiple shells
get a split button (primary click launches the `$SHELL` default; a caret opens
a picker of installed shells, default labeled). SDK agents with multiple
distinct-purpose terminals keep the existing plain dropdown unchanged.
- `examples/polly/config.yaml`: add a `zsh` terminal alongside the existing
bash `shell` for the builtin polly agent.
## Test Plan
- `uv run pytest tests/inner/test_proc_and_platform.py tests/test_native_coding_agents.py`
— new unit tests for shell detection and the multi-shell spec.
- `uv run pytest -k "native and (materialize or terminal or agent_spec)"` — 296
passed, including the runner create-session-terminal flow; updated 4 native
wrapper tests that asserted the old single-`shell` shape.
- `npx vitest run src/shell/NewTerminalButton.test.tsx` (+ related shell suites)
— split-button default launch, caret pick of a non-default shell, and SDK
dropdown-unchanged cases.
- ruff check/format, prettier, oxlint, and tsc clean on all touched files.
- Verified polly's YAML parses through `_parse_terminals` with both `shell`
(bash) and `zsh` terminals.
## Type of change
- [ ] Bug fix
- [x] Feature
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change
## Test coverage
- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable
## Coverage notes
Shell detection, the native multi-shell spec, and the frontend split-button
behavior are covered by new/updated unit tests (pytest + vitest). Manually
verified that `default_interactive_shell()`/`installed_interactive_shells()`
resolve the host's shells, all 11 native wrappers import cycle-free, and
polly's edited YAML parses through Omnigent's real terminal parser. The live
end-to-end (clicking "+ New shell" in a running native session and confirming
the shell that opens) was not exercised here as it needs an interactive session.
280 lines
10 KiB
Python
280 lines
10 KiB
Python
"""Unit tests for the omni hermes CLI-side helpers + harness wiring (no server)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import click
|
|
import pytest
|
|
|
|
from omnigent import hermes_native as hn
|
|
|
|
|
|
def test_resolve_hermes_executable_found() -> None:
|
|
resolved = hn.resolve_hermes_executable(
|
|
env={}, which=lambda cmd: f"/usr/local/bin/{cmd}" if cmd == "hermes" else None
|
|
)
|
|
assert resolved == "/usr/local/bin/hermes"
|
|
|
|
|
|
def test_resolve_hermes_executable_honors_path_override() -> None:
|
|
resolved = hn.resolve_hermes_executable(
|
|
env={"OMNIGENT_HERMES_PATH": "/opt/hermes"},
|
|
which=lambda cmd: cmd if cmd == "/opt/hermes" else None,
|
|
)
|
|
assert resolved == "/opt/hermes"
|
|
|
|
|
|
def test_resolve_hermes_executable_missing_raises_with_hint() -> None:
|
|
with pytest.raises(click.ClickException) as exc:
|
|
hn.resolve_hermes_executable(env={}, which=lambda _cmd: None)
|
|
assert "hermes-agent.nousresearch.com" in str(exc.value)
|
|
|
|
|
|
def test_build_hermes_launch_argv() -> None:
|
|
launch = hn.build_hermes_launch(
|
|
["--resume", "x"],
|
|
env={},
|
|
which=lambda cmd: f"/bin/{cmd}",
|
|
)
|
|
assert launch.executable == "/bin/hermes"
|
|
assert launch.argv == ["/bin/hermes", "--resume", "x"]
|
|
|
|
|
|
def test_terminal_resource_id_stable() -> None:
|
|
assert hn.hermes_terminal_resource_id() == hn.hermes_terminal_resource_id()
|
|
|
|
|
|
def test_harness_registry_has_hermes_native() -> None:
|
|
from omnigent.runtime.harnesses import _HARNESS_MODULES
|
|
|
|
assert _HARNESS_MODULES["hermes-native"] == "omnigent.inner.hermes_native_harness"
|
|
|
|
|
|
def test_alias_and_native_membership() -> None:
|
|
from omnigent.harness_aliases import (
|
|
NATIVE_HARNESSES,
|
|
canonicalize_harness,
|
|
is_native_harness,
|
|
)
|
|
|
|
assert canonicalize_harness("native-hermes") == "hermes-native"
|
|
assert "hermes-native" in NATIVE_HARNESSES
|
|
assert "native-hermes" in NATIVE_HARNESSES
|
|
assert is_native_harness("hermes-native") is True
|
|
assert is_native_harness("native-hermes") is True
|
|
# The headless ``hermes`` harness is NOT a native CLI harness.
|
|
assert is_native_harness("hermes") is False
|
|
|
|
|
|
def test_native_coding_agent_resolves() -> None:
|
|
from omnigent._wrapper_labels import (
|
|
HERMES_NATIVE_WRAPPER_VALUE,
|
|
UI_MODE_LABEL_KEY,
|
|
UI_MODE_TERMINAL_VALUE,
|
|
WRAPPER_LABEL_KEY,
|
|
)
|
|
from omnigent.harness_plugins import HERMES_NATIVE_CODING_AGENT
|
|
from omnigent.native_coding_agents import native_coding_agent_for_harness
|
|
|
|
agent = native_coding_agent_for_harness("native-hermes")
|
|
assert agent is HERMES_NATIVE_CODING_AGENT
|
|
assert agent is native_coding_agent_for_harness("hermes-native")
|
|
assert agent.agent_name == "hermes-native-ui"
|
|
assert agent.terminal_name == "hermes"
|
|
assert agent.presentation_labels == {
|
|
UI_MODE_LABEL_KEY: UI_MODE_TERMINAL_VALUE,
|
|
WRAPPER_LABEL_KEY: HERMES_NATIVE_WRAPPER_VALUE,
|
|
}
|
|
|
|
|
|
def test_create_app_builds() -> None:
|
|
from omnigent.inner.hermes_native_harness import create_app
|
|
|
|
assert create_app() is not None
|
|
|
|
|
|
# --- CLI orchestration helpers (no server/daemon needed) ----------------------
|
|
|
|
|
|
def test_materialize_agent_spec_is_terminal_first_hermes_native(tmp_path, monkeypatch) -> None:
|
|
import yaml
|
|
|
|
# Pin the host shells so the declared terminals are deterministic: zsh is
|
|
# $SHELL (default, first), bash/fish resolve on PATH.
|
|
monkeypatch.setattr("shutil.which", lambda name: f"/usr/bin/{name}")
|
|
monkeypatch.setenv("SHELL", "/bin/zsh")
|
|
spec_path = hn._materialize_hermes_agent_spec(tmp_path)
|
|
raw = yaml.safe_load(spec_path.read_text())
|
|
assert raw["name"] == "hermes-native-ui"
|
|
assert raw["executor"] == {"harness": "hermes-native"}
|
|
assert raw["spawn"] is True
|
|
# One terminal per installed shell, $SHELL first — a non-empty block also
|
|
# gates the MCP relay's sys_terminal_* advertisement.
|
|
assert list(raw["terminals"]) == ["zsh", "bash", "fish"]
|
|
assert raw["terminals"]["zsh"]["command"] == "zsh"
|
|
|
|
|
|
def test_configured_hermes_command_default_and_override() -> None:
|
|
assert hn._configured_hermes_command({}) == "hermes"
|
|
assert hn._configured_hermes_command({"OMNIGENT_HERMES_PATH": "/opt/hermes"}) == "/opt/hermes"
|
|
|
|
|
|
def test_launched_terminal_from_payload_decodes_tmux_metadata() -> None:
|
|
term = hn._launched_hermes_terminal_from_payload(
|
|
{"id": "terminal_hermes_main", "metadata": {"tmux_socket": "/s", "tmux_target": "t:0.0"}}
|
|
)
|
|
assert term.terminal_id == "terminal_hermes_main"
|
|
assert str(term.tmux_socket) == "/s"
|
|
assert term.tmux_target == "t:0.0"
|
|
# No metadata → id only, sockets None.
|
|
bare = hn._launched_hermes_terminal_from_payload({"id": "terminal_hermes_main"})
|
|
assert bare.tmux_socket is None and bare.tmux_target is None
|
|
|
|
|
|
def test_launched_terminal_from_payload_rejects_bad_shapes() -> None:
|
|
with pytest.raises(click.ClickException):
|
|
hn._launched_hermes_terminal_from_payload(["not", "a", "dict"])
|
|
with pytest.raises(click.ClickException):
|
|
hn._launched_hermes_terminal_from_payload({"metadata": {}}) # no id
|
|
|
|
|
|
def test_direct_tmux_unavailable_reason_branches(tmp_path, monkeypatch) -> None:
|
|
def _prep(socket, target):
|
|
return hn.PreparedHermesTerminal(
|
|
session_id="c",
|
|
terminal_id="t",
|
|
tmux_socket=socket,
|
|
tmux_target=target,
|
|
reattached=False,
|
|
)
|
|
|
|
assert "socket path" in (hn._direct_tmux_unavailable_reason(_prep(None, "t")) or "")
|
|
assert "tmux target" in (hn._direct_tmux_unavailable_reason(_prep(tmp_path, None)) or "")
|
|
missing = tmp_path / "nope.sock"
|
|
assert "not reachable" in (hn._direct_tmux_unavailable_reason(_prep(missing, "t")) or "")
|
|
# Socket exists + tmux present → no reason (attach is available).
|
|
sock = tmp_path / "live.sock"
|
|
sock.write_text("")
|
|
monkeypatch.setattr(hn.shutil, "which", lambda _c: "/usr/bin/tmux")
|
|
assert hn._direct_tmux_unavailable_reason(_prep(sock, "t")) is None
|
|
# tmux absent → reason.
|
|
monkeypatch.setattr(hn.shutil, "which", lambda _c: None)
|
|
assert "tmux is not available" in (hn._direct_tmux_unavailable_reason(_prep(sock, "t")) or "")
|
|
|
|
|
|
def test_preflight_requires_tmux(monkeypatch) -> None:
|
|
monkeypatch.setattr(hn.shutil, "which", lambda _c: None)
|
|
with pytest.raises(click.ClickException, match="tmux"):
|
|
hn._preflight_local_tools()
|
|
monkeypatch.setattr(hn.shutil, "which", lambda _c: "/usr/bin/tmux")
|
|
hn._preflight_local_tools() # no raise
|
|
|
|
|
|
def test_update_startup_progress_is_noop_without_renderer() -> None:
|
|
hn._update_startup_progress(None, "hi") # no error
|
|
|
|
class _Progress:
|
|
def __init__(self) -> None:
|
|
self.messages: list[str] = []
|
|
|
|
def update(self, msg: str) -> None:
|
|
self.messages.append(msg)
|
|
|
|
prog = _Progress()
|
|
hn._update_startup_progress(prog, "Starting…")
|
|
assert prog.messages == ["Starting…"]
|
|
|
|
|
|
# --- daemon-flow HTTP helpers (fake async client; no real server) -------------
|
|
|
|
|
|
class _FakeResp:
|
|
def __init__(self, status: int, payload=None, text: str = "") -> None:
|
|
self.status_code = status
|
|
self._payload = payload
|
|
self.text = text
|
|
|
|
def json(self):
|
|
if self._payload is None:
|
|
raise ValueError("no json")
|
|
return self._payload
|
|
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, resp: _FakeResp) -> None:
|
|
self._resp = resp
|
|
|
|
async def post(self, *_a, **_k):
|
|
return self._resp
|
|
|
|
async def get(self, *_a, **_k):
|
|
return self._resp
|
|
|
|
async def patch(self, *_a, **_k):
|
|
return self._resp
|
|
|
|
|
|
async def test_create_hermes_session_returns_id_or_raises() -> None:
|
|
ok = _FakeAsyncClient(_FakeResp(200, {"session_id": "conv_x"}))
|
|
assert await hn._create_hermes_session(ok, b"bundle") == "conv_x"
|
|
with pytest.raises(click.ClickException):
|
|
await hn._create_hermes_session(_FakeAsyncClient(_FakeResp(500, {})), b"bundle")
|
|
with pytest.raises(click.ClickException, match="session_id"):
|
|
await hn._create_hermes_session(_FakeAsyncClient(_FakeResp(200, {})), b"bundle")
|
|
|
|
|
|
async def test_fetch_hermes_session_handles_status() -> None:
|
|
payload = {"labels": {"omnigent.wrapper": "hermes-native-ui"}}
|
|
assert (
|
|
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(200, payload)), "c") == payload
|
|
)
|
|
with pytest.raises(click.ClickException, match="not found"):
|
|
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(404)), "c")
|
|
with pytest.raises(click.ClickException):
|
|
await hn._fetch_hermes_session(_FakeAsyncClient(_FakeResp(500, {})), "c")
|
|
|
|
|
|
async def test_ensure_terminal_on_runner_raises_on_error() -> None:
|
|
await hn._ensure_hermes_terminal_on_runner(_FakeAsyncClient(_FakeResp(200, {})), "c") # ok
|
|
with pytest.raises(click.ClickException):
|
|
await hn._ensure_hermes_terminal_on_runner(_FakeAsyncClient(_FakeResp(500, {})), "c")
|
|
|
|
|
|
async def test_find_running_terminal_states() -> None:
|
|
# 404 → not created yet.
|
|
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(_FakeResp(404)), "c") is None
|
|
# running:false → treated as absent.
|
|
not_running = _FakeResp(200, {"id": "terminal_hermes_main", "metadata": {"running": False}})
|
|
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(not_running), "c") is None
|
|
# 409 not-bound → None (runner not ready), not an error.
|
|
notbound = _FakeResp(409, {"error": {"message": "session not bound to a runner"}})
|
|
assert await hn._find_running_hermes_terminal(_FakeAsyncClient(notbound), "c") is None
|
|
# Live terminal with tmux metadata → decoded.
|
|
live = _FakeResp(
|
|
200,
|
|
{"id": "terminal_hermes_main", "metadata": {"tmux_socket": "/s", "tmux_target": "t:0.0"}},
|
|
)
|
|
term = await hn._find_running_hermes_terminal(_FakeAsyncClient(live), "c")
|
|
assert term is not None and term.tmux_target == "t:0.0"
|
|
|
|
|
|
async def test_wait_for_terminal_ready_found_and_timeout(monkeypatch) -> None:
|
|
live = hn.LaunchedHermesTerminal(terminal_id="t", tmux_socket=None, tmux_target=None)
|
|
|
|
async def _found(_client, _sid):
|
|
return live
|
|
|
|
monkeypatch.setattr(hn, "_find_running_hermes_terminal", _found)
|
|
out = await hn._wait_for_hermes_terminal_ready(
|
|
_FakeAsyncClient(_FakeResp(200, {})), "c", timeout_s=5
|
|
)
|
|
assert out is live
|
|
|
|
async def _never(_client, _sid):
|
|
return None
|
|
|
|
monkeypatch.setattr(hn, "_find_running_hermes_terminal", _never)
|
|
with pytest.raises(click.ClickException, match="did not create"):
|
|
await hn._wait_for_hermes_terminal_ready(
|
|
_FakeAsyncClient(_FakeResp(200, {})), "c", timeout_s=0.0
|
|
)
|