Files
tracer-cloud--opensre/tests/cli/test_model_persistence_scenarios.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

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