1121 lines
38 KiB
Python
1121 lines
38 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import tomllib
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from opensquilla.cli.main import app
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
class FakeGatewayClient:
|
|
calls: list[tuple[str, Any]] = []
|
|
rpc_payloads: dict[str, Any] = {}
|
|
model_rows: list[dict[str, Any]] = []
|
|
sessions_payload: dict[str, Any] = {"sessions": [], "count": 0}
|
|
cost_payload: dict[str, Any] = {"breakdown": [], "totalCostUsd": 0.0}
|
|
|
|
async def connect(self, url: str, *, token=None) -> None:
|
|
type(self).calls.append(("connect", url))
|
|
|
|
async def close(self) -> None:
|
|
type(self).calls.append(("close", None))
|
|
|
|
async def call(self, method: str, params: dict | None = None) -> Any:
|
|
type(self).calls.append((method, params or {}))
|
|
return type(self).rpc_payloads.get(method, {})
|
|
|
|
async def list_models(
|
|
self,
|
|
provider: str | None = None,
|
|
capabilities: list[str] | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
type(self).calls.append(
|
|
("models.list", {"provider": provider, "capabilities": capabilities})
|
|
)
|
|
return type(self).model_rows
|
|
|
|
async def list_sessions(self, limit: int = 50) -> dict[str, Any]:
|
|
type(self).calls.append(("sessions.list", {"limit": limit}))
|
|
return type(self).sessions_payload
|
|
|
|
async def resolve_session(self, key: str) -> dict[str, Any]:
|
|
type(self).calls.append(("sessions.resolve", {"key": key}))
|
|
return type(self).rpc_payloads.get("sessions.resolve", {"key": key})
|
|
|
|
async def preview_sessions(
|
|
self,
|
|
keys: list[str] | None = None,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
type(self).calls.append(("sessions.preview", {"keys": keys, "limit": limit}))
|
|
return type(self).rpc_payloads.get("sessions.preview", {"previews": []})
|
|
|
|
async def abort_session(self, key: str) -> dict[str, Any]:
|
|
type(self).calls.append(("sessions.abort", {"key": key}))
|
|
return type(self).rpc_payloads.get("sessions.abort", {"aborted": False, "key": key})
|
|
|
|
async def usage_cost(self) -> dict[str, Any]:
|
|
type(self).calls.append(("usage.cost", {}))
|
|
return type(self).cost_payload
|
|
|
|
|
|
class FailingConnectGatewayClient(FakeGatewayClient):
|
|
async def connect(self, url: str, *, token=None) -> None:
|
|
raise SystemExit("gateway offline")
|
|
|
|
|
|
class RPCFailGatewayClient(FakeGatewayClient):
|
|
async def call(self, method: str, params: dict | None = None) -> Any:
|
|
from opensquilla.cli.gateway_client import GatewayRPCError
|
|
|
|
type(self).calls.append((method, params or {}))
|
|
raise GatewayRPCError(
|
|
method,
|
|
code="UNAUTHORIZED",
|
|
message="operator.admin scope required",
|
|
data={"scope": "operator.admin"},
|
|
)
|
|
|
|
|
|
def _install_fake_gateway(monkeypatch, cls=FakeGatewayClient) -> type[FakeGatewayClient]:
|
|
cls.calls = []
|
|
cls.rpc_payloads = {}
|
|
cls.model_rows = []
|
|
cls.sessions_payload = {"sessions": [], "count": 0}
|
|
cls.cost_payload = {"breakdown": [], "totalCostUsd": 0.0}
|
|
monkeypatch.setattr("opensquilla.cli.gateway_client.GatewayClient", cls)
|
|
return cls
|
|
|
|
|
|
def test_catalog_list_json_surfaces(tmp_path: Path, monkeypatch):
|
|
target = tmp_path / "c.toml"
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
runner.invoke(
|
|
app,
|
|
[
|
|
"channels", "add", "slack",
|
|
"--name", "w", "--token", "supersecret",
|
|
"--field", "signing_secret=ss",
|
|
],
|
|
)
|
|
|
|
providers = runner.invoke(app, ["providers", "list", "--json"])
|
|
search = runner.invoke(app, ["search", "list", "--json"])
|
|
channels = runner.invoke(app, ["channels", "list", "--json"])
|
|
|
|
assert providers.exit_code == 0, providers.stdout
|
|
assert search.exit_code == 0, search.stdout
|
|
assert channels.exit_code == 0, channels.stdout
|
|
assert any(row["providerId"] == "openrouter" for row in json.loads(providers.stdout))
|
|
assert any(row["providerId"] == "brave" for row in json.loads(search.stdout))
|
|
channel_payload = json.loads(channels.stdout)
|
|
assert channel_payload[0]["name"] == "w"
|
|
assert "supersecret" not in channels.stdout
|
|
assert "***" in channels.stdout
|
|
|
|
|
|
def test_models_list_json_uses_gateway_client(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"models.list": {
|
|
"models": [
|
|
{
|
|
"id": "model-a",
|
|
"provider": "openrouter",
|
|
"contextWindow": 123,
|
|
"capabilities": ["chat"],
|
|
}
|
|
],
|
|
"errors": [],
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["models", "list", "--provider", "openrouter", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)[0]["id"] == "model-a"
|
|
assert ("models.list", {"provider": "openrouter", "capabilities": None}) in fake.calls
|
|
|
|
|
|
def test_models_list_table_warns_about_provider_listing_errors(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"models.list": {
|
|
"models": [],
|
|
"errors": [
|
|
{
|
|
"provider": "openrouter",
|
|
"kind": "auth_invalid",
|
|
"detail": "401 invalid api key ***",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["models", "list"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
# Rows table stays on stdout; the warning block goes to stderr.
|
|
assert "auth_invalid" not in result.stdout
|
|
assert "openrouter" in result.stderr
|
|
assert "auth_invalid" in result.stderr
|
|
|
|
|
|
def test_config_get_honors_env_path_and_redacts(tmp_path: Path, monkeypatch):
|
|
target = tmp_path / "opensquilla.toml"
|
|
target.write_text(
|
|
"search_api_key = \"secret\"\n"
|
|
"[llm]\nprovider = \"openrouter\"\nmodel = \"test/model\"\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
|
|
model_result = runner.invoke(app, ["config", "get", "llm.model"])
|
|
key_result = runner.invoke(app, ["config", "get", "search_api_key"])
|
|
all_result = runner.invoke(app, ["config", "get"])
|
|
|
|
assert model_result.exit_code == 0, model_result.stdout
|
|
assert "test/model" in model_result.stdout
|
|
assert key_result.exit_code == 0, key_result.stdout
|
|
assert "[redacted]" in key_result.stdout
|
|
assert "secret" not in key_result.stdout
|
|
assert all_result.exit_code == 0, all_result.stdout
|
|
assert "[redacted]" in all_result.stdout
|
|
assert "secret" not in all_result.stdout
|
|
|
|
|
|
def test_config_get_explicit_config_path_wins(tmp_path: Path):
|
|
target = tmp_path / "explicit.toml"
|
|
target.write_text("[llm]\nmodel = \"explicit/model\"\n", encoding="utf-8")
|
|
|
|
result = runner.invoke(app, ["config", "get", "llm.model", "--config", str(target)])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert "explicit/model" in result.stdout
|
|
|
|
|
|
def test_config_set_explicit_config_path_persists_to_target(tmp_path: Path):
|
|
target = tmp_path / "explicit.toml"
|
|
target.write_text("log_file_enabled = false\n", encoding="utf-8")
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
["config", "set", "log_file_enabled", "true", "--config", str(target)],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert "Config:" in result.stdout
|
|
assert "Restart the gateway" in result.stdout
|
|
check = runner.invoke(app, ["config", "get", "log_file_enabled", "--config", str(target)])
|
|
assert check.exit_code == 0, check.stdout
|
|
assert "True" in check.stdout
|
|
|
|
|
|
def test_config_set_get_privacy_network_observability_round_trips(tmp_path: Path):
|
|
target = tmp_path / "privacy.toml"
|
|
target.write_text("", encoding="utf-8")
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"config",
|
|
"set",
|
|
"privacy.disable_network_observability",
|
|
"true",
|
|
"--config",
|
|
str(target),
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
persisted = tomllib.loads(target.read_text(encoding="utf-8"))
|
|
assert persisted["privacy"]["disable_network_observability"] is True
|
|
check = runner.invoke(
|
|
app,
|
|
[
|
|
"config",
|
|
"get",
|
|
"privacy.disable_network_observability",
|
|
"--config",
|
|
str(target),
|
|
],
|
|
)
|
|
assert check.exit_code == 0, check.stdout
|
|
assert "True" in check.stdout
|
|
|
|
|
|
def test_version_check_honors_config_privacy_network_observability(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
):
|
|
from opensquilla.observability import update_check
|
|
|
|
target = tmp_path / "privacy.toml"
|
|
target.write_text(
|
|
"[privacy]\ndisable_network_observability = true\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
monkeypatch.setattr(update_check, "_CACHED_INFO", None)
|
|
|
|
def fail_fetch(*_args, **_kwargs):
|
|
raise AssertionError("version --check should not contact the network")
|
|
|
|
monkeypatch.setattr(update_check, "_fetch_latest_release", fail_fetch)
|
|
|
|
result = runner.invoke(app, ["version", "--check", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["latest"] is None
|
|
assert payload["updateAvailable"] is False
|
|
assert payload["disabled"] is True
|
|
assert payload["error"] is None
|
|
|
|
|
|
def test_gateway_json_errors_go_to_stderr(monkeypatch):
|
|
_install_fake_gateway(monkeypatch, FailingConnectGatewayClient)
|
|
|
|
result = runner.invoke(app, ["models", "list", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout == ""
|
|
assert json.loads(result.stderr)["error"]["code"] == "GATEWAY_UNAVAILABLE"
|
|
|
|
|
|
def test_skills_view_and_update_use_gateway_rpc(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"skills.get": {
|
|
"name": "planner",
|
|
"layer": "managed",
|
|
"eligible": True,
|
|
"description": "Plan work",
|
|
"content": "skill body",
|
|
},
|
|
"skills.update": {
|
|
"results": [{"success": True, "name": "planner", "message": "updated"}]
|
|
},
|
|
}
|
|
|
|
view = runner.invoke(app, ["skills", "view", "planner", "--json"])
|
|
update = runner.invoke(app, ["skills", "update", "planner", "--json"])
|
|
|
|
assert view.exit_code == 0, view.stdout
|
|
assert json.loads(view.stdout)["name"] == "planner"
|
|
assert update.exit_code == 0, update.stdout
|
|
assert json.loads(update.stdout)["results"][0]["success"] is True
|
|
assert ("skills.get", {"name": "planner"}) in fake.calls
|
|
assert ("skills.update", {"name": "planner"}) in fake.calls
|
|
|
|
|
|
def test_skills_update_all_exits_nonzero_on_partial_failure(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"skills.update": {
|
|
"results": [
|
|
{"success": True, "name": "a", "message": "updated"},
|
|
{"success": False, "name": "b", "message": "failed"},
|
|
]
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["skills", "update", "--all", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert json.loads(result.stdout)["results"][1]["name"] == "b"
|
|
assert ("skills.update", {}) in fake.calls
|
|
|
|
|
|
def test_skills_update_exits_nonzero_on_top_level_failure(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"skills.update": {
|
|
"success": False,
|
|
"message": "No skill installer configured",
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["skills", "update", "planner", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert json.loads(result.stdout)["message"] == "No skill installer configured"
|
|
assert ("skills.update", {"name": "planner"}) in fake.calls
|
|
|
|
|
|
def test_skills_install_and_uninstall_use_gateway_rpc_when_available(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"skills.install": {
|
|
"success": True,
|
|
"name": "planner",
|
|
"message": "installed by gateway",
|
|
"path": "/gateway/skill",
|
|
},
|
|
"skills.uninstall": {
|
|
"success": True,
|
|
"name": "planner",
|
|
"message": "removed by gateway",
|
|
},
|
|
}
|
|
|
|
install = runner.invoke(app, ["skills", "install", "planner", "--json"])
|
|
uninstall = runner.invoke(app, ["skills", "uninstall", "planner", "--json"])
|
|
|
|
assert install.exit_code == 0, install.stdout
|
|
assert json.loads(install.stdout)["path"] == "/gateway/skill"
|
|
assert uninstall.exit_code == 0, uninstall.stdout
|
|
assert json.loads(uninstall.stdout)["message"] == "removed by gateway"
|
|
assert (
|
|
"skills.install",
|
|
{"identifier": "planner", "source": "clawhub", "force": False},
|
|
) in fake.calls
|
|
assert ("skills.uninstall", {"name": "planner"}) in fake.calls
|
|
|
|
|
|
def test_skills_install_and_uninstall_fall_back_when_gateway_unavailable(monkeypatch):
|
|
_install_fake_gateway(monkeypatch, FailingConnectGatewayClient)
|
|
from opensquilla.skills.hub.installer import InstallResult, SkillInstaller
|
|
|
|
async def fake_install(self, identifier: str, source: str, force: bool = False):
|
|
return InstallResult(
|
|
success=True,
|
|
name=identifier,
|
|
message=f"installed from {source}",
|
|
path="/tmp/skill",
|
|
)
|
|
|
|
async def fake_uninstall(self, name: str):
|
|
return InstallResult(success=False, name=name, message="missing")
|
|
|
|
monkeypatch.setattr(SkillInstaller, "install", fake_install)
|
|
monkeypatch.setattr(SkillInstaller, "uninstall", fake_uninstall)
|
|
|
|
install = runner.invoke(app, ["skills", "install", "planner", "--json"])
|
|
uninstall = runner.invoke(app, ["skills", "uninstall", "missing", "--json"])
|
|
|
|
assert install.exit_code == 0, install.stdout
|
|
assert json.loads(install.stdout)["path"] == "/tmp/skill"
|
|
assert uninstall.exit_code == 1
|
|
assert json.loads(uninstall.stdout)["message"] == "missing"
|
|
|
|
|
|
def test_skills_install_fallback_exposes_github_source_without_token(monkeypatch):
|
|
_install_fake_gateway(monkeypatch, FailingConnectGatewayClient)
|
|
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
|
|
from opensquilla.skills.hub.installer import InstallResult, SkillInstaller
|
|
|
|
async def fake_install(self, identifier: str, source: str, force: bool = False):
|
|
assert source == "github"
|
|
assert identifier == "https://github.com/acme/skillpack/tree/main/skills/demo"
|
|
assert "github" in self._router.source_ids
|
|
return InstallResult(
|
|
success=True,
|
|
name="demo",
|
|
message="installed from github",
|
|
path="/tmp/demo",
|
|
)
|
|
|
|
monkeypatch.setattr(SkillInstaller, "install", fake_install)
|
|
|
|
install = runner.invoke(
|
|
app,
|
|
[
|
|
"skills",
|
|
"install",
|
|
"https://github.com/acme/skillpack/tree/main/skills/demo",
|
|
"--source",
|
|
"github",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert install.exit_code == 0, install.stdout
|
|
assert json.loads(install.stdout)["name"] == "demo"
|
|
|
|
|
|
def test_skills_install_rpc_error_does_not_fall_back_to_local_installer(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch, RPCFailGatewayClient)
|
|
from opensquilla.skills.hub.installer import SkillInstaller
|
|
|
|
local_install_called = False
|
|
|
|
async def fake_install(self, identifier: str, source: str, force: bool = False):
|
|
nonlocal local_install_called
|
|
local_install_called = True
|
|
raise AssertionError("local fallback must not run after RPC errors")
|
|
|
|
monkeypatch.setattr(SkillInstaller, "install", fake_install)
|
|
|
|
result = runner.invoke(app, ["skills", "install", "planner", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout == ""
|
|
assert json.loads(result.stderr)["error"]["code"] == "UNAUTHORIZED"
|
|
assert local_install_called is False
|
|
assert (
|
|
"skills.install",
|
|
{"identifier": "planner", "source": "clawhub", "force": False},
|
|
) in fake.calls
|
|
|
|
|
|
def test_sessions_list_json_filters_client_side(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.sessions_payload = {
|
|
"sessions": [
|
|
{
|
|
"key": "a",
|
|
"agentId": "main",
|
|
"status": "active",
|
|
"channel": "slack",
|
|
"updatedAt": "2026-05-05T00:00:00Z",
|
|
"message_count": 2,
|
|
},
|
|
{
|
|
"key": "b",
|
|
"agentId": "ops",
|
|
"status": "done",
|
|
"channel": "telegram",
|
|
"updatedAt": "2026-05-01T00:00:00Z",
|
|
"message_count": 1,
|
|
},
|
|
],
|
|
"count": 2,
|
|
"ts": 1,
|
|
}
|
|
|
|
result = runner.invoke(
|
|
app,
|
|
[
|
|
"sessions",
|
|
"list",
|
|
"--agent",
|
|
"main",
|
|
"--status",
|
|
"active",
|
|
"--channel",
|
|
"slack",
|
|
"--since",
|
|
"2026-05-04",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["count"] == 1
|
|
assert payload["sessions"][0]["key"] == "a"
|
|
|
|
|
|
def test_sessions_show_json_resolves_and_previews(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"sessions.resolve": {
|
|
"session_key": "agent:main:abc",
|
|
"session_id": "abc",
|
|
"status": "active",
|
|
"agent_id": "main",
|
|
"model": "openai/test",
|
|
},
|
|
"sessions.preview": {
|
|
"previews": [
|
|
{
|
|
"key": "agent:main:abc",
|
|
"title": "Debugging",
|
|
"lastMessage": "latest",
|
|
"updatedAt": 123,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
|
|
result = runner.invoke(app, ["sessions", "show", "abc", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["resolved"]["session_key"] == "agent:main:abc"
|
|
assert payload["preview"]["previews"][0]["lastMessage"] == "latest"
|
|
assert ("sessions.resolve", {"key": "abc"}) in fake.calls
|
|
assert ("sessions.preview", {"keys": ["agent:main:abc"], "limit": 50}) in fake.calls
|
|
|
|
|
|
def test_sessions_show_json_errors_go_to_stderr(monkeypatch):
|
|
_install_fake_gateway(monkeypatch, FailingConnectGatewayClient)
|
|
|
|
result = runner.invoke(app, ["sessions", "show", "abc", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert result.stdout == ""
|
|
assert json.loads(result.stderr)["error"]["code"] == "GATEWAY_UNAVAILABLE"
|
|
|
|
|
|
def test_sessions_abort_resolves_then_aborts(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"sessions.resolve": {"key": "agent:main:abc", "session_id": "abc"},
|
|
"sessions.abort": {"aborted": True, "key": "agent:main:abc"},
|
|
}
|
|
|
|
result = runner.invoke(app, ["sessions", "abort", "abc", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
payload = json.loads(result.stdout)
|
|
assert payload["aborted"] is True
|
|
assert ("sessions.resolve", {"key": "abc"}) in fake.calls
|
|
assert ("sessions.abort", {"key": "agent:main:abc"}) in fake.calls
|
|
|
|
|
|
def test_memory_status_json_reuses_doctor_rpc(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"doctor.memory.status": {
|
|
"backend": "sqlite",
|
|
"status": "ok",
|
|
"entryCount": 3,
|
|
"sizeBytes": 42,
|
|
"error": None,
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["memory", "status", "--agent", "main", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["entryCount"] == 3
|
|
assert ("doctor.memory.status", {"agentId": "main"}) in fake.calls
|
|
|
|
|
|
def test_memory_status_table_surfaces_source_counts(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"doctor.memory.status": {
|
|
"backend": "sqlite",
|
|
"status": "ok",
|
|
"entryCount": 4,
|
|
"sizeBytes": 42,
|
|
"error": None,
|
|
"sourceCounts": {
|
|
"memory": {"files": 1, "chunks": 2},
|
|
"sessions": {"files": 1, "chunks": 2},
|
|
},
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["memory", "status", "--agent", "main"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert "Sources" in result.stdout
|
|
assert "memory" in result.stdout
|
|
assert "sessions" in result.stdout
|
|
|
|
|
|
def test_memory_status_deep_json_passes_deep_flag(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"doctor.memory.status": {
|
|
"backend": "sqlite",
|
|
"status": "degraded",
|
|
"vecAvailable": False,
|
|
"ftsAvailable": True,
|
|
"degraded": [],
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["memory", "status", "--agent", "main", "--deep", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["vecAvailable"] is False
|
|
assert ("doctor.memory.status", {"agentId": "main", "deep": True}) in fake.calls
|
|
|
|
|
|
def test_memory_list_json_uses_gateway_rpc(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"memory.list": {
|
|
"agentId": "main",
|
|
"count": 1,
|
|
"files": [{"path": "memory/a.md", "lineCount": 2, "sizeBytes": 12}],
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["memory", "list", "--agent", "main", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["files"][0]["path"] == "memory/a.md"
|
|
assert ("memory.list", {"agentId": "main"}) in fake.calls
|
|
|
|
|
|
def test_memory_search_and_show_use_gateway_rpcs(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"memory.search": {
|
|
"agentId": "main",
|
|
"query": "alpha",
|
|
"count": 1,
|
|
"results": [
|
|
{
|
|
"source": "sessions",
|
|
"path": "sessions/main/session-1.md",
|
|
"startLine": 1,
|
|
"endLine": 2,
|
|
"score": 0.8,
|
|
"snippet": "alpha transcript",
|
|
}
|
|
],
|
|
},
|
|
"memory.show": {
|
|
"agentId": "main",
|
|
"path": "memory/a.md",
|
|
"fromLine": 2,
|
|
"lineCount": 1,
|
|
"truncated": False,
|
|
"content": "line",
|
|
},
|
|
}
|
|
|
|
search_default = runner.invoke(
|
|
app,
|
|
["memory", "search", "alpha", "--limit", "3", "--json"],
|
|
)
|
|
search = runner.invoke(
|
|
app,
|
|
["memory", "search", "alpha", "--limit", "3", "--source", "sessions", "--json"],
|
|
)
|
|
search_table = runner.invoke(
|
|
app,
|
|
["memory", "search", "alpha", "--limit", "3", "--source", "sessions"],
|
|
)
|
|
show = runner.invoke(
|
|
app,
|
|
[
|
|
"memory",
|
|
"show",
|
|
"memory/a.md",
|
|
"--from-line",
|
|
"2",
|
|
"--lines",
|
|
"1",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert search_default.exit_code == 0, search_default.stdout
|
|
assert search.exit_code == 0, search.stdout
|
|
assert search_table.exit_code == 0, search_table.stdout
|
|
assert show.exit_code == 0, show.stdout
|
|
assert "Source" in search_table.stdout
|
|
assert "sessions" in search_table.stdout
|
|
assert json.loads(show.stdout)["content"] == "line"
|
|
assert (
|
|
"memory.search",
|
|
{"query": "alpha", "agentId": "main", "limit": 3, "source": "memory"},
|
|
) in fake.calls
|
|
assert (
|
|
"memory.search",
|
|
{"query": "alpha", "agentId": "main", "limit": 3, "source": "sessions"},
|
|
) in fake.calls
|
|
assert (
|
|
"memory.show",
|
|
{"path": "memory/a.md", "agentId": "main", "fromLine": 2, "lines": 1},
|
|
) in fake.calls
|
|
|
|
|
|
def test_memory_index_raw_fallback_and_repair_commands_use_admin_rpcs(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"memory.index": {"agentId": "main", "force": True},
|
|
"memory.raw_fallbacks.list": {
|
|
"agentId": "main",
|
|
"count": 1,
|
|
"files": [{"path": "memory/.raw_fallbacks/raw.md", "sizeBytes": 12}],
|
|
},
|
|
"memory.raw_fallbacks.show": {
|
|
"agentId": "main",
|
|
"path": "memory/.raw_fallbacks/raw.md",
|
|
"fromLine": 1,
|
|
"lineCount": 1,
|
|
"truncated": False,
|
|
"content": "raw",
|
|
},
|
|
"memory.repair.list": {
|
|
"agentId": "main",
|
|
"count": 1,
|
|
"items": [
|
|
{
|
|
"summaryId": 7,
|
|
"sessionKey": "agent:main:thread-1",
|
|
"compactionId": "cmp-1",
|
|
"flushReceiptStatus": "degraded_forensic",
|
|
}
|
|
],
|
|
},
|
|
"memory.repair.show": {
|
|
"agentId": "main",
|
|
"sessionKey": "agent:main:thread-1",
|
|
"compactionId": "cmp-1",
|
|
"entries": [{"role": "user", "content": "preimage fact"}],
|
|
},
|
|
"memory.repair.run": {
|
|
"agentId": "main",
|
|
"count": 1,
|
|
"results": [{"compactionId": "cmp-1", "status": "repaired"}],
|
|
},
|
|
}
|
|
|
|
index = runner.invoke(app, ["memory", "index", "--agent", "main", "--force", "--json"])
|
|
listed = runner.invoke(app, ["memory", "raw-fallbacks", "list", "--json"])
|
|
shown = runner.invoke(
|
|
app,
|
|
["memory", "raw-fallbacks", "show", "memory/.raw_fallbacks/raw.md", "--json"],
|
|
)
|
|
repair_listed = runner.invoke(app, ["memory", "repair", "list", "--json"])
|
|
repair_shown = runner.invoke(
|
|
app,
|
|
[
|
|
"memory",
|
|
"repair",
|
|
"show",
|
|
"--session-key",
|
|
"agent:main:thread-1",
|
|
"--compaction-id",
|
|
"cmp-1",
|
|
"--json",
|
|
],
|
|
)
|
|
repair_run = runner.invoke(
|
|
app,
|
|
[
|
|
"memory",
|
|
"repair",
|
|
"run",
|
|
"--session-key",
|
|
"agent:main:thread-1",
|
|
"--compaction-id",
|
|
"cmp-1",
|
|
"--json",
|
|
],
|
|
)
|
|
|
|
assert index.exit_code == 0, index.stdout
|
|
assert listed.exit_code == 0, listed.stdout
|
|
assert shown.exit_code == 0, shown.stdout
|
|
assert repair_listed.exit_code == 0, repair_listed.stdout
|
|
assert repair_shown.exit_code == 0, repair_shown.stdout
|
|
assert repair_run.exit_code == 0, repair_run.stdout
|
|
assert ("memory.index", {"agentId": "main", "force": True}) in fake.calls
|
|
assert ("memory.raw_fallbacks.list", {"agentId": "main"}) in fake.calls
|
|
assert (
|
|
"memory.raw_fallbacks.show",
|
|
{"path": "memory/.raw_fallbacks/raw.md", "agentId": "main"},
|
|
) in fake.calls
|
|
assert ("memory.repair.list", {"agentId": "main", "limit": 50}) in fake.calls
|
|
assert (
|
|
"memory.repair.show",
|
|
{
|
|
"agentId": "main",
|
|
"sessionKey": "agent:main:thread-1",
|
|
"compactionId": "cmp-1",
|
|
},
|
|
) in fake.calls
|
|
assert (
|
|
"memory.repair.run",
|
|
{
|
|
"agentId": "main",
|
|
"limit": 50,
|
|
"sessionKey": "agent:main:thread-1",
|
|
"compactionId": "cmp-1",
|
|
},
|
|
) in fake.calls
|
|
|
|
|
|
def test_cron_run_requires_confirmation_before_gateway_call(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
|
|
result = runner.invoke(app, ["cron", "run", "job-1", "--json"])
|
|
|
|
assert result.exit_code == 2
|
|
assert result.stdout == ""
|
|
assert json.loads(result.stderr)["error"]["code"] == "CONFIRMATION_REQUIRED"
|
|
assert fake.calls == []
|
|
|
|
|
|
def test_cron_run_yes_calls_existing_rpc(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {"cron.run": {"success": True, "status": "accepted"}}
|
|
|
|
result = runner.invoke(app, ["cron", "run", "job-1", "--yes", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["status"] == "accepted"
|
|
assert ("cron.run", {"id": "job-1"}) in fake.calls
|
|
|
|
|
|
def test_cron_commands_use_existing_rpc_payloads(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"cron.list": [{"id": "job-1", "name": "Daily", "agentId": "main"}],
|
|
"cron.status": {"id": "job-1", "name": "Daily"},
|
|
"cron.add": {"id": "job-2", "expression": "*/5 * * * *"},
|
|
"cron.update": {"id": "job-1", "enabled": False},
|
|
"cron.runs": [{"id": "run-1", "status": "ok"}],
|
|
}
|
|
|
|
list_result = runner.invoke(app, ["cron", "list", "--agent", "main", "--json"])
|
|
status_result = runner.invoke(app, ["cron", "status", "job-1", "--json"])
|
|
add_result = runner.invoke(
|
|
app,
|
|
[
|
|
"cron",
|
|
"add",
|
|
"--expression",
|
|
"*/5 * * * *",
|
|
"--text",
|
|
"check in",
|
|
"--agent",
|
|
"main",
|
|
"--session-target",
|
|
"isolated",
|
|
"--json",
|
|
],
|
|
)
|
|
update_result = runner.invoke(app, ["cron", "update", "job-1", "--disabled", "--json"])
|
|
remove_result = runner.invoke(app, ["cron", "remove", "job-1", "--yes", "--json"])
|
|
runs_result = runner.invoke(app, ["cron", "runs", "job-1", "--limit", "3", "--json"])
|
|
|
|
assert list_result.exit_code == 0, list_result.stdout
|
|
assert status_result.exit_code == 0, status_result.stdout
|
|
assert add_result.exit_code == 0, add_result.stdout
|
|
assert update_result.exit_code == 0, update_result.stdout
|
|
assert remove_result.exit_code == 0, remove_result.stdout
|
|
assert runs_result.exit_code == 0, runs_result.stdout
|
|
assert ("cron.list", {"agentId": "main"}) in fake.calls
|
|
assert ("cron.status", {"id": "job-1"}) in fake.calls
|
|
assert (
|
|
"cron.add",
|
|
{
|
|
"expression": "*/5 * * * *",
|
|
"text": "check in",
|
|
"payloadKind": "reminder",
|
|
"sessionTarget": "isolated",
|
|
"agentId": "main",
|
|
},
|
|
) in fake.calls
|
|
assert ("cron.update", {"id": "job-1", "enabled": False}) in fake.calls
|
|
assert ("cron.remove", {"id": "job-1"}) in fake.calls
|
|
assert ("cron.runs", {"id": "job-1", "limit": 3}) in fake.calls
|
|
|
|
|
|
def test_channels_runtime_restart_requires_confirmation(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
|
|
result = runner.invoke(app, ["channels", "restart", "slack", "--json"])
|
|
|
|
assert result.exit_code == 2
|
|
assert json.loads(result.stderr)["error"]["code"] == "CONFIRMATION_REQUIRED"
|
|
assert fake.calls == []
|
|
|
|
|
|
def test_channels_status_and_logout_use_existing_rpcs(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"channels.status": {
|
|
"channels": [{"name": "slack", "status": "connected", "connected": True}]
|
|
},
|
|
"channels.logout": {"status": "disconnected", "channel": "slack"},
|
|
}
|
|
|
|
status = runner.invoke(app, ["channels", "status", "slack", "--json"])
|
|
logout = runner.invoke(app, ["channels", "logout", "slack", "--yes", "--json"])
|
|
|
|
assert status.exit_code == 0, status.stdout
|
|
assert logout.exit_code == 0, logout.stdout
|
|
assert json.loads(status.stdout)["channels"][0]["status"] == "connected"
|
|
assert json.loads(logout.stdout)["status"] == "disconnected"
|
|
assert ("channels.status", {}) in fake.calls
|
|
assert ("channels.logout", {"name": "slack"}) in fake.calls
|
|
|
|
|
|
def test_channels_status_table_shows_channel_diagnostic(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"channels.status": {
|
|
"channels": [
|
|
{
|
|
"name": "dingtalk",
|
|
"type": "dingtalk",
|
|
"status": "stopped",
|
|
"connected": False,
|
|
"enabled": True,
|
|
"configured": True,
|
|
"diagnostics": {
|
|
"last_error": {
|
|
"error_class": "auth_invalid",
|
|
"message": "凭证无效:检查 DingTalk AppKey/AppSecret",
|
|
}
|
|
},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["channels", "status", "dingtalk"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert "凭证无效:检查 DingTalk AppKey/AppSecret" in result.stdout
|
|
assert "auth_invalid" not in result.stdout
|
|
assert ("channels.status", {}) in fake.calls
|
|
|
|
|
|
def test_runtime_diagnostics_commands_can_target_configured_gateway(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
target = tmp_path / "custom.toml"
|
|
target.write_text('host = "0.0.0.0"\nport = 19999\n', encoding="utf-8")
|
|
fake.rpc_payloads = {
|
|
"channels.status": {
|
|
"channels": [{"name": "slack", "status": "connected", "connected": True}]
|
|
},
|
|
"providers.status": {"activeProvider": "openrouter", "providers": [], "count": 0},
|
|
"search.status": {
|
|
"activeProvider": "duckduckgo",
|
|
"provider": "duckduckgo",
|
|
"configured": True,
|
|
"buildable": True,
|
|
},
|
|
"diagnostics.status": {"diagnostics_enabled": {"effective": True}},
|
|
"doctor.memory.status": {"backend": "sqlite", "status": "ok"},
|
|
}
|
|
|
|
channels = runner.invoke(
|
|
app, ["channels", "status", "slack", "--json", "--config", str(target)]
|
|
)
|
|
providers = runner.invoke(app, ["providers", "status", "--json", "--config", str(target)])
|
|
search = runner.invoke(app, ["search", "status", "--json", "--config", str(target)])
|
|
diagnostics = runner.invoke(
|
|
app, ["diagnostics", "status", "--json", "--config", str(target)]
|
|
)
|
|
memory = runner.invoke(app, ["memory", "status", "--json", "--config", str(target)])
|
|
|
|
assert channels.exit_code == 0, channels.stdout
|
|
assert providers.exit_code == 0, providers.stdout
|
|
assert search.exit_code == 0, search.stdout
|
|
assert diagnostics.exit_code == 0, diagnostics.stdout
|
|
assert memory.exit_code == 0, memory.stdout
|
|
connected_urls = [value for method, value in fake.calls if method == "connect"]
|
|
assert connected_urls == ["ws://127.0.0.1:19999/ws"] * 5
|
|
|
|
|
|
def test_runtime_diagnostics_commands_use_gateway_config_env_path(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
target = tmp_path / "env-config.toml"
|
|
target.write_text('host = "127.0.0.1"\nport = 20001\n', encoding="utf-8")
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(target))
|
|
fake.rpc_payloads = {
|
|
"providers.status": {"activeProvider": "openrouter", "providers": [], "count": 0},
|
|
}
|
|
|
|
result = runner.invoke(app, ["providers", "status", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert ("connect", "ws://127.0.0.1:20001/ws") in fake.calls
|
|
|
|
|
|
def test_cost_json_returns_gateway_payload(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.cost_payload = {
|
|
"breakdown": [{"session": "s", "model": "m", "input_tokens": 1, "cost_usd": 0.1}],
|
|
"totalCostUsd": 0.1,
|
|
}
|
|
|
|
result = runner.invoke(app, ["cost", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["totalCostUsd"] == 0.1
|
|
assert ("usage.cost", {}) in fake.calls
|
|
|
|
|
|
def test_provider_and_search_diagnostics_use_gateway_rpcs(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"providers.status": {"activeProvider": "openrouter", "providers": [], "count": 0},
|
|
"search.status": {
|
|
"activeProvider": "duckduckgo",
|
|
"provider": "duckduckgo",
|
|
"configured": True,
|
|
"buildable": True,
|
|
},
|
|
"search.query": {
|
|
"ok": True,
|
|
"query": "hello",
|
|
"provider": "duckduckgo",
|
|
"results": [{"title": "T", "url": "https://example.com", "snippet": "S"}],
|
|
},
|
|
}
|
|
|
|
providers = runner.invoke(app, ["providers", "status", "--json"])
|
|
search_status = runner.invoke(app, ["search", "status", "--json"])
|
|
search_query = runner.invoke(
|
|
app,
|
|
["search", "query", "hello", "--provider", "duckduckgo", "--limit", "2", "--json"],
|
|
)
|
|
|
|
assert providers.exit_code == 0, providers.stdout
|
|
assert search_status.exit_code == 0, search_status.stdout
|
|
assert search_query.exit_code == 0, search_query.stdout
|
|
assert json.loads(search_query.stdout)["results"][0]["title"] == "T"
|
|
assert ("providers.status", {"probeModels": False}) in fake.calls
|
|
assert ("search.status", {}) in fake.calls
|
|
assert (
|
|
"search.query",
|
|
{"query": "hello", "provider": "duckduckgo", "limit": 2},
|
|
) in fake.calls
|
|
|
|
|
|
def test_search_query_json_exits_nonzero_on_diagnostic_failure(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"search.query": {
|
|
"ok": False,
|
|
"query": "hello",
|
|
"provider": "duckduckgo",
|
|
"results": [],
|
|
"error": {
|
|
"kind": "network",
|
|
"class": "ConnectError",
|
|
"message": "network down",
|
|
"retryable": True,
|
|
},
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["search", "query", "hello", "--json"])
|
|
|
|
assert result.exit_code == 1
|
|
assert json.loads(result.stdout)["error"]["message"] == "network down"
|
|
|
|
|
|
def test_doctor_json_uses_gateway_doctor_rpc(monkeypatch):
|
|
fake = _install_fake_gateway(monkeypatch)
|
|
fake.rpc_payloads = {
|
|
"doctor.status": {
|
|
"status": "ready",
|
|
"ready": True,
|
|
"summary": "Ready",
|
|
"counts": {"error": 0, "warn": 0, "info": 0, "ok": 1},
|
|
"findings": [],
|
|
}
|
|
}
|
|
|
|
result = runner.invoke(app, ["doctor", "--json"])
|
|
|
|
assert result.exit_code == 0, result.stdout
|
|
assert json.loads(result.stdout)["ready"] is True
|
|
assert ("doctor.status", {"agentId": "main", "deep": True}) in fake.calls
|