mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 05:18:49 +03:00
feat(config): add actionable startup diagnostics and WebUI recovery (#5110)
This commit is contained in:
@@ -0,0 +1,539 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.commands import app
|
||||
from nanobot.gateway import GatewayRuntime, GatewayStartOptions, GatewayStatus, RuntimeResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
_ANTHROPIC_BACKEND_CASES = (
|
||||
("anthropic", "anthropic", "claude-sonnet-4-5", "ANTHROPIC_API_KEY", "Anthropic"),
|
||||
("kimi_coding", "kimiCoding", "kimi-for-coding", "KIMI_CODING_API_KEY", "Kimi Coding"),
|
||||
(
|
||||
"minimax_anthropic",
|
||||
"minimaxAnthropic",
|
||||
"MiniMax-M2.7-highspeed",
|
||||
"MINIMAX_API_KEY",
|
||||
"MiniMax (Anthropic)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _without_rendered_line_breaks(output: str) -> str:
|
||||
return "".join(output.splitlines())
|
||||
|
||||
|
||||
def _write_ready_config(config_path, *, channels: dict | None = None) -> None:
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "ollama/llama3.2",
|
||||
"provider": "ollama",
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"apiBase": "http://localhost:11434/v1",
|
||||
}
|
||||
},
|
||||
"channels": channels or {},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_status_reports_ready_provider_and_next_step(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_write_ready_config(config_path)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "Ollama:" in result.stdout
|
||||
assert "Model: ollama/llama3.2" in result.stdout
|
||||
assert 'nanobot agent -m "Hello!"' in result.stdout
|
||||
assert "Status does not call the model" in result.stdout
|
||||
|
||||
|
||||
def test_status_validates_bedrock_without_constructing_provider(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from nanobot.providers.bedrock_provider import BedrockProvider
|
||||
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "bedrock/amazon.nova-lite-v1:0",
|
||||
"provider": "bedrock",
|
||||
}
|
||||
},
|
||||
"providers": {"bedrock": {"region": "us-east-1"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _unexpected_init(*_args, **_kwargs) -> None:
|
||||
pytest.fail("status must not construct a provider client")
|
||||
|
||||
monkeypatch.setattr(BedrockProvider, "__init__", _unexpected_init)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "Status does not call the model or verify network access" in result.stdout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "provider_key", "model", "env_name", "label"),
|
||||
_ANTHROPIC_BACKEND_CASES,
|
||||
)
|
||||
def test_status_reports_missing_key_for_anthropic_backends(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
provider_key: str,
|
||||
model: str,
|
||||
env_name: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
monkeypatch.delenv(env_name, raising=False)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": model, "provider": provider}},
|
||||
"providers": {provider_key: {}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert f"Agent: ✗ No API key configured for provider '{provider}'." in output
|
||||
assert f"{label}: not set" in output
|
||||
assert "provider/model configuration is ready" not in output
|
||||
assert 'Next: nanobot agent -m "Hello!"' not in output
|
||||
assert "Settings → Models" in output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provider", "provider_key", "model", "env_name", "label"),
|
||||
_ANTHROPIC_BACKEND_CASES,
|
||||
)
|
||||
def test_status_accepts_resolved_key_for_anthropic_backends(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
provider: str,
|
||||
provider_key: str,
|
||||
model: str,
|
||||
env_name: str,
|
||||
label: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv(env_name, "test-api-key")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"model": model, "provider": provider}},
|
||||
"providers": {provider_key: {"apiKey": f"${{{env_name}}}"}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert f"{label}: ✓" in result.stdout
|
||||
assert 'nanobot agent -m "Hello!"' in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_missing_provider_with_shortest_setup_routes(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{}", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✗" in result.stdout
|
||||
assert "No provider is configured for model" in result.stdout
|
||||
assert "Settings → Models" in result.stdout
|
||||
assert "nanobot onboard --wizard" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
|
||||
|
||||
def test_status_readiness_does_not_validate_channel_configuration(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
_write_ready_config(
|
||||
config_path,
|
||||
channels={"websocket": {"enabled": False, "path": "missing-slash"}},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Agent: ✓ provider/model configuration is ready" in result.stdout
|
||||
assert "channels.websocket" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_json_location_without_traceback(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid configuration" in result.stdout
|
||||
assert "JSON syntax error at line 1, column 2" in result.stdout
|
||||
assert "Traceback" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_field_without_exposing_secret(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
secret = "should-never-appear"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"apiKey": [secret]}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "providers.openrouter.apiKey" in result.stdout
|
||||
assert secret not in result.stdout
|
||||
assert "input_value" not in result.stdout
|
||||
assert "errors.pydantic.dev" not in result.stdout
|
||||
|
||||
|
||||
def test_status_reports_missing_env_var_at_field(tmp_path, monkeypatch) -> None:
|
||||
name = "NANOBOT_TEST_STATUS_MISSING"
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
json.dumps({"providers": {"openrouter": {"apiKey": f"${{{name}}}"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "providers.openrouter.apiKey" in result.stdout
|
||||
assert name in result.stdout
|
||||
assert "OpenRouter: not set" in result.stdout
|
||||
assert "OpenRouter: ✓" not in result.stdout
|
||||
|
||||
|
||||
def test_webui_reports_malformed_environment_config_without_traceback(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
config_path = tmp_path / "missing.json"
|
||||
invalid_value = "sensitive-not-json"
|
||||
monkeypatch.setenv("NANOBOT_PROVIDERS", invalid_value)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["webui", "--config", str(config_path), "--yes", "--no-open"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert "Environment-based configuration could not be parsed" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert invalid_value not in result.stdout
|
||||
assert not config_path.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["webui", "--yes", "--no-open"],
|
||||
["agent", "--message", "hello"],
|
||||
],
|
||||
)
|
||||
def test_agent_entrypoints_point_invalid_config_to_status(tmp_path, args: list[str]) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text("{broken", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Invalid configuration" in result.stdout
|
||||
assert "nanobot status --config" in result.stdout
|
||||
assert "Traceback" not in result.stdout
|
||||
|
||||
|
||||
def test_agent_provider_setup_failure_points_to_shortest_routes(tmp_path) -> None:
|
||||
config_path = tmp_path / "config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps({"agents": {"defaults": {"workspace": str(workspace)}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["agent", "--message", "hello", "--config", str(config_path)],
|
||||
)
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Agent cannot start: No provider is configured for model" in output
|
||||
assert "Settings → Models" in output
|
||||
assert "nanobot onboard --wizard" in output
|
||||
assert "nanobot status --config" in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"args",
|
||||
[
|
||||
["gateway"],
|
||||
["gateway", "--background"],
|
||||
["gateway", "restart"],
|
||||
],
|
||||
)
|
||||
def test_gateway_provider_setup_failure_points_to_shortest_routes_when_webui_disabled(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
args: list[str],
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-gateway-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {"websocket": {"enabled": False}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def unexpected_managed_start(*_args, **_kwargs) -> RuntimeResult:
|
||||
pytest.fail("provider validation must fail before a managed gateway start")
|
||||
|
||||
monkeypatch.setattr(GatewayRuntime, "start_background", unexpected_managed_start)
|
||||
monkeypatch.setattr(GatewayRuntime, "restart", unexpected_managed_start)
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Gateway cannot start: No provider is configured for model" in output
|
||||
assert "Settings → Models" in output
|
||||
assert "nanobot onboard --wizard" in output
|
||||
assert "nanobot status --config" in output
|
||||
assert config_path.name in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "start_mode"),
|
||||
[
|
||||
(["gateway", "--background"], "background"),
|
||||
(["gateway", "restart"], "restart"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("secret_field", ["tokenIssueSecret", "token"])
|
||||
def test_gateway_missing_provider_managed_start_for_webui_setup(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
args: list[str],
|
||||
start_mode: str,
|
||||
secret_field: str,
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-gateway-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
webui_port = 18776
|
||||
bootstrap_secret = "must-not-appear-in-gateway-output"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": webui_port,
|
||||
secret_field: bootstrap_secret,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
started_options: list[tuple[str, GatewayStartOptions]] = []
|
||||
status = GatewayStatus(
|
||||
running=True,
|
||||
pid=12345,
|
||||
state_path=tmp_path / "gateway.json",
|
||||
log_path=tmp_path / "gateway.log",
|
||||
started_at="2026-07-28T00:00:00Z",
|
||||
port=18790,
|
||||
reason="running",
|
||||
)
|
||||
|
||||
def fake_start_background(
|
||||
_runtime: GatewayRuntime,
|
||||
options: GatewayStartOptions,
|
||||
) -> RuntimeResult:
|
||||
started_options.append(("background", options))
|
||||
return RuntimeResult(True, "gateway_started_background", status)
|
||||
|
||||
def fake_restart(
|
||||
_runtime: GatewayRuntime,
|
||||
options: GatewayStartOptions,
|
||||
*,
|
||||
timeout_s: int,
|
||||
) -> RuntimeResult:
|
||||
assert timeout_s == 20
|
||||
started_options.append(("restart", options))
|
||||
return RuntimeResult(True, "gateway_started_background", status)
|
||||
|
||||
monkeypatch.setattr(GatewayRuntime, "start_background", fake_start_background)
|
||||
monkeypatch.setattr(GatewayRuntime, "restart", fake_restart)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.commands.ensure_webui_bundle",
|
||||
lambda **_kwargs: None,
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[*args, "--config", str(config_path)],
|
||||
)
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Provider/model setup is incomplete: No provider is configured for model" in output
|
||||
assert "Gateway will start so you can configure a provider and model" in output
|
||||
assert "WebUI Settings" in output
|
||||
assert "Models." in output
|
||||
assert f"WebUI: http://127.0.0.1:{webui_port}" in output
|
||||
assert f"channels.websocket.{secret_field}" in output
|
||||
if secret_field == "token":
|
||||
assert "channels.websocket.tokenIssueSecret" not in output
|
||||
assert "bootstrapSecret" not in output
|
||||
assert bootstrap_secret not in output
|
||||
assert "Gateway cannot start" not in output
|
||||
assert started_options == [
|
||||
(
|
||||
start_mode,
|
||||
GatewayStartOptions(
|
||||
port=18790,
|
||||
config_path=str(config_path.resolve()),
|
||||
),
|
||||
)
|
||||
]
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
def test_gateway_invalid_webui_config_blocks_unconfigured_setup_mode(tmp_path) -> None:
|
||||
config_path = tmp_path / "invalid-webui-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"agents": {"defaults": {"workspace": str(workspace)}},
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"port": "not-a-port",
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Gateway configuration is invalid." in output
|
||||
assert "channels.websocket.port" in output
|
||||
assert "Provider/model setup is incomplete" not in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("args", "summary", "retry_command"),
|
||||
[
|
||||
(
|
||||
["webui", "--yes", "--no-open"],
|
||||
"WebUI configuration is invalid.",
|
||||
"nanobot webui --config",
|
||||
),
|
||||
(
|
||||
["gateway"],
|
||||
"Gateway configuration is invalid.",
|
||||
"nanobot gateway --config",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_config_validation_is_redacted_and_actionable(
|
||||
tmp_path,
|
||||
args: list[str],
|
||||
summary: str,
|
||||
retry_command: str,
|
||||
) -> None:
|
||||
config_path = tmp_path / "explicit-runtime-config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
invalid_value = "sensitive-not-a-port"
|
||||
_write_ready_config(
|
||||
config_path,
|
||||
channels={
|
||||
"websocket": {
|
||||
"enabled": True,
|
||||
"port": invalid_value,
|
||||
}
|
||||
},
|
||||
)
|
||||
data = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
data["agents"]["defaults"]["workspace"] = str(workspace)
|
||||
config_path.write_text(json.dumps(data), encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, [*args, "--config", str(config_path)])
|
||||
output = _without_rendered_line_breaks(result.stdout)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert summary in output
|
||||
assert "channels.websocket.port" in output
|
||||
assert retry_command in output
|
||||
assert config_path.name in output
|
||||
assert invalid_value not in output
|
||||
assert "input_value" not in output
|
||||
assert "errors.pydantic.dev" not in output
|
||||
assert "Traceback" not in output
|
||||
assert not workspace.exists()
|
||||
|
||||
|
||||
def test_status_missing_file_points_to_setup_without_changing_exit_contract(tmp_path) -> None:
|
||||
config_path = tmp_path / "missing.json"
|
||||
|
||||
result = runner.invoke(app, ["status", "--config", str(config_path)])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "configuration file not found" in result.stdout
|
||||
assert "nanobot webui" in result.stdout
|
||||
assert "nanobot onboard --wizard" in result.stdout
|
||||
Reference in New Issue
Block a user