chore: import upstream snapshot with attribution
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
"""Unit tests for CLI OIDC token storage (omnigent/cli_auth.py).
|
||||
|
||||
Tests the store/load/clear lifecycle for session tokens persisted
|
||||
by ``omnigent login``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def token_dir(tmp_path, monkeypatch):
|
||||
"""Redirect the token file to a temp directory.
|
||||
|
||||
Patches ``state_dir`` to return ``tmp_path`` so tests don't
|
||||
touch ``~/.omnigent``.
|
||||
|
||||
:param tmp_path: Pytest temp directory.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: The temp directory path.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli_auth._token_file_path",
|
||||
lambda: tmp_path / "auth_tokens.json",
|
||||
)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_store_and_load_token(token_dir) -> None:
|
||||
"""A stored token can be loaded back by server URL.
|
||||
|
||||
This is the happy path: ``omnigent login`` stores a token,
|
||||
``omnigent run --server`` loads it.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-abc",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
result = load_token("http://localhost:8000")
|
||||
# Token must be the exact value stored.
|
||||
assert result == "jwt-abc", f"Expected 'jwt-abc', got {result!r}."
|
||||
|
||||
|
||||
def test_load_returns_none_when_no_file(token_dir) -> None:
|
||||
"""load_token returns None when no token file exists.
|
||||
|
||||
The first time a user runs ``omnigent run --server`` without
|
||||
having run ``omnigent login``, there should be no crash.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token
|
||||
|
||||
assert load_token("http://localhost:8000") is None
|
||||
|
||||
|
||||
def test_load_returns_none_for_unknown_server(token_dir) -> None:
|
||||
"""load_token returns None for a server with no stored token.
|
||||
|
||||
A token stored for one server must not leak to another.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-abc",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
assert load_token("http://other-server:9000") is None
|
||||
|
||||
|
||||
def test_load_returns_none_for_expired_token(token_dir) -> None:
|
||||
"""load_token returns None when the stored token has expired.
|
||||
|
||||
Expired tokens must not be used — the user needs to re-run
|
||||
``omnigent login``.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-expired",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() - 1, # Already expired.
|
||||
)
|
||||
|
||||
assert load_token("http://localhost:8000") is None
|
||||
|
||||
|
||||
def test_clear_token(token_dir) -> None:
|
||||
"""clear_token removes a stored token for a server.
|
||||
|
||||
After clearing, load_token must return None.
|
||||
"""
|
||||
from omnigent.cli_auth import clear_token, load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-abc",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
clear_token("http://localhost:8000")
|
||||
|
||||
assert load_token("http://localhost:8000") is None
|
||||
|
||||
|
||||
def test_trailing_slash_normalization(token_dir) -> None:
|
||||
"""Server URLs are normalized (trailing slash stripped).
|
||||
|
||||
``http://localhost:8000/`` and ``http://localhost:8000`` must
|
||||
resolve to the same stored token.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000/",
|
||||
token="jwt-slash",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
# Load without trailing slash.
|
||||
assert load_token("http://localhost:8000") == "jwt-slash"
|
||||
|
||||
|
||||
def test_file_permissions(token_dir) -> None:
|
||||
"""Token file is created with 0o600 (user-only read/write).
|
||||
|
||||
Tokens are sensitive — they must not be world-readable.
|
||||
"""
|
||||
|
||||
from omnigent.cli_auth import store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-abc",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
path = token_dir / "auth_tokens.json"
|
||||
mode = path.stat().st_mode & 0o777
|
||||
# 0o600 = user read + write only.
|
||||
assert mode == 0o600, (
|
||||
f"Token file should have 0o600 permissions, got {oct(mode)}. "
|
||||
f"This means the token could be readable by other users."
|
||||
)
|
||||
|
||||
|
||||
def test_store_overwrites_existing(token_dir) -> None:
|
||||
"""Storing a token for the same server overwrites the old one.
|
||||
|
||||
Re-running ``omnigent login`` should update the token, not
|
||||
append.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="old-token",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="new-token",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
assert load_token("http://localhost:8000") == "new-token"
|
||||
|
||||
|
||||
def test_multiple_servers(token_dir) -> None:
|
||||
"""Tokens for different servers are stored independently.
|
||||
|
||||
A user may have accounts on multiple servers.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="token-a",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
store_token(
|
||||
server_url="https://prod.example.com",
|
||||
token="token-b",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
assert load_token("http://localhost:8000") == "token-a"
|
||||
assert load_token("https://prod.example.com") == "token-b"
|
||||
|
||||
|
||||
# ── Databricks Apps pointer records ────────────────────────────────
|
||||
|
||||
|
||||
def test_store_and_load_databricks_record(token_dir) -> None:
|
||||
"""A stored Databricks pointer record resolves back to its workspace.
|
||||
|
||||
``omnigent login <apps-url>`` stores the record; the server-auth
|
||||
chain looks up the workspace host to mint fresh tokens.
|
||||
"""
|
||||
from omnigent.cli_auth import load_databricks_workspace_host, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://myapp-123.aws.databricksapps.com",
|
||||
workspace_host="https://example.databricks.com",
|
||||
user_id="alice@example.com",
|
||||
)
|
||||
|
||||
host = load_databricks_workspace_host("https://myapp-123.aws.databricksapps.com")
|
||||
assert host == "https://example.databricks.com", (
|
||||
f"Expected the stored workspace host back, got {host!r}. A miss means "
|
||||
"the auth chain would silently fall through to ambient credentials."
|
||||
)
|
||||
|
||||
|
||||
def test_databricks_request_headers_org_only(token_dir) -> None:
|
||||
"""A recorded ?o= selector surfaces as the workspace-routing header.
|
||||
|
||||
When the bare host is the account, the request routes by this header
|
||||
(equivalently to ``?o=``). A record with no org id (single-workspace
|
||||
host) yields no header, so those callers are unaffected.
|
||||
"""
|
||||
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
||||
workspace_host="https://acme.databricks.com",
|
||||
org_id="2850744067564480",
|
||||
)
|
||||
assert databricks_request_headers("https://acme.databricks.com/api/2.0/omnigent") == {
|
||||
"X-Databricks-Org-Id": "2850744067564480"
|
||||
}
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://single.databricks.com/api/2.0/omnigent",
|
||||
workspace_host="https://single.databricks.com",
|
||||
)
|
||||
assert databricks_request_headers("https://single.databricks.com/api/2.0/omnigent") == {}
|
||||
|
||||
|
||||
def test_databricks_request_headers_pairs_bearer_and_org(token_dir) -> None:
|
||||
"""The paired minter always emits the bearer and the ?o= header together.
|
||||
|
||||
The static-dict seams (WS handshakes, hook-config replay) call this so a
|
||||
workspace request can never carry ``Authorization`` without the routing
|
||||
header. A missing token or selector is omitted, so single-workspace and
|
||||
local-unauthenticated callers are unaffected.
|
||||
"""
|
||||
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
||||
workspace_host="https://acme.databricks.com",
|
||||
org_id="2850744067564480",
|
||||
)
|
||||
recorded = "https://acme.databricks.com/api/2.0/omnigent"
|
||||
# Bearer + org travel together.
|
||||
assert databricks_request_headers(recorded, bearer_token="tok") == {
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Databricks-Org-Id": "2850744067564480",
|
||||
}
|
||||
# Recorded selector but no token (local/unauth): org still rides, no bearer.
|
||||
assert databricks_request_headers(recorded) == {"X-Databricks-Org-Id": "2850744067564480"}
|
||||
# No record (unknown server): bearer only, no routing header.
|
||||
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
||||
"Authorization": "Bearer tok"
|
||||
}
|
||||
|
||||
|
||||
def test_databricks_request_headers_folds_extra_headers(token_dir, monkeypatch) -> None:
|
||||
"""OMNIGENT_DATABRICKS_EXTRA_HEADERS rides every request built via the helper.
|
||||
|
||||
Databricks deployments set it to opaque request-routing selector headers so
|
||||
a request pins to a specific server instance. Because it is folded into this
|
||||
one helper, any caller that routes through it gets the selectors for free; a
|
||||
caller that hand-rolls a bare bearer misses them. Malformed / unset input is
|
||||
a no-op (prod-safe).
|
||||
"""
|
||||
from omnigent.cli_auth import databricks_request_headers, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://acme.databricks.com/api/2.0/omnigent",
|
||||
workspace_host="https://acme.databricks.com",
|
||||
org_id="2850744067564480",
|
||||
)
|
||||
recorded = "https://acme.databricks.com/api/2.0/omnigent"
|
||||
monkeypatch.setenv(
|
||||
"OMNIGENT_DATABRICKS_EXTRA_HEADERS",
|
||||
'{"x-databricks-route-hint": "instance-abc"}',
|
||||
)
|
||||
# Extra header travels alongside the bearer + ?o= routing header.
|
||||
assert databricks_request_headers(recorded, bearer_token="tok") == {
|
||||
"Authorization": "Bearer tok",
|
||||
"X-Databricks-Org-Id": "2850744067564480",
|
||||
"x-databricks-route-hint": "instance-abc",
|
||||
}
|
||||
# It rides even for an unknown server with no token (routing-only request).
|
||||
assert databricks_request_headers("https://other.example.com") == {
|
||||
"x-databricks-route-hint": "instance-abc",
|
||||
}
|
||||
# Malformed JSON is ignored (no crash) so a bad value can't break requests.
|
||||
monkeypatch.setenv("OMNIGENT_DATABRICKS_EXTRA_HEADERS", "not-json")
|
||||
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
||||
"Authorization": "Bearer tok",
|
||||
}
|
||||
# Unset (prod default) is a no-op.
|
||||
monkeypatch.delenv("OMNIGENT_DATABRICKS_EXTRA_HEADERS", raising=False)
|
||||
assert databricks_request_headers("https://other.example.com", bearer_token="tok") == {
|
||||
"Authorization": "Bearer tok",
|
||||
}
|
||||
|
||||
|
||||
def test_load_token_returns_none_for_databricks_record(token_dir) -> None:
|
||||
"""A Databricks pointer record carries NO bearer — load_token must miss.
|
||||
|
||||
Databricks OAuth tokens expire after ~1h, so the record deliberately
|
||||
stores only the workspace host. If load_token returned anything here,
|
||||
the JWT path would send a garbage Authorization header.
|
||||
"""
|
||||
from omnigent.cli_auth import load_token, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://myapp-123.aws.databricksapps.com",
|
||||
workspace_host="https://example.databricks.com",
|
||||
)
|
||||
|
||||
assert load_token("https://myapp-123.aws.databricksapps.com") is None
|
||||
|
||||
|
||||
def test_load_databricks_host_returns_none_for_jwt_record(token_dir) -> None:
|
||||
"""A session-JWT record is not a Databricks pointer record.
|
||||
|
||||
The Databricks resolution path must not fire for servers the user
|
||||
logged into via accounts/OIDC — those send the stored JWT instead.
|
||||
"""
|
||||
import time
|
||||
|
||||
from omnigent.cli_auth import load_databricks_workspace_host, store_token
|
||||
|
||||
store_token(
|
||||
server_url="http://localhost:8000",
|
||||
token="jwt-abc",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
assert load_databricks_workspace_host("http://localhost:8000") is None
|
||||
|
||||
|
||||
def test_databricks_record_normalizes_workspace_trailing_slash(token_dir) -> None:
|
||||
"""The stored workspace host is normalized (trailing slash stripped).
|
||||
|
||||
``Config(host=...)`` treats ``https://ws`` and ``https://ws/`` as
|
||||
distinct cache keys in some SDK paths — store one canonical form.
|
||||
"""
|
||||
from omnigent.cli_auth import load_databricks_workspace_host, store_databricks_auth
|
||||
|
||||
store_databricks_auth(
|
||||
server_url="https://myapp-123.aws.databricksapps.com/",
|
||||
workspace_host="https://example.databricks.com/",
|
||||
)
|
||||
|
||||
# Lookup without the trailing slash hits the same record, and the
|
||||
# stored host comes back canonical.
|
||||
host = load_databricks_workspace_host("https://myapp-123.aws.databricksapps.com")
|
||||
assert host == "https://example.databricks.com"
|
||||
|
||||
|
||||
def test_databricks_record_overwrites_jwt_record(token_dir) -> None:
|
||||
"""Re-logging into a server replaces its record wholesale.
|
||||
|
||||
A server that switched deployment shape (accounts → Databricks Apps)
|
||||
must not keep serving the stale JWT.
|
||||
"""
|
||||
import time
|
||||
|
||||
from omnigent.cli_auth import (
|
||||
load_databricks_workspace_host,
|
||||
load_token,
|
||||
store_databricks_auth,
|
||||
store_token,
|
||||
)
|
||||
|
||||
store_token(
|
||||
server_url="https://server.example.com",
|
||||
token="old-jwt",
|
||||
user_id="alice@example.com",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
store_databricks_auth(
|
||||
server_url="https://server.example.com",
|
||||
workspace_host="https://example.databricks.com",
|
||||
)
|
||||
|
||||
# The JWT is gone; the pointer record answers instead.
|
||||
assert load_token("https://server.example.com") is None
|
||||
assert (
|
||||
load_databricks_workspace_host("https://server.example.com")
|
||||
== "https://example.databricks.com"
|
||||
)
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Tests for the always-on CLI diagnostics log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent import cli_diagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _LoggerSnapshot:
|
||||
"""
|
||||
Logger state captured before a test configures CLI diagnostics.
|
||||
|
||||
:param handlers: Handlers present before the test started.
|
||||
:param level: Numeric logging level configured before the test started.
|
||||
:param propagate: Whether the logger propagated before the test started.
|
||||
"""
|
||||
|
||||
handlers: list[logging.Handler]
|
||||
level: int
|
||||
propagate: bool
|
||||
|
||||
|
||||
class _FailingRedirectedStderr:
|
||||
"""
|
||||
Stderr stub whose close path fails after exposing the original stream.
|
||||
|
||||
:param original: Terminal stderr stream that ``restore_stderr`` should
|
||||
restore before attempting to close the redirected stream.
|
||||
"""
|
||||
|
||||
def __init__(self, original: io.TextIOBase) -> None:
|
||||
"""
|
||||
Create the failing redirected stderr stub.
|
||||
|
||||
:param original: Terminal stderr stream saved for restoration.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
self._original_stderr = original
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Raise the close failure that ``restore_stderr`` must log.
|
||||
|
||||
:returns: ``None``.
|
||||
:raises OSError: Always, to exercise the diagnostics path.
|
||||
"""
|
||||
raise OSError("close failed")
|
||||
|
||||
|
||||
def _capture_logger_snapshots() -> dict[str, _LoggerSnapshot]:
|
||||
"""
|
||||
Capture package logger state mutated by ``setup_cli_logging``.
|
||||
|
||||
:returns: Snapshot keyed by logger name.
|
||||
"""
|
||||
snapshots: dict[str, _LoggerSnapshot] = {}
|
||||
for name in ("", "omnigent", "omnigent_ui_sdk", "databricks.sdk"):
|
||||
logger = logging.getLogger(name)
|
||||
snapshots[name] = _LoggerSnapshot(
|
||||
handlers=list(logger.handlers),
|
||||
level=logger.level,
|
||||
propagate=logger.propagate,
|
||||
)
|
||||
return snapshots
|
||||
|
||||
|
||||
def _restore_logger_snapshots(snapshots: dict[str, _LoggerSnapshot]) -> None:
|
||||
"""
|
||||
Restore package loggers after ``setup_cli_logging`` added file handlers.
|
||||
|
||||
:param snapshots: Logger state returned by
|
||||
:func:`_capture_logger_snapshots`.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
for name, snapshot in snapshots.items():
|
||||
logger = logging.getLogger(name)
|
||||
for handler in list(logger.handlers):
|
||||
logger.removeHandler(handler)
|
||||
if handler not in snapshot.handlers:
|
||||
handler.close()
|
||||
for handler in snapshot.handlers:
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(snapshot.level)
|
||||
logger.propagate = snapshot.propagate
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_cli_diagnostics(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> Iterator[None]:
|
||||
"""
|
||||
Isolate CLI diagnostics global logging state for a test.
|
||||
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:param tmp_path: Temporary home directory root for diagnostics logs.
|
||||
:returns: Iterator yielding control to the test body.
|
||||
"""
|
||||
snapshots = _capture_logger_snapshots()
|
||||
original_stderr = sys.stderr
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
yield
|
||||
cli_diagnostics.restore_stderr()
|
||||
sys.stderr = original_stderr
|
||||
_restore_logger_snapshots(snapshots)
|
||||
|
||||
|
||||
def test_redirect_stderr_to_log_redacts_direct_stderr_writes(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Redirected raw stderr writes must honor the diagnostics redaction contract.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
|
||||
cli_diagnostics.redirect_stderr_to_log()
|
||||
print("MY_API_KEY=super-secret-token", file=sys.stderr)
|
||||
sys.stderr.write("Authorization: Bearer sk-directsecret12345\n")
|
||||
sys.stderr.flush()
|
||||
cli_diagnostics.restore_stderr()
|
||||
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "super-secret-token" not in log_text, (
|
||||
f"redirected stderr leaked an API key into the CLI diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "sk-directsecret12345" not in log_text, (
|
||||
f"redirected stderr leaked an SDK token into the CLI diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "[REDACTED]" in log_text, (
|
||||
f"redirected stderr should preserve context with redacted values, got: {log_text!r}"
|
||||
)
|
||||
assert terminal_stderr.getvalue() == "", (
|
||||
f"redirected stderr should not paint into the terminal, got: "
|
||||
f"{terminal_stderr.getvalue()!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_stderr_returns_writes_to_original_terminal(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
``restore_stderr`` must return subsequent writes to the original stream.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
|
||||
cli_diagnostics.redirect_stderr_to_log()
|
||||
print("during-tui", file=sys.stderr)
|
||||
cli_diagnostics.restore_stderr()
|
||||
print("after-tui", file=sys.stderr)
|
||||
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "during-tui" in log_text, (
|
||||
f"stderr written during the TUI lifetime should land in the log: {log_text!r}"
|
||||
)
|
||||
assert "after-tui" not in log_text, (
|
||||
f"stderr written after restore should not keep landing in the log: {log_text!r}"
|
||||
)
|
||||
assert terminal_stderr.getvalue() == "after-tui\n", (
|
||||
f"stderr was not restored to the original terminal stream: {terminal_stderr.getvalue()!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_log_cli_error_hint_uses_original_stderr_when_redirected(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Fatal-error hints must stay visible even while TUI stderr is redirected.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
cli_diagnostics.redirect_stderr_to_log()
|
||||
|
||||
try:
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError as exc:
|
||||
cli_diagnostics.log_cli_error_hint(exc)
|
||||
|
||||
cli_diagnostics.restore_stderr()
|
||||
|
||||
hint = terminal_stderr.getvalue()
|
||||
assert hint == f"Details logged to {ctx.path}\n", (
|
||||
f"fatal-error hint should print to the original stderr stream, got: {hint!r}"
|
||||
)
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "Fatal CLI error: boom" in log_text, (
|
||||
f"fatal exception context was not written to the diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "Details logged to" not in log_text, (
|
||||
f"user-facing fatal-error hint should not be redirected into the log: {log_text!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_redirect_stderr_to_log_retargets_existing_logging_stderr_handlers(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Existing stderr-backed logging handlers must follow TUI stderr redirect.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
root = logging.getLogger()
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
handler = logging.StreamHandler(terminal_stderr)
|
||||
handler.setFormatter(logging.Formatter("%(levelname)s:%(name)s:%(message)s"))
|
||||
root.addHandler(handler)
|
||||
root.setLevel(logging.WARNING)
|
||||
databricks_logger = logging.getLogger("databricks.sdk")
|
||||
databricks_logger.handlers.clear()
|
||||
databricks_logger.setLevel(logging.WARNING)
|
||||
databricks_logger.propagate = True
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
|
||||
cli_diagnostics.redirect_stderr_to_log()
|
||||
databricks_logger.warning(
|
||||
"Databricks CLI v0.295.0 does not support --force-refresh "
|
||||
"(requires >= v0.296.0). The CLI's token cache may provide stale tokens."
|
||||
)
|
||||
cli_diagnostics.restore_stderr()
|
||||
databricks_logger.warning("after TUI")
|
||||
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
warning_line = (
|
||||
"WARNING:databricks.sdk:Databricks CLI v0.295.0 does not support --force-refresh"
|
||||
)
|
||||
assert warning_line in log_text, (
|
||||
f"stderr-backed third-party logging should land in the CLI log: {log_text!r}"
|
||||
)
|
||||
assert "after TUI" not in log_text, (
|
||||
f"logging handlers should be restored after the TUI exits: {log_text!r}"
|
||||
)
|
||||
assert terminal_stderr.getvalue() == "WARNING:databricks.sdk:after TUI\n", (
|
||||
f"third-party logging painted into the terminal during TUI redirect: "
|
||||
f"{terminal_stderr.getvalue()!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_redirect_stderr_logs_open_failures(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
``redirect_stderr_to_log`` must record failures instead of swallowing them.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
|
||||
def _raise_open_failure(*_args: object, **_kwargs: object) -> io.TextIOWrapper:
|
||||
"""
|
||||
Stand in for ``open`` when the redirected stderr file cannot be opened.
|
||||
|
||||
:param _args: Positional arguments passed by ``redirect_stderr_to_log``.
|
||||
:param _kwargs: Keyword arguments passed by ``redirect_stderr_to_log``.
|
||||
:returns: Never returns.
|
||||
:raises OSError: Always, to exercise the diagnostics path.
|
||||
"""
|
||||
raise OSError("open failed")
|
||||
|
||||
monkeypatch.setattr(cli_diagnostics, "open", _raise_open_failure, raising=False)
|
||||
|
||||
cli_diagnostics.redirect_stderr_to_log()
|
||||
|
||||
assert sys.stderr is terminal_stderr, (
|
||||
"redirect_stderr_to_log should leave stderr alone when opening the diagnostics file fails."
|
||||
)
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "Failed to redirect stderr to CLI log: open failed" in log_text, (
|
||||
f"stderr redirect setup failures must be captured in the diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "Traceback" in log_text, (
|
||||
f"stderr redirect setup failures should include traceback context: {log_text!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_restore_stderr_logs_close_failures(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
``restore_stderr`` must log close failures after restoring the terminal.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
terminal_stderr = io.StringIO()
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
monkeypatch.setattr(sys, "stderr", _FailingRedirectedStderr(terminal_stderr))
|
||||
|
||||
cli_diagnostics.restore_stderr()
|
||||
|
||||
assert sys.stderr is terminal_stderr, (
|
||||
"restore_stderr should restore the original terminal stream before "
|
||||
"closing the redirected stream."
|
||||
)
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "Failed to close redirected stderr: close failed" in log_text, (
|
||||
f"redirected stderr close failures must be captured in the diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "Traceback" in log_text, (
|
||||
f"redirected stderr close failures should include traceback context: {log_text!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_main_logs_click_exceptions(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""
|
||||
Click-handled command errors must still reach the diagnostics log.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:param capsys: Pytest capture fixture for terminal stderr.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
from omnigent import cli as cli_module
|
||||
|
||||
# An unsupported --harness is a deterministic ClickException trigger that
|
||||
# raises before any daemon/network work. (A bare `omnigent run` no longer
|
||||
# errors — it drops into first-run `configure harnesses` — so it can't be
|
||||
# the trigger here.)
|
||||
monkeypatch.setattr(sys, "argv", ["omnigent", "run", "--harness", "not-a-real-harness"])
|
||||
# Isolate from any real ~/.omnigent/config.yaml on the developer's machine.
|
||||
monkeypatch.setattr(cli_module, "_load_global_config", dict)
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cli_module.main()
|
||||
|
||||
assert exc_info.value.code == 1, (
|
||||
f"ClickException should preserve Click's exit code, got {exc_info.value.code!r}"
|
||||
)
|
||||
terminal = capsys.readouterr()
|
||||
assert "Error: Unsupported harness 'not-a-real-harness'" in terminal.err, (
|
||||
f"Click's normal user-facing error output changed: {terminal.err!r}"
|
||||
)
|
||||
path = cli_diagnostics.current_cli_log_path()
|
||||
assert path is not None, "main() should set up the active CLI diagnostics log."
|
||||
log_text = path.read_text(encoding="utf-8")
|
||||
assert "Click CLI error: Unsupported harness 'not-a-real-harness'" in log_text, (
|
||||
f"ClickException was not captured in the diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "Traceback" in log_text, (
|
||||
f"ClickException log entry should include traceback context: {log_text!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slash_command_exceptions_reach_cli_log(
|
||||
isolated_cli_diagnostics: None,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
REPL slash-command exceptions must be recorded in the CLI diagnostics log.
|
||||
|
||||
:param isolated_cli_diagnostics: Fixture isolating logging globals.
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:returns: ``None``.
|
||||
"""
|
||||
del isolated_cli_diagnostics
|
||||
from omnigent_ui_sdk import RichBlockFormatter
|
||||
|
||||
from omnigent.repl._repl import handle_slash_command
|
||||
from tests.repl.helpers import CapturingHost
|
||||
|
||||
class _SessionWithoutModelSetter:
|
||||
"""
|
||||
Session stub matching the broken adapter surface from the REPL.
|
||||
|
||||
It intentionally exposes ``model_override`` and ``is_streaming``
|
||||
but not ``set_model_override`` so ``/model <name>`` raises the
|
||||
production AttributeError this regression covers.
|
||||
"""
|
||||
|
||||
model_override: str | None = None
|
||||
is_streaming = False
|
||||
|
||||
terminal_stderr = io.StringIO()
|
||||
monkeypatch.setattr(sys, "stderr", terminal_stderr)
|
||||
ctx = cli_diagnostics.setup_cli_logging(["run", "agent.yaml"])
|
||||
host = CapturingHost()
|
||||
|
||||
await handle_slash_command(
|
||||
"/model openai/gpt-5.4-mini",
|
||||
_SessionWithoutModelSetter(), # type: ignore[arg-type]
|
||||
None, # type: ignore[arg-type]
|
||||
host,
|
||||
RichBlockFormatter(),
|
||||
)
|
||||
|
||||
assert "Error: '_SessionWithoutModelSetter' object has no attribute" in host.text, (
|
||||
f"slash-command failures should still render inline for the user: {host.text!r}"
|
||||
)
|
||||
log_text = ctx.path.read_text(encoding="utf-8")
|
||||
assert "Slash command failed: /model" in log_text, (
|
||||
f"slash-command failures must be captured in the diagnostics log: {log_text!r}"
|
||||
)
|
||||
assert "AttributeError" in log_text, (
|
||||
f"slash-command diagnostics should include the exception type: {log_text!r}"
|
||||
)
|
||||
assert "openai/gpt-5.4-mini" not in log_text, (
|
||||
f"slash-command diagnostics should not copy command arguments into the log: {log_text!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_safe_mtime_returns_zero_for_vanished_file(tmp_path: Path) -> None:
|
||||
"""A file present at glob time but gone before stat resolves to 0.0, not a raise."""
|
||||
real = tmp_path / "cli-real.log"
|
||||
real.write_text("x")
|
||||
assert cli_diagnostics._safe_mtime(real) > 0.0
|
||||
assert cli_diagnostics._safe_mtime(tmp_path / "cli-gone.log") == 0.0
|
||||
|
||||
|
||||
def test_prune_old_logs_survives_file_vanishing_mid_sort(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Concurrent ``omnigent run`` launches race to prune the same logs; a file
|
||||
globbed by one but deleted by the other must not crash the sort (previously a
|
||||
FileNotFoundError in the stat sort key aborted CLI startup)."""
|
||||
real = [tmp_path / f"cli-{i:03d}.log" for i in range(cli_diagnostics.MAX_LOG_FILES + 3)]
|
||||
for p in real:
|
||||
p.write_text("x")
|
||||
vanished = tmp_path / "cli-vanished.log" # globbed, then deleted by a peer run
|
||||
monkeypatch.setattr(Path, "glob", lambda self, pattern: [*real, vanished])
|
||||
|
||||
cli_diagnostics._prune_old_logs(tmp_path) # must not raise
|
||||
|
||||
surviving = [p for p in real if p.exists()]
|
||||
assert len(surviving) == cli_diagnostics.MAX_LOG_FILES # newest kept, oldest pruned
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
"""Tests for the OpenCode ``omni setup`` default-model picker helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import omnigent.cli as cli
|
||||
from omnigent.cli import _list_opencode_models, _load_global_config, _set_opencode_default_model
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _isolated_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
|
||||
"""Point the global config at a tmp file so saves don't touch ``~/.omnigent``."""
|
||||
path = tmp_path / "config.yaml"
|
||||
monkeypatch.setattr("omnigent.cli._GLOBAL_CONFIG_PATH", path)
|
||||
return path
|
||||
|
||||
|
||||
def _fake_spec() -> SimpleNamespace:
|
||||
return SimpleNamespace(binary="opencode")
|
||||
|
||||
|
||||
# ── _list_opencode_models ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_models_parses_nonblank_lines(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.subprocess,
|
||||
"run",
|
||||
lambda *a, **k: subprocess.CompletedProcess(
|
||||
a, 0, stdout="anthropic/claude-sonnet-4-5\nopenai/gpt-5.5\n\n \n", stderr=""
|
||||
),
|
||||
)
|
||||
assert _list_opencode_models() == ["anthropic/claude-sonnet-4-5", "openai/gpt-5.5"]
|
||||
|
||||
|
||||
def test_list_models_empty_when_cli_absent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: None
|
||||
)
|
||||
assert _list_opencode_models() == []
|
||||
|
||||
|
||||
def test_list_models_empty_on_subprocess_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
"omnigent.onboarding.harness_install.harness_install_spec", lambda _key: _fake_spec()
|
||||
)
|
||||
|
||||
def _boom(*_a: object, **_k: object) -> object:
|
||||
raise OSError("no binary")
|
||||
|
||||
monkeypatch.setattr(cli.subprocess, "run", _boom)
|
||||
assert _list_opencode_models() == []
|
||||
|
||||
|
||||
# ── _set_opencode_default_model ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_set_default_model_persists_choice(
|
||||
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
cli, "_list_opencode_models", lambda: ["anthropic/claude-sonnet-4-5", "x/y"]
|
||||
)
|
||||
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 0)
|
||||
status = _set_opencode_default_model(current=None)
|
||||
assert status == "✓ default model: anthropic/claude-sonnet-4-5"
|
||||
assert _load_global_config()["opencode_model"] == "anthropic/claude-sonnet-4-5"
|
||||
|
||||
|
||||
def test_set_default_model_clear_unsets(
|
||||
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
|
||||
) -> None:
|
||||
cli._save_global_config({"opencode_model": "x/y"})
|
||||
monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"])
|
||||
# options == ["a/b", "Clear default ..."]; index 1 is the clear row.
|
||||
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: 1)
|
||||
status = _set_opencode_default_model(current="x/y")
|
||||
assert status == "✓ default model cleared"
|
||||
assert "opencode_model" not in _load_global_config()
|
||||
|
||||
|
||||
def test_set_default_model_cancel_is_noop(
|
||||
monkeypatch: pytest.MonkeyPatch, _isolated_config: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(cli, "_list_opencode_models", lambda: ["a/b"])
|
||||
monkeypatch.setattr("omnigent.onboarding.interactive.select", lambda *a, **k: -1)
|
||||
assert _set_opencode_default_model(current=None) is None
|
||||
assert _load_global_config() == {}
|
||||
|
||||
|
||||
def test_set_default_model_no_models_short_circuits(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(cli, "_list_opencode_models", list)
|
||||
called = False
|
||||
|
||||
def _select(*_a: object, **_k: object) -> int:
|
||||
nonlocal called
|
||||
called = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("omnigent.onboarding.interactive.select", _select)
|
||||
status = _set_opencode_default_model(current=None)
|
||||
assert status is not None and status.startswith("✗")
|
||||
assert called is False # never prompts when there's nothing to pick
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Unit tests for ``omnigent pane-picker``'s argv normalization.
|
||||
|
||||
The picker is exec'd as the new tmux pane's initial command after a
|
||||
``pane-split``. It reads the parent pane's launch context, strips
|
||||
flags that don't make sense for a sibling pane (resume modes,
|
||||
one-shot prompts), then ``os.execvp``\\s into a fresh REPL.
|
||||
|
||||
These tests pin the strip helpers — the real exec path is exercised
|
||||
manually in the design's § 6 phase 5 verification.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent.cli import _strip_one_shot_flags, _strip_resume_flags
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argv", "expected"),
|
||||
[
|
||||
# Bare ``--resume`` (picker mode): drop the single token.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--profile", "prf", "--resume"],
|
||||
["omnigent", "run", "a.yaml", "--profile", "prf"],
|
||||
),
|
||||
# ``--resume`` with a conversation id: drop both tokens.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--resume", "conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``--resume=conv_id`` long-form: drop the combined token.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--resume=conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``-r`` short form, no value: drop the single token.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "-r"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``-r conv_id`` short form with value: drop both tokens.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "-r", "conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# Continue forms (always boolean).
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "-c"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--continue"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# Legacy ``--session`` / ``-s`` shapes still strip cleanly so
|
||||
# a parent argv saved before the resume/session consolidation
|
||||
# sanitizes without errors.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--session", "conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "-s", "conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--session=conv_abc"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# Multiple resume flags in one argv: all dropped.
|
||||
(
|
||||
[
|
||||
"omnigent",
|
||||
"run",
|
||||
"a.yaml",
|
||||
"--profile",
|
||||
"prf",
|
||||
"--resume",
|
||||
"--continue",
|
||||
"--resume",
|
||||
"conv_x",
|
||||
],
|
||||
["omnigent", "run", "a.yaml", "--profile", "prf"],
|
||||
),
|
||||
# Non-resume flags survive intact even when sandwiched
|
||||
# between resume flags. Bare ``--resume`` followed by
|
||||
# another flag must NOT swallow that flag as its value.
|
||||
(
|
||||
[
|
||||
"omnigent",
|
||||
"run",
|
||||
"a.yaml",
|
||||
"--resume",
|
||||
"--profile",
|
||||
"prf",
|
||||
"--resume",
|
||||
"x",
|
||||
"--model",
|
||||
"m",
|
||||
],
|
||||
["omnigent", "run", "a.yaml", "--profile", "prf", "--model", "m"],
|
||||
),
|
||||
# Empty argv → empty.
|
||||
([], []),
|
||||
# Non-resume argv: identity.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--model", "m", "--profile", "prf"],
|
||||
["omnigent", "run", "a.yaml", "--model", "m", "--profile", "prf"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_strip_resume_flags(argv: list[str], expected: list[str]) -> None:
|
||||
"""
|
||||
The strip helper must remove every shape of resume flag
|
||||
(bare ``--resume`` for the picker, ``--resume <id>`` for an
|
||||
explicit pin, the ``--resume=<id>`` long form, short ``-r``
|
||||
variants, and ``--continue`` / ``-c``) and leave every other
|
||||
flag untouched. Legacy ``--session`` / ``-s`` are still
|
||||
handled for backwards compatibility with parent argvs saved
|
||||
before the consolidation.
|
||||
|
||||
Claim: each input → its expected pruned argv. Live regression
|
||||
that prompted this helper: the live pane's argv had
|
||||
``--resume``, the click ``run`` subcommand at the time didn't
|
||||
accept that option, so exec'ing the parent's verbatim argv
|
||||
exited with a click ``Error: No such option: --resume``
|
||||
immediately, closing the new pane within seconds.
|
||||
"""
|
||||
assert _strip_resume_flags(argv) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("argv", "expected"),
|
||||
[
|
||||
# ``-p`` short form: drop the flag and its value.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "-p", "hello there"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``--prompt`` long form.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--prompt", "hello"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``--prompt=value``.
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--prompt=hello"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
# ``--system-prompt`` (note: spans both an arg-bearing flag
|
||||
# and a similarly named flag — make sure we don't strip
|
||||
# ``--system`` or ``--prompt-foo`` accidentally).
|
||||
(
|
||||
["omnigent", "run", "a.yaml", "--system-prompt", "be terse"],
|
||||
["omnigent", "run", "a.yaml"],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_strip_one_shot_flags(argv: list[str], expected: list[str]) -> None:
|
||||
"""
|
||||
One-shot flags (``-p``, ``--prompt``, ``--system-prompt``) tied
|
||||
to the parent's first turn must be removed before exec'ing in
|
||||
the new pane — otherwise the new pane silently auto-sends the
|
||||
parent's prompt, surprising the user.
|
||||
|
||||
Claim: every variant of one-shot flag is removed; everything
|
||||
else passes through.
|
||||
"""
|
||||
assert _strip_one_shot_flags(argv) == expected
|
||||
@@ -0,0 +1,325 @@
|
||||
"""Tests for ``omnigent._runner_startup`` (startup UX helpers).
|
||||
|
||||
These cover the two pieces of UX added when local-runner startup
|
||||
fails or stalls:
|
||||
|
||||
1. ``format_runner_log_tail`` — the failure-message helper that
|
||||
surfaces the captured runner log path and a tail of its
|
||||
contents on the ``ClickException``.
|
||||
2. ``runner_startup_progress`` — the context manager that wraps
|
||||
the runner-spawn → registration-wait window with a rich
|
||||
spinner on a TTY, falling back to plain ``click.echo`` lines
|
||||
off-TTY.
|
||||
|
||||
The behavior assertions matter because a regression in either
|
||||
helper degrades the diagnosis story for "my runner won't start"
|
||||
back to the opaque pre-fix state ("Local runner did not register
|
||||
within 60s.").
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from omnigent._runner_startup import (
|
||||
_NO_SPINNER_ENV_VAR,
|
||||
STARTUP_PHASE_LABELS,
|
||||
_spinner_enabled,
|
||||
format_runner_log_tail,
|
||||
runner_startup_progress,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# format_runner_log_tail
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_runner_log_tail_none_path_returns_empty_string() -> None:
|
||||
"""``log_path=None`` means no log was captured; suppress the block.
|
||||
|
||||
Callers concatenate the return value onto a ``ClickException``
|
||||
message, so an empty string is the right signal that nothing
|
||||
should be appended.
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
assert format_runner_log_tail(None) == ""
|
||||
|
||||
|
||||
def test_format_runner_log_tail_surfaces_path_only(tmp_path) -> None:
|
||||
"""A real log path is surfaced as a single ``Runner log:`` line.
|
||||
|
||||
The helper deliberately does NOT include log contents inline:
|
||||
a wall of log lines drowns the actual error summary, and the
|
||||
log file is one ``cat`` away once the user has the path. This
|
||||
test pins the policy so a future regression that re-introduces
|
||||
inline log tailing fails loud.
|
||||
|
||||
:param tmp_path: Pytest tmp dir fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
log = tmp_path / "runner.log"
|
||||
log.write_text("ERROR: tunnel rejected (HTTP 401)\n")
|
||||
out = format_runner_log_tail(log)
|
||||
# The path is named explicitly so the user can ``cat`` it.
|
||||
assert out == f"\nRunner log: {log}"
|
||||
# Content of the log MUST NOT leak into the error message —
|
||||
# that was the previous, overwhelming behavior.
|
||||
assert "ERROR: tunnel rejected" not in out
|
||||
|
||||
|
||||
def test_format_runner_log_tail_does_not_require_existing_file(tmp_path) -> None:
|
||||
"""The helper does not stat the path before surfacing it.
|
||||
|
||||
The runner may fail before its log file actually lands on
|
||||
disk (fork error, bad cwd, immediate import crash). We still
|
||||
want to surface the configured log location so the user can
|
||||
see where we *expected* the log to live — "file is missing"
|
||||
is itself useful diagnostic information.
|
||||
|
||||
:param tmp_path: Pytest tmp dir fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
missing = tmp_path / "never-existed.log"
|
||||
assert format_runner_log_tail(missing) == f"\nRunner log: {missing}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _spinner_enabled (TTY + env-var policy)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_spinner_enabled_requires_a_tty() -> None:
|
||||
"""Off a TTY (CI logs, piped stderr) we must not emit a spinner.
|
||||
|
||||
A spinner's cursor-rewrites would render as raw escape codes
|
||||
in a captured log, defeating the point of capturing it.
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
assert _spinner_enabled(stream_isatty=False, env={}) is False
|
||||
|
||||
|
||||
def test_spinner_enabled_on_tty_default() -> None:
|
||||
"""On a TTY with no opt-out, the spinner renders.
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
assert _spinner_enabled(stream_isatty=True, env={}) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"])
|
||||
def test_spinner_disabled_by_env_var(value: str) -> None:
|
||||
"""Any truthy ``OMNIGENT_NO_SPINNER`` value disables the spinner.
|
||||
|
||||
Lets users with mis-detecting terminals (tmux quirks, ssh into
|
||||
bare containers) force the plain-echo fallback without code
|
||||
changes.
|
||||
|
||||
:param value: Truthy spelling under test.
|
||||
:returns: None.
|
||||
"""
|
||||
assert _spinner_enabled(stream_isatty=True, env={_NO_SPINNER_ENV_VAR: value}) is False
|
||||
|
||||
|
||||
def test_spinner_not_disabled_by_falsy_env_var() -> None:
|
||||
"""``OMNIGENT_NO_SPINNER=0`` (or empty) leaves the spinner on.
|
||||
|
||||
Mirrors typical "0 is off, 1 is on" UX so users do not
|
||||
accidentally suppress the spinner by exporting the variable
|
||||
with the value they meant as a disable.
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
assert _spinner_enabled(stream_isatty=True, env={_NO_SPINNER_ENV_VAR: "0"}) is True
|
||||
assert _spinner_enabled(stream_isatty=True, env={_NO_SPINNER_ENV_VAR: ""}) is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# runner_startup_progress
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_runner_startup_progress_plain_mode_prints_to_stderr(
|
||||
capsys,
|
||||
) -> None:
|
||||
"""Off-TTY: initial + every ``.update`` call go to stderr.
|
||||
|
||||
Plain-mode lines stay in scrollback so a CI log capture
|
||||
reflects the same phase transitions a user would see
|
||||
interactively.
|
||||
|
||||
:param capsys: Pytest stdio capture fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
with runner_startup_progress(
|
||||
initial_message="Starting local runner\u2026",
|
||||
enabled=False,
|
||||
) as p:
|
||||
p.update("Waiting for runner to register with example.com\u2026")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# Nothing leaks to stdout (one-shot ``-p`` callers pipe stdout
|
||||
# to consumers that should see only the agent's reply).
|
||||
assert captured.out == ""
|
||||
# Both phases land on stderr with the standard prefix.
|
||||
assert "omnigent: Starting local runner" in captured.err
|
||||
assert "omnigent: Waiting for runner to register" in captured.err
|
||||
|
||||
|
||||
def test_runner_startup_progress_plain_mode_does_not_swallow_exceptions(
|
||||
capsys,
|
||||
) -> None:
|
||||
"""An exception inside the body propagates with no extra wrapping.
|
||||
|
||||
The context manager only owns the renderer — it must not turn
|
||||
a real runner-startup failure into a generic "context exited
|
||||
with error" message.
|
||||
|
||||
:param capsys: Pytest stdio capture fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
with runner_startup_progress(
|
||||
initial_message="Starting\u2026",
|
||||
enabled=False,
|
||||
):
|
||||
raise RuntimeError("boom")
|
||||
# The initial message still printed; the exception did not
|
||||
# suppress it.
|
||||
assert "omnigent: Starting" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_runner_startup_progress_rich_mode_writes_only_to_stderr(
|
||||
capsys,
|
||||
) -> None:
|
||||
"""Spinner output goes to stderr; stdout stays clean.
|
||||
|
||||
Stdout cleanliness matters for ``run --server -p "\u2026"`` where the
|
||||
one-shot prints the agent's reply to stdout; we cannot let a
|
||||
spinner contaminate that stream.
|
||||
|
||||
:param capsys: Pytest stdio capture fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
with runner_startup_progress(
|
||||
initial_message="Starting\u2026",
|
||||
enabled=True,
|
||||
) as p:
|
||||
p.update("Waiting\u2026")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
# Rich's transient spinner emits ANSI escape sequences while
|
||||
# running, but on successful exit the line is cleared. We do
|
||||
# NOT assert the post-exit stream is byte-empty (a CR or
|
||||
# cursor-restore sequence may remain). The contract that
|
||||
# matters for the user is: stdout is clean, and the visible
|
||||
# cursor row is empty when the context exits.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cold-start phase labels (run / chat startup UX)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize("label", STARTUP_PHASE_LABELS)
|
||||
def test_startup_phase_labels_avoid_internal_jargon(label: str) -> None:
|
||||
"""Cold-start labels must stay plain-language, not architecture terms.
|
||||
|
||||
The whole point of the ``run`` / ``chat`` startup spinner is to make
|
||||
the silent cold-start gap read as ordinary forward motion to a user
|
||||
who does not (and should not need to) know the framework's internals.
|
||||
A regression that surfaces an internal term — "Waiting for runner
|
||||
tunnel registration…", "Spawning host daemon…" — defeats that, so
|
||||
pin the intent: none of the labels may contain an internal term.
|
||||
|
||||
A failure here means a phase label was changed to leak an
|
||||
architecture word; rename it to something a non-developer user would
|
||||
understand.
|
||||
|
||||
:param label: One cold-start phase label under test, e.g.
|
||||
``"Starting the local server…"``.
|
||||
:returns: None.
|
||||
"""
|
||||
# Internal nouns that mean nothing to an end user staring at startup.
|
||||
# "server" is intentionally NOT here: "local server" is user-facing
|
||||
# vocabulary (it is the ``omnigent server`` they may run directly).
|
||||
jargon = {
|
||||
"daemon",
|
||||
"host",
|
||||
"runner",
|
||||
"tunnel",
|
||||
"websocket",
|
||||
"socket",
|
||||
"rpc",
|
||||
"bundle",
|
||||
"registration",
|
||||
"executor",
|
||||
"subprocess",
|
||||
}
|
||||
words = set(re.findall(r"[a-z]+", label.lower()))
|
||||
leaked = words & jargon
|
||||
assert not leaked, (
|
||||
f"Startup label {label!r} leaks internal jargon {sorted(leaked)}. "
|
||||
"Cold-start labels are shown to end users; keep them plain-language."
|
||||
)
|
||||
|
||||
|
||||
def test_startup_phase_labels_render_in_order_plain_mode(capsys) -> None:
|
||||
"""Driving the spinner with the real labels emits them in sequence.
|
||||
|
||||
This is the contract the ``cli._ensure_backend`` /
|
||||
``chat._prepare_chat_session_via_daemon`` wiring relies on: each
|
||||
``update`` with a ``STARTUP_PHASE_*`` constant produces a distinct
|
||||
stderr line, in call order, with stdout untouched (so one-shot
|
||||
``-p`` stdout stays clean).
|
||||
|
||||
A failure means the progress helper stopped forwarding ``update``
|
||||
calls to stderr in order, or started leaking onto stdout.
|
||||
|
||||
:param capsys: Pytest stdio capture fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
with runner_startup_progress(
|
||||
initial_message=STARTUP_PHASE_LABELS[0],
|
||||
enabled=False,
|
||||
) as p:
|
||||
for label in STARTUP_PHASE_LABELS[1:]:
|
||||
p.update(label)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# stdout stays clean — the spinner is a stderr-only affordance.
|
||||
assert captured.out == ""
|
||||
# Every label landed on stderr, and in the order it was emitted (the
|
||||
# find-index of each label is strictly increasing). A wrong order
|
||||
# would mean the helper buffered/reordered updates.
|
||||
indices = [captured.err.find(label) for label in STARTUP_PHASE_LABELS]
|
||||
assert all(i != -1 for i in indices), (
|
||||
f"Not all labels reached stderr. Indices: {indices}. stderr was:\n{captured.err}"
|
||||
)
|
||||
assert indices == sorted(indices), (
|
||||
f"Labels rendered out of order. Indices: {indices}. stderr was:\n{captured.err}"
|
||||
)
|
||||
|
||||
|
||||
def test_runner_startup_progress_auto_detects_tty(monkeypatch, capsys) -> None:
|
||||
"""``enabled=None`` consults ``sys.stderr.isatty()``.
|
||||
|
||||
Production callers leave ``enabled`` unset; this test pins the
|
||||
contract that a non-TTY stderr in pytest's captured environment
|
||||
falls through to plain-echo mode.
|
||||
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:param capsys: Pytest stdio capture fixture.
|
||||
:returns: None.
|
||||
"""
|
||||
# Under pytest capture stderr.isatty() is already False, but be
|
||||
# explicit so the test does not depend on capture mode.
|
||||
monkeypatch.setattr(sys.stderr, "isatty", lambda: False, raising=False)
|
||||
with runner_startup_progress(initial_message="Starting\u2026"):
|
||||
pass
|
||||
assert "omnigent: Starting" in capsys.readouterr().err
|
||||
@@ -0,0 +1,381 @@
|
||||
"""Tests for the server-lifecycle CLI: ``server start/stop/status`` and top-level ``stop``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
from omnigent.cli import _HostDaemonRecord, _SessionPagesResult, cli
|
||||
from omnigent.host.local_server import LocalServerInfo, LocalServerStartup
|
||||
|
||||
|
||||
def _record(
|
||||
target: str = "local", *, mode: str = "local", pid: int = 999_999
|
||||
) -> _HostDaemonRecord:
|
||||
"""Build a real daemon record for stubbing the registry.
|
||||
|
||||
:param target: Daemon target — ``"local"`` or a server URL.
|
||||
:param mode: ``"local"`` or ``"server"``.
|
||||
:param pid: Recorded daemon PID, e.g. ``999999``.
|
||||
:returns: A populated :class:`_HostDaemonRecord`.
|
||||
"""
|
||||
return _HostDaemonRecord(
|
||||
pid=pid,
|
||||
target=target,
|
||||
mode=mode,
|
||||
server_url=None if mode == "local" else target,
|
||||
log_path=None,
|
||||
started_at=0,
|
||||
)
|
||||
|
||||
|
||||
# ── server status ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_server_status_not_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server status`` reports not-running when no background server is recorded."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(running=False, pid=None, port=None, url=None),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "status"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "not running" in result.output
|
||||
|
||||
|
||||
def test_server_status_running_reports_details(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server status`` prints url/pid/port, the live-session count, and daemon-attached state."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(running=True, pid=4321, port=8123, url="http://127.0.0.1:8123"),
|
||||
)
|
||||
# A record exists → "host daemon attached: yes".
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: _record())
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._fetch_session_pages",
|
||||
lambda **kwargs: _SessionPagesResult(sessions=[], error=None),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "status"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "http://127.0.0.1:8123" in result.output
|
||||
assert "pid 4321" in result.output
|
||||
assert "live sessions: 0" in result.output # empty fetch → 0 live sessions
|
||||
assert "host daemon attached: yes" in result.output
|
||||
|
||||
|
||||
def test_server_status_json(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server status --json`` emits the structured fields incl. the log path."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(
|
||||
running=True,
|
||||
pid=4321,
|
||||
port=8123,
|
||||
url="http://127.0.0.1:8123",
|
||||
log_path=Path("/tmp/.omnigent/logs/server/local-server-ab12.log"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._fetch_session_pages",
|
||||
lambda **kwargs: _SessionPagesResult(sessions=[], error=None),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "status", "--json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert json.loads(result.output) == {
|
||||
"running": True,
|
||||
"pid": 4321,
|
||||
"port": 8123,
|
||||
"url": "http://127.0.0.1:8123",
|
||||
"log_path": "/tmp/.omnigent/logs/server/local-server-ab12.log",
|
||||
"live_sessions": 0,
|
||||
"daemon_attached": False,
|
||||
}
|
||||
|
||||
|
||||
def test_server_status_text_reports_log_path(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server status`` (text) names the running server's captured log file."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(
|
||||
running=True,
|
||||
pid=4321,
|
||||
port=8123,
|
||||
url="http://127.0.0.1:8123",
|
||||
log_path=Path.home() / ".omnigent" / "logs" / "server" / "local-server-ab12.log",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._fetch_session_pages",
|
||||
lambda **kwargs: _SessionPagesResult(sessions=[], error=None),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "status"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "running at http://127.0.0.1:8123" in result.output
|
||||
assert "log: ~/.omnigent/logs/server/local-server-ab12.log" in result.output
|
||||
|
||||
|
||||
def test_server_status_session_count_failure_is_graceful(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A failed session fetch leaves the count unknown rather than erroring the command."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(running=True, pid=1, port=8123, url="http://127.0.0.1:8123"),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
|
||||
def _boom(**kwargs: object) -> _SessionPagesResult:
|
||||
raise ClickException("server unreachable")
|
||||
|
||||
monkeypatch.setattr("omnigent.cli._fetch_session_pages", _boom)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "status"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "running at http://127.0.0.1:8123" in result.output
|
||||
assert "live sessions:" not in result.output # count omitted on fetch failure
|
||||
|
||||
|
||||
# ── server start ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_server_start_spawns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server start`` reports the URL and exact log file of a spawned server."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.ensure_local_omnigent_server",
|
||||
lambda: LocalServerStartup(
|
||||
url="http://127.0.0.1:8123",
|
||||
spawned=True,
|
||||
log_path=Path.home() / ".omnigent" / "logs" / "server" / "local-server-ab12.log",
|
||||
),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "start"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Started background server at http://127.0.0.1:8123" in result.output
|
||||
# The exact captured-log file is surfaced so the detached server isn't a
|
||||
# black box — collapsed to ``~`` for readability.
|
||||
assert "log: ~/.omnigent/logs/server/local-server-ab12.log" in result.output
|
||||
|
||||
|
||||
def test_server_start_reuses(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server start`` reports reuse and the reused server's log file."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.ensure_local_omnigent_server",
|
||||
lambda: LocalServerStartup(
|
||||
url="http://127.0.0.1:8123",
|
||||
spawned=False,
|
||||
log_path=Path.home() / ".omnigent" / "logs" / "server" / "local-server-cd34.log",
|
||||
),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "start"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "already running at http://127.0.0.1:8123" in result.output
|
||||
# Even a reused server (one this invocation didn't spawn) names its log,
|
||||
# read back from the sidecar.
|
||||
assert "log: ~/.omnigent/logs/server/local-server-cd34.log" in result.output
|
||||
|
||||
|
||||
def test_server_start_omits_log_when_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No log line when the running server has no captured-log file.
|
||||
|
||||
A foreground ``omnigent server`` streams logs to its terminal, so a
|
||||
reuse of it carries ``log_path=None`` and ``server start`` must not print
|
||||
a bogus or empty ``log:`` line.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.ensure_local_omnigent_server",
|
||||
lambda: LocalServerStartup(url="http://127.0.0.1:8123", spawned=False, log_path=None),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "start"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "log:" not in result.output
|
||||
|
||||
|
||||
# ── server stop ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_server_stop_stops_server_and_local_daemon(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server stop`` terminates the local daemon, then stops the background server."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_url_if_healthy", lambda: "http://127.0.0.1:8123"
|
||||
)
|
||||
local_record = _record()
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._find_daemon_record",
|
||||
lambda target: local_record if target == "local" else None,
|
||||
)
|
||||
terminated: list[_HostDaemonRecord] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._terminate_daemon",
|
||||
lambda record, *, force: terminated.append(record),
|
||||
)
|
||||
stop_server = Mock()
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", stop_server)
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Stopped the background server." in result.output
|
||||
assert terminated == [local_record] # the local daemon was terminated
|
||||
stop_server.assert_called_once_with() # and the server stopped
|
||||
|
||||
|
||||
def test_server_stop_no_server_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``server stop`` reports nothing running when no background server exists."""
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
stop_server = Mock()
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", stop_server)
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "No background server is running." in result.output
|
||||
stop_server.assert_called_once_with() # idempotent: still clears any stale pidfile
|
||||
|
||||
|
||||
# ── top-level stop ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_stop_terminates_all_daemons_and_server(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``stop`` terminates every daemon and stops the background server."""
|
||||
records = [_record("local"), _record("https://example.com", mode="server")]
|
||||
monkeypatch.setattr("omnigent.cli._list_daemon_records", lambda: records)
|
||||
terminated: list[_HostDaemonRecord] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._terminate_daemon",
|
||||
lambda record, *, force: terminated.append(record),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_url_if_healthy", lambda: "http://127.0.0.1:8123"
|
||||
)
|
||||
stop_server = Mock()
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", stop_server)
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert terminated == records # both daemons terminated
|
||||
stop_server.assert_called_once_with()
|
||||
assert "Stopped 2 daemon(s) and the background server." in result.output
|
||||
|
||||
|
||||
def test_stop_nothing_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``stop`` reports nothing to stop when no daemons or server exist."""
|
||||
monkeypatch.setattr("omnigent.cli._list_daemon_records", list)
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", Mock())
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Nothing to stop." in result.output
|
||||
|
||||
|
||||
def test_stop_surfaces_failures_and_suggests_force(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A stubborn daemon makes ``stop`` exit nonzero and suggest ``--force``."""
|
||||
records = [_record("local"), _record("https://example.com", mode="server")]
|
||||
monkeypatch.setattr("omnigent.cli._list_daemon_records", lambda: records)
|
||||
|
||||
def _terminate(record: _HostDaemonRecord, *, force: bool) -> None:
|
||||
if record.target == "local":
|
||||
raise ClickException(f"daemon {record.pid} did not exit")
|
||||
|
||||
monkeypatch.setattr("omnigent.cli._terminate_daemon", _terminate)
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", Mock())
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["stop"])
|
||||
|
||||
assert result.exit_code != 0 # the stubborn daemon surfaces as a failure
|
||||
assert "retry with --force" in result.output
|
||||
|
||||
|
||||
def test_stop_clears_stale_legacy_host_pid(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""``stop`` clears a stale legacy ``host.pid`` (tracked only by the legacy pidfile).
|
||||
|
||||
Regression for the phantom where ``_delete_daemon_record`` removed only the
|
||||
JSON record, leaving ``host.pid`` to reappear on every subsequent ``stop``.
|
||||
Uses a dead PID so termination falls straight through to record deletion.
|
||||
"""
|
||||
host_pid = tmp_path / "host.pid"
|
||||
# 2147483647 is not a real PID, so _pid_alive() is False → delete-only path.
|
||||
host_pid.write_text("2147483647\nhttps://stale.example\n")
|
||||
monkeypatch.setattr("omnigent.cli._HOST_PID_PATH", host_pid)
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", Mock())
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: None)
|
||||
|
||||
first = CliRunner().invoke(cli, ["stop"])
|
||||
assert first.exit_code == 0, first.output
|
||||
assert not host_pid.exists() # the legacy pidfile is cleared
|
||||
|
||||
second = CliRunner().invoke(cli, ["stop"])
|
||||
assert "Nothing to stop." in second.output # phantom does not reappear
|
||||
|
||||
|
||||
def test_stop_reports_untracked_orphan_server(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""``stop`` sweeps and reports an orphaned server the pidfile lost track of.
|
||||
|
||||
Reproduces the reported symptom: no daemons, no pidfile-tracked server,
|
||||
yet a live server lingers on :6767. The off-switch must stop it (via
|
||||
:func:`stop_untracked_local_server`) and say so — not "Nothing to stop."
|
||||
"""
|
||||
monkeypatch.setattr("omnigent.cli._list_daemon_records", list)
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", Mock())
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: 93359)
|
||||
|
||||
result = CliRunner().invoke(cli, ["stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "untracked server on :6767 (pid 93359)" in result.output
|
||||
assert "Nothing to stop." not in result.output
|
||||
|
||||
|
||||
def test_server_stop_finds_untracked_orphan_when_pidfile_lost(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``server stop`` reports success when only an untracked orphan is found.
|
||||
|
||||
The pidfile is gone (``local_server_url_if_healthy`` → ``None``), but a
|
||||
live server is still on :6767. Previously this printed "No background
|
||||
server is running" while the server kept running; now the orphan sweep
|
||||
catches it.
|
||||
"""
|
||||
monkeypatch.setattr("omnigent.cli.local_server_url_if_healthy", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli._find_daemon_record", lambda target: None)
|
||||
monkeypatch.setattr("omnigent.cli.stop_local_omnigent_server", Mock())
|
||||
monkeypatch.setattr("omnigent.cli.stop_untracked_local_server", lambda: 93359)
|
||||
|
||||
result = CliRunner().invoke(cli, ["server", "stop"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Stopped the background server." in result.output
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,526 @@
|
||||
"""Tests for the ``omni upgrade`` command (omnigent.cli.upgrade)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from omnigent.cli import cli
|
||||
from omnigent.host.local_server import LocalServerInfo
|
||||
from omnigent.update_check import _InstalledWheelInfo
|
||||
|
||||
|
||||
def _uv_registry_info() -> _InstalledWheelInfo:
|
||||
"""A registry uv-tool install → ``uv tool upgrade omnigent`` (runnable)."""
|
||||
return _InstalledWheelInfo(
|
||||
install_time_epoch=0.0,
|
||||
installer="uv",
|
||||
vcs_url=None,
|
||||
commit_sha=None,
|
||||
is_editable=False,
|
||||
package_version="0.1.0",
|
||||
detected_installer="uv",
|
||||
)
|
||||
|
||||
|
||||
def _git_install_info() -> _InstalledWheelInfo:
|
||||
"""A git/VCS uv-tool install → ``uv tool install --reinstall git+…``."""
|
||||
return _InstalledWheelInfo(
|
||||
install_time_epoch=0.0,
|
||||
installer="uv",
|
||||
vcs_url="git+https://github.com/omnigent-ai/omnigent.git",
|
||||
commit_sha="a" * 40,
|
||||
is_editable=False,
|
||||
package_version="0.1.0",
|
||||
detected_installer="uv",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def _wheel_install(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Make the running interpreter look like a registry uv-tool install.
|
||||
|
||||
Pins the resolved versions and stubs the install-shape detectors so
|
||||
every upgrade test starts from "installed v0.1.0 via uv, not a clone".
|
||||
The PyPI lookup and the server-stop side effects are stubbed per test.
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
monkeypatch.setattr(importlib.metadata, "version", lambda _name: "0.1.0")
|
||||
monkeypatch.setattr("omnigent.update_check._find_repo_root", lambda: None)
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _uv_registry_info)
|
||||
# Neutralize the process side effects unless a test opts in to assert them.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(running=False, pid=None, port=None, url=None),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._stop_local_server_and_daemon", lambda *, force: False)
|
||||
|
||||
|
||||
def test_upgrade_up_to_date(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
|
||||
"""Latest == installed → reports up to date, exit 0, nothing stopped/run."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.1.0")
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("upgrade command ran while already up to date")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "up to date" in result.output
|
||||
assert "0.1.0" in result.output
|
||||
|
||||
|
||||
def test_upgrade_check_reports_newer_and_exits_nonzero(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``--check`` with a newer release → prints the delta and exits non-zero, no upgrade."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("--check must not run the upgrade")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--check"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "v0.1.0 → v0.2.0" in result.output
|
||||
|
||||
|
||||
def test_upgrade_runs_installer_and_drains_first(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""A newer release → drain (no force), stop the server, run the uv command."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
|
||||
events: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._wait_for_local_sessions_to_drain", lambda: events.append("drained")
|
||||
)
|
||||
|
||||
def _stop(*, force: bool) -> bool:
|
||||
events.append(f"stop(force={force})")
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("omnigent.cli._stop_local_server_and_daemon", _stop)
|
||||
|
||||
ran: list[str] = []
|
||||
|
||||
def _run(command: str, _console: object) -> int:
|
||||
ran.append(command)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _run)
|
||||
# The post-upgrade verification re-reads the installed version in a fresh
|
||||
# subprocess; stub it to report the version the installer "moved" to.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.2.0", None)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Drain happened before the stop, before the install ran.
|
||||
assert events == ["drained", "stop(force=False)"]
|
||||
assert ran == ["uv tool upgrade omnigent"]
|
||||
assert "Upgraded to v0.2.0" in result.output
|
||||
|
||||
|
||||
def test_upgrade_force_skips_drain_and_force_stops(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``--force`` skips the drain wait and force-stops the server."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
|
||||
def _no_drain() -> None:
|
||||
raise AssertionError("--force must not wait for sessions to drain")
|
||||
|
||||
monkeypatch.setattr("omnigent.cli._wait_for_local_sessions_to_drain", _no_drain)
|
||||
|
||||
stop_calls: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._stop_local_server_and_daemon",
|
||||
lambda *, force: stop_calls.append(force) or False,
|
||||
)
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 0)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.2.0", None)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--force"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert stop_calls == [True]
|
||||
|
||||
|
||||
def test_upgrade_install_failure_surfaces(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""A non-zero installer exit → ClickException naming the status, exit non-zero."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 3)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "exited with status 3" in result.output
|
||||
|
||||
|
||||
def test_upgrade_index_unreachable(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
|
||||
"""Index unreachable → clear error, no upgrade attempted."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: None)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "package index" in result.output
|
||||
|
||||
|
||||
def test_upgrade_rejects_dev_clone(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
|
||||
"""A source checkout is redirected to ``git pull``, not upgraded."""
|
||||
monkeypatch.setattr("omnigent.update_check._find_repo_root", lambda: Path("/repo"))
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "git pull" in result.output
|
||||
|
||||
|
||||
def test_upgrade_rejects_editable(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
|
||||
"""An editable install is redirected to ``git pull``, not upgraded."""
|
||||
editable = _InstalledWheelInfo(
|
||||
install_time_epoch=0.0,
|
||||
installer="uv",
|
||||
vcs_url="file:///Users/me/omnigent",
|
||||
commit_sha=None,
|
||||
is_editable=True,
|
||||
package_version="0.1.0",
|
||||
detected_installer="uv",
|
||||
)
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", lambda: editable)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "git pull" in result.output
|
||||
|
||||
|
||||
def test_upgrade_pre_check_detects_release_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``--pre --check`` includes pre-releases and reports the rc as available."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fetch(include_prereleases: bool = False, **_kw: object) -> str:
|
||||
captured["include_prereleases"] = include_prereleases
|
||||
return "0.1.1rc1" if include_prereleases else "0.1.0"
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", _fetch)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--pre", "--check"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "v0.1.0 → v0.1.1rc1" in result.output
|
||||
assert captured["include_prereleases"] is True
|
||||
|
||||
|
||||
def test_upgrade_without_pre_ignores_release_candidate(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""Without ``--pre`` the rc is invisible → reports up to date."""
|
||||
|
||||
def _fetch(include_prereleases: bool = False, **_kw: object) -> str | None:
|
||||
return "0.1.1rc1" if include_prereleases else "0.1.0"
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", _fetch)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--check"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "up to date" in result.output
|
||||
|
||||
|
||||
def test_count_running_sessions_ignores_idle_connected(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Only ``status == "running"`` sessions count; idle-connected ones don't."""
|
||||
from omnigent.cli import _count_running_sessions, _SessionPagesResult
|
||||
|
||||
sessions = [
|
||||
{"id": "a", "status": "idle"},
|
||||
{"id": "b", "status": "running"},
|
||||
{"id": "c", "status": "idle"},
|
||||
{"id": "d", "status": "running"},
|
||||
{"id": "e"}, # missing status → not running
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._fetch_session_pages",
|
||||
lambda **_kw: _SessionPagesResult(sessions=sessions, error=None),
|
||||
)
|
||||
|
||||
assert _count_running_sessions("http://127.0.0.1:6767") == 2
|
||||
|
||||
|
||||
def test_drain_returns_immediately_when_only_idle_connected(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
"""Regression: 39 idle-but-connected sessions must not block the drain.
|
||||
|
||||
Previously the drain counted *connected* sessions, so a box with idle
|
||||
sessions holding their connection open hung ``omni upgrade`` forever.
|
||||
"""
|
||||
from omnigent.cli import _SessionPagesResult, _wait_for_local_sessions_to_drain
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli.local_server_status",
|
||||
lambda: LocalServerInfo(running=True, pid=1, port=6767, url="http://127.0.0.1:6767"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.cli._fetch_session_pages",
|
||||
lambda **_kw: _SessionPagesResult(
|
||||
sessions=[{"id": f"conv_{i}", "status": "idle"} for i in range(39)], error=None
|
||||
),
|
||||
)
|
||||
|
||||
_wait_for_local_sessions_to_drain() # must return, not hang
|
||||
|
||||
assert "Waiting for" not in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_upgrade_pre_passes_prerelease_flag_to_installer(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``--pre`` upgrade runs the uv command with ``--prerelease allow``."""
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check.fetch_latest_version",
|
||||
lambda include_prereleases=False, **_kw: "0.1.1rc1",
|
||||
)
|
||||
monkeypatch.setattr("omnigent.cli._wait_for_local_sessions_to_drain", lambda: None)
|
||||
monkeypatch.setattr("omnigent.cli._stop_local_server_and_daemon", lambda *, force: False)
|
||||
ran: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._run_upgrade_command",
|
||||
lambda command, _console: ran.append(command) or 0,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.1.1rc1", None)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--pre"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert ran == ["uv tool upgrade omnigent --prerelease allow"]
|
||||
|
||||
|
||||
# ── ``omni update`` alias ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_update_is_alias_for_upgrade_same_callback() -> None:
|
||||
"""``update`` and ``upgrade`` resolve to the exact same Click command.
|
||||
|
||||
The alias is registered by handing the *same* Command object to the
|
||||
group under a second name, so its callback, options and help must be
|
||||
identical — there is no second implementation to drift from ``upgrade``.
|
||||
"""
|
||||
update_cmd = cli.commands["update"]
|
||||
upgrade_cmd = cli.commands["upgrade"]
|
||||
|
||||
assert update_cmd is upgrade_cmd
|
||||
assert update_cmd.callback is upgrade_cmd.callback
|
||||
# Same option surface (--check / --force / --pre).
|
||||
assert [p.name for p in update_cmd.params] == [p.name for p in upgrade_cmd.params]
|
||||
|
||||
|
||||
def test_update_up_to_date(monkeypatch: pytest.MonkeyPatch, _wheel_install: None) -> None:
|
||||
"""``omni update`` runs the upgrade flow end-to-end (up-to-date path)."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.1.0")
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("upgrade command ran while already up to date")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
result = CliRunner().invoke(cli, ["update"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "up to date" in result.output
|
||||
assert "0.1.0" in result.output
|
||||
|
||||
|
||||
def test_update_check_matches_upgrade_check(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``update --check`` behaves identically to ``upgrade --check``."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("--check must not run the upgrade")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
runner = CliRunner()
|
||||
update_result = runner.invoke(cli, ["update", "--check"])
|
||||
upgrade_result = runner.invoke(cli, ["upgrade", "--check"])
|
||||
|
||||
assert update_result.exit_code == upgrade_result.exit_code == 1
|
||||
assert update_result.output == upgrade_result.output
|
||||
assert "v0.1.0 → v0.2.0" in update_result.output
|
||||
|
||||
|
||||
def test_update_suppresses_update_check_like_upgrade() -> None:
|
||||
"""``update`` is special-cased alongside ``upgrade`` in the skip set."""
|
||||
from omnigent.cli import _should_skip_update_check
|
||||
|
||||
assert _should_skip_update_check(["update"]) is True
|
||||
assert _should_skip_update_check(["upgrade"]) is True
|
||||
|
||||
|
||||
def test_upgrade_noop_install_reports_failure_not_success(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""Regression: a no-op upgrade must NOT claim success.
|
||||
|
||||
The installer exits 0 but the installed version doesn't move (a pinned
|
||||
spec, a cooldown/exclude-newer excluding the release, or a stale index
|
||||
cache). The old code printed "✓ Upgraded to v0.2.0" anyway, so the next
|
||||
``--check`` kept reporting the same update — the bug this guards against.
|
||||
"""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 0)
|
||||
# The installer ran (exit 0) but the version is unchanged.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.1.0", None)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "still v0.1.0" in result.output
|
||||
assert "✓ Upgraded" not in result.output
|
||||
|
||||
|
||||
def test_upgrade_unconfirmed_version_is_honest(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""When the new version can't be read back, don't assert a version."""
|
||||
monkeypatch.setattr("omnigent.update_check.fetch_latest_version", lambda *_a, **_k: "0.2.0")
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 0)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: (None, None)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "couldn't confirm" in result.output
|
||||
assert "✓ Upgraded" not in result.output
|
||||
|
||||
|
||||
def test_upgrade_git_install_up_to_date(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""A git install whose ref hasn't moved reports up to date by commit."""
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _git_install_info)
|
||||
# fetch_latest_version must never be consulted for a git install.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check.fetch_latest_version",
|
||||
lambda *_a, **_k: pytest.fail("git install must not query PyPI"),
|
||||
)
|
||||
monkeypatch.setattr("omnigent.update_check._remote_git_head", lambda _url: "a" * 40)
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("nothing to re-pull when already at the ref HEAD")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "up to date" in result.output
|
||||
|
||||
|
||||
def test_upgrade_git_check_behind_exits_nonzero(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""``--check`` on a git install behind its ref reports the delta, exits non-zero."""
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _git_install_info)
|
||||
monkeypatch.setattr("omnigent.update_check._remote_git_head", lambda _url: "b" * 40)
|
||||
|
||||
def _must_not_run(*_a: object, **_k: object) -> int:
|
||||
raise AssertionError("--check must not re-pull")
|
||||
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", _must_not_run)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade", "--check"])
|
||||
|
||||
assert result.exit_code == 1, result.output
|
||||
assert "newer commit is available" in result.output
|
||||
|
||||
|
||||
def test_upgrade_git_install_repulls_and_verifies_commit(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""A git install behind its ref re-pulls and reports the NEW commit."""
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _git_install_info)
|
||||
monkeypatch.setattr("omnigent.update_check._remote_git_head", lambda _url: "b" * 40)
|
||||
|
||||
ran: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._run_upgrade_command",
|
||||
lambda command, _console: ran.append(command) or 0,
|
||||
)
|
||||
# After re-pull the install is at the new commit.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.1.0", "b" * 40)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert ran == ["uv tool install --reinstall git+https://github.com/omnigent-ai/omnigent.git"]
|
||||
assert "Updated to git bbbbbbbbb" in result.output
|
||||
|
||||
|
||||
def test_upgrade_git_install_noop_does_not_claim_update(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""If the re-pull leaves the commit unchanged, say so — don't claim an update."""
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _git_install_info)
|
||||
# Remote unknown (e.g. offline) → re-pull anyway, then verify by commit.
|
||||
monkeypatch.setattr("omnigent.update_check._remote_git_head", lambda _url: None)
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 0)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.1.0", "a" * 40)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "nothing changed" in result.output
|
||||
assert "✓ Updated" not in result.output
|
||||
|
||||
|
||||
def test_upgrade_git_confirmed_behind_but_repull_noop_fails(
|
||||
monkeypatch: pytest.MonkeyPatch, _wheel_install: None
|
||||
) -> None:
|
||||
"""Regression: known-behind git install whose re-pull doesn't move the commit
|
||||
must FAIL, not silently exit 0 (else it recreates the loop on the git path)."""
|
||||
monkeypatch.setattr("omnigent.update_check._read_installed_wheel_info", _git_install_info)
|
||||
# Remote HEAD differs from the installed commit ("a"*40) → positively behind.
|
||||
monkeypatch.setattr("omnigent.update_check._remote_git_head", lambda _url: "b" * 40)
|
||||
monkeypatch.setattr("omnigent.update_check._run_upgrade_command", lambda *_a, **_k: 0)
|
||||
# …but the re-pull left the install on the SAME commit (pinned ref / cached).
|
||||
monkeypatch.setattr(
|
||||
"omnigent.update_check._probe_installed_distribution", lambda: ("0.1.0", "a" * 40)
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(cli, ["upgrade"])
|
||||
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "still at aaaaaaaaa" in result.output
|
||||
assert "✓ Updated" not in result.output
|
||||
Reference in New Issue
Block a user