4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""End-to-end persistence scenarios for /model, onboard paths, and wizard env sync."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
import surfaces.cli.wizard.env_sync as env_sync
|
|
import surfaces.cli.wizard.store as wizard_store
|
|
from surfaces.cli.wizard.config import PROJECT_ENV_PATH, PROJECT_ROOT, PROVIDER_BY_VALUE
|
|
from surfaces.interactive_shell.command_registry import dispatch_slash
|
|
from surfaces.interactive_shell.command_registry import repl_data as repl_data_module
|
|
from surfaces.interactive_shell.session import Session
|
|
|
|
|
|
def _capture() -> tuple[Console, io.StringIO]:
|
|
buf = io.StringIO()
|
|
return Console(file=buf, force_terminal=False, highlight=False), buf
|
|
|
|
|
|
@pytest.fixture
|
|
def persistence_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Path]:
|
|
"""Isolate .env and opensre.json writes for scenario tests."""
|
|
env_path = tmp_path / "project.env"
|
|
store_path = tmp_path / "opensre.json"
|
|
monkeypatch.setattr(env_sync, "PROJECT_ENV_PATH", env_path)
|
|
monkeypatch.setattr(wizard_store, "get_store_path", lambda: store_path)
|
|
return {"env": env_path, "store": store_path}
|
|
|
|
|
|
@pytest.fixture
|
|
def patch_llm_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _Fake:
|
|
provider = "anthropic"
|
|
anthropic_reasoning_model = "claude-opus-4-7"
|
|
anthropic_toolcall_model = "claude-haiku-4-5-20251001"
|
|
|
|
monkeypatch.setattr(repl_data_module, "load_llm_settings", lambda: _Fake())
|
|
|
|
|
|
class TestProjectPaths:
|
|
def test_default_env_path_is_repo_root_dotenv(self) -> None:
|
|
assert (PROJECT_ROOT / "pyproject.toml").is_file()
|
|
assert PROJECT_ENV_PATH == PROJECT_ROOT / ".env"
|
|
|
|
|
|
class TestEnvSyncPersistence:
|
|
def test_provider_switch_writes_env_and_store(
|
|
self, persistence_paths: dict[str, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.delenv("LLM_PROVIDER", raising=False)
|
|
env_sync.sync_provider_env(
|
|
provider=PROVIDER_BY_VALUE["openai"],
|
|
model="gpt-5-mini",
|
|
env_path=persistence_paths["env"],
|
|
)
|
|
env_body = persistence_paths["env"].read_text(encoding="utf-8")
|
|
assert "LLM_PROVIDER=openai" in env_body
|
|
assert "OPENAI_REASONING_MODEL=gpt-5-mini" in env_body
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])
|
|
local = stored["targets"]["local"]
|
|
assert local["provider"] == "openai"
|
|
assert local["model"] == "gpt-5-mini"
|
|
assert local["api_key_env"] == "OPENAI_API_KEY"
|
|
|
|
def test_reasoning_only_updates_store_model(
|
|
self, persistence_paths: dict[str, Path], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
wizard_store.save_local_config(
|
|
wizard_mode="quickstart",
|
|
provider="openai",
|
|
model="gpt-5-mini",
|
|
api_key_env="OPENAI_API_KEY",
|
|
model_env="OPENAI_REASONING_MODEL",
|
|
probes={},
|
|
path=persistence_paths["store"],
|
|
)
|
|
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
|
|
|
env_sync.sync_reasoning_model_env(
|
|
provider=PROVIDER_BY_VALUE["openai"],
|
|
model="gpt-5.4-mini",
|
|
env_path=persistence_paths["env"],
|
|
)
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])
|
|
assert stored["targets"]["local"]["provider"] == "openai"
|
|
assert stored["targets"]["local"]["model"] == "gpt-5.4-mini"
|
|
assert "LLM_PROVIDER=" not in persistence_paths["env"].read_text(encoding="utf-8")
|
|
|
|
def test_cli_provider_switch_clears_stale_api_key_env(self, tmp_path: Path) -> None:
|
|
store_path = tmp_path / "opensre.json"
|
|
wizard_store.save_local_config(
|
|
wizard_mode="quickstart",
|
|
provider="anthropic",
|
|
model="claude-haiku",
|
|
api_key_env="ANTHROPIC_API_KEY",
|
|
model_env="ANTHROPIC_REASONING_MODEL",
|
|
probes={},
|
|
path=store_path,
|
|
)
|
|
wizard_store.update_local_llm_selection(
|
|
provider="claude-code",
|
|
model="",
|
|
api_key_env="",
|
|
model_env="CLAUDE_CODE_MODEL",
|
|
path=store_path,
|
|
)
|
|
local = wizard_store.load_local_config(store_path)["targets"]["local"]
|
|
assert local["provider"] == "claude-code"
|
|
assert local["api_key_env"] == ""
|
|
|
|
|
|
class TestReplModelPersistence:
|
|
pytestmark = pytest.mark.usefixtures("patch_llm_settings")
|
|
|
|
def test_model_set_provider_updates_env_and_store(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
|
|
monkeypatch.setattr("core.llm.factory.reset_llm_clients", lambda: None)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
|
|
|
console, buf = _capture()
|
|
ok = dispatch_slash("/model set anthropic claude-opus-4-7", Session(), console)
|
|
assert ok is True
|
|
assert "switched LLM provider" in buf.getvalue()
|
|
|
|
env_body = persistence_paths["env"].read_text(encoding="utf-8")
|
|
assert "LLM_PROVIDER=anthropic" in env_body
|
|
assert "ANTHROPIC_REASONING_MODEL=claude-opus-4-7" in env_body
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])["targets"]["local"]
|
|
assert stored["provider"] == "anthropic"
|
|
assert stored["model"] == "claude-opus-4-7"
|
|
|
|
def test_model_set_bare_model_updates_env_and_store(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
|
|
monkeypatch.setattr("core.llm.factory.reset_llm_clients", lambda: None)
|
|
monkeypatch.setenv("LLM_PROVIDER", "anthropic")
|
|
wizard_store.save_local_config(
|
|
wizard_mode="quickstart",
|
|
provider="anthropic",
|
|
model="claude-haiku",
|
|
api_key_env="ANTHROPIC_API_KEY",
|
|
model_env="ANTHROPIC_REASONING_MODEL",
|
|
probes={},
|
|
path=persistence_paths["store"],
|
|
)
|
|
|
|
console, _ = _capture()
|
|
dispatch_slash("/model set claude-opus-4-7", Session(), console)
|
|
|
|
env_body = persistence_paths["env"].read_text(encoding="utf-8")
|
|
assert "ANTHROPIC_REASONING_MODEL=claude-opus-4-7" in env_body
|
|
assert "LLM_PROVIDER=" not in env_body
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])["targets"]["local"]
|
|
assert stored["provider"] == "anthropic"
|
|
assert stored["model"] == "claude-opus-4-7"
|
|
|
|
def test_model_set_with_toolcall_flag_persists_both_slots(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
|
|
monkeypatch.setattr("core.llm.factory.reset_llm_clients", lambda: None)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
|
|
|
console, buf = _capture()
|
|
ok = dispatch_slash(
|
|
"/model set anthropic claude-opus-4-7 --toolcall-model claude-haiku-4-5",
|
|
Session(),
|
|
console,
|
|
)
|
|
assert ok is True
|
|
assert "toolcall model:" in buf.getvalue()
|
|
|
|
env_body = persistence_paths["env"].read_text(encoding="utf-8")
|
|
assert "ANTHROPIC_REASONING_MODEL=claude-opus-4-7" in env_body
|
|
assert "ANTHROPIC_TOOLCALL_MODEL=claude-haiku-4-5" in env_body
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])["targets"]["local"]
|
|
assert stored["model"] == "claude-opus-4-7"
|
|
|
|
def test_model_toolcall_set_does_not_rewrite_store_reasoning_model(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
|
|
monkeypatch.setattr("core.llm.factory.reset_llm_clients", lambda: None)
|
|
monkeypatch.setenv("LLM_PROVIDER", "anthropic")
|
|
wizard_store.save_local_config(
|
|
wizard_mode="quickstart",
|
|
provider="anthropic",
|
|
model="claude-opus-4-7",
|
|
api_key_env="ANTHROPIC_API_KEY",
|
|
model_env="ANTHROPIC_REASONING_MODEL",
|
|
probes={},
|
|
path=persistence_paths["store"],
|
|
)
|
|
|
|
console, _ = _capture()
|
|
dispatch_slash("/model toolcall set claude-haiku-4-5-20251001", Session(), console)
|
|
|
|
assert "ANTHROPIC_TOOLCALL_MODEL=claude-haiku-4-5-20251001" in persistence_paths[
|
|
"env"
|
|
].read_text(encoding="utf-8")
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])["targets"]["local"]
|
|
assert stored["model"] == "claude-opus-4-7"
|
|
|
|
def test_model_restore_updates_env_and_store(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
|
|
monkeypatch.setattr("core.llm.factory.reset_llm_clients", lambda: None)
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
|
monkeypatch.setenv("LLM_PROVIDER", "anthropic")
|
|
|
|
console, _ = _capture()
|
|
dispatch_slash("/model restore anthropic", Session(), console)
|
|
|
|
default_model = PROVIDER_BY_VALUE["anthropic"].default_model
|
|
env_body = persistence_paths["env"].read_text(encoding="utf-8")
|
|
assert f"ANTHROPIC_REASONING_MODEL={default_model}" in env_body
|
|
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])["targets"]["local"]
|
|
assert stored["provider"] == "anthropic"
|
|
assert stored["model"] == default_model
|
|
|
|
def test_model_set_refuses_missing_credential_without_touching_files(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
|
monkeypatch.setenv("OPENSRE_DISABLE_KEYRING", "1")
|
|
monkeypatch.setenv("LLM_PROVIDER", "openai")
|
|
|
|
console, buf = _capture()
|
|
dispatch_slash("/model set anthropic", Session(), console)
|
|
|
|
assert "missing credential for anthropic" in buf.getvalue()
|
|
assert not persistence_paths["env"].exists()
|
|
assert not persistence_paths["store"].exists()
|
|
|
|
def test_model_set_unknown_model_does_not_persist(
|
|
self,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test")
|
|
|
|
console, buf = _capture()
|
|
dispatch_slash("/model set anthropic not-a-real-model-xyz", Session(), console)
|
|
|
|
assert "unknown model for anthropic" in buf.getvalue()
|
|
assert not persistence_paths["env"].exists()
|
|
assert not persistence_paths["store"].exists()
|
|
|
|
@pytest.mark.parametrize("model", ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])
|
|
def test_model_set_accepts_and_persists_gpt56_tiers(
|
|
self,
|
|
model: str,
|
|
persistence_paths: dict[str, Path],
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# #3931 acceptance criterion: ``/model set openai gpt-5.6-<tier>``
|
|
# is accepted and persists. openai sets ``allow_custom_models``, so
|
|
# this already held before the quick-picks landed; the test pins it
|
|
# against a future tightening of the allowlist.
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
|
|
|
console, buf = _capture()
|
|
dispatch_slash(f"/model set openai {model}", Session(), console)
|
|
|
|
assert "unknown model" not in buf.getvalue()
|
|
assert f"OPENAI_REASONING_MODEL={model}" in persistence_paths["env"].read_text(
|
|
encoding="utf-8"
|
|
)
|
|
stored = wizard_store.load_local_config(persistence_paths["store"])
|
|
assert stored["targets"]["local"]["model"] == model
|