from pathlib import Path import pytest from nisse_dev.config import AgentConfig, ConfigError COMPLETE = { "NISSE_STATE_DB": "~/demo/state.db", "NISSE_WORKSPACE": "~/demo/work", "NISSE_PROJECT": "greet", "NISSE_LLM_BASE_URL": "https://litellm.test", "NISSE_LLM_TOKEN": "sk-x", "NISSE_ROLE_EXECUTOR": "claude-haiku-4-5", "NISSE_ROLE_PLANNER": "claude-opus-5", "NISSE_ROLE_DELEGATE": "claude-opus-5", "NISSE_ROLE_CONSULTANT": "claude-haiku-4-5", "NISSE_MATRIX_HOMESERVER": "https://matrix.test", "NISSE_MATRIX_USER_ID": "@nisse-dev-testing:test", "NISSE_MATRIX_TOKEN": "syt_x", "NISSE_MATRIX_ROOM_ID": "!room:test", "NISSE_MATRIX_OPERATORS": "@don:test", "NISSE_GITEA_URL": "https://gitea.test", "NISSE_GITEA_TOKEN": "gt", "NISSE_GITEA_REPO": "don/demo", "NISSE_GITEA_OPERATORS": "don", } def test_complete_environment_builds() -> None: config = AgentConfig.from_env(COMPLETE) assert config.project == "greet" assert config.roles.planner == "claude-opus-5" assert config.matrix.room_id == "!room:test" assert config.gitea_repo == "don/demo" assert config.base_branch == "main" def test_paths_expand_the_home_directory() -> None: config = AgentConfig.from_env(COMPLETE) assert config.workspace.is_absolute() assert "~" not in str(config.state_db) def test_every_missing_variable_is_reported_at_once() -> None: # Failing at first use would surface a missing token twenty minutes into # a milestone, on the call that mattered. partial = {k: v for k, v in COMPLETE.items() if k != "NISSE_MATRIX_TOKEN"} partial.pop("NISSE_GITEA_REPO") with pytest.raises(ConfigError) as excinfo: AgentConfig.from_env(partial) message = str(excinfo.value) assert "NISSE_MATRIX_TOKEN" in message assert "NISSE_GITEA_REPO" in message def test_empty_is_treated_as_missing() -> None: with pytest.raises(ConfigError, match="NISSE_GITEA_TOKEN"): AgentConfig.from_env({**COMPLETE, "NISSE_GITEA_TOKEN": " "}) def test_operator_lists_are_split_and_trimmed() -> None: config = AgentConfig.from_env( { **COMPLETE, "NISSE_MATRIX_OPERATORS": " @don:test , @other:test ,", "NISSE_GITEA_OPERATORS": "don,", } ) assert config.operators == {"@don:test", "@other:test"} # A trailing comma must not admit an empty identity into an allowlist. assert "" not in config.operators assert config.gitea_operators == {"don"} def test_base_branch_is_overridable() -> None: config = AgentConfig.from_env({**COMPLETE, "NISSE_BASE_BRANCH": "trunk"}) assert config.base_branch == "trunk" def test_redacted_summary_carries_no_secrets() -> None: summary = AgentConfig.from_env(COMPLETE).redacted() flat = " ".join(summary.values()) for secret in ("sk-x", "syt_x", "gt"): assert secret not in flat assert summary["project"] == "greet" def test_state_db_and_workspace_are_separate(tmp_path: Path) -> None: # A2: build artefacts must not share a volume with agent state. config = AgentConfig.from_env(COMPLETE) assert config.state_db.parent != config.workspace def test_state_db_inside_the_clone_is_refused(tmp_path: Path) -> None: # git add -A would commit it, and the push would publish the agent's # own state alongside the project. work = tmp_path / "project" with pytest.raises(ConfigError, match="inside the workspace"): AgentConfig.from_env( { **COMPLETE, "NISSE_WORKSPACE": str(work), "NISSE_STATE_DB": str(work / "state.db"), } ) def test_env_file_inside_the_clone_is_refused(tmp_path: Path) -> None: # The severe case: the agent would commit and push its own credentials # as a side effect of working correctly. work = tmp_path / "project" with pytest.raises(ConfigError, match="NISSE_ENV"): AgentConfig.from_env( { **COMPLETE, "NISSE_WORKSPACE": str(work), "NISSE_STATE_DB": str(tmp_path / "state.db"), "NISSE_ENV": str(work / ".env"), } ) def test_sibling_paths_are_fine(tmp_path: Path) -> None: config = AgentConfig.from_env( { **COMPLETE, "NISSE_WORKSPACE": str(tmp_path / "project"), "NISSE_STATE_DB": str(tmp_path / "state.db"), "NISSE_ENV": str(tmp_path / ".env"), } ) assert config.workspace.name == "project" # --- the YAML file (#32) --------------------------------------------------- FILE = """ state_db: ~/demo/state.db workspace: ~/demo/work project: greet llm: base_url: https://litellm.test token: ${TEST_LLM_TOKEN} roles: executor: local-fast planner: claude-opus-5 delegate: claude-opus-5 consultant: claude-haiku-4-5 matrix: homeserver: https://matrix.test/ user_id: "@nisse-dev-testing:test" token: ${TEST_MATRIX_TOKEN} room_id: "!room:test" operators: ["@don:test", " "] gitea: url: https://gitea.test token: ${TEST_GITEA_TOKEN} repo: don/demo operators: [don] """ SECRETS = { "TEST_LLM_TOKEN": "sk-x", "TEST_MATRIX_TOKEN": "syt_x", "TEST_GITEA_TOKEN": "gt", } def write(tmp_path: Path, text: str) -> Path: path = tmp_path / "nisse-dev.yaml" path.write_text(text) return path def test_a_file_configures_everything_with_secrets_by_reference( tmp_path: Path, ) -> None: config = AgentConfig.from_sources(SECRETS, write(tmp_path, FILE)) assert config.llm_token == "sk-x" assert config.gitea_token == "gt" assert config.roles.executor == "local-fast" assert config.operators == {"@don:test"} # a blank entry admits no one assert config.matrix.homeserver == "https://matrix.test" assert config.tuning.executor.max_turns == 24 # absent section: defaults def test_the_environment_wins_over_the_file(tmp_path: Path) -> None: config = AgentConfig.from_sources( {**SECRETS, "NISSE_ROLE_EXECUTOR": "claude-haiku-4-5"}, write(tmp_path, FILE) ) assert config.roles.executor == "claude-haiku-4-5" def test_an_overridden_key_needs_no_resolvable_reference(tmp_path: Path) -> None: env = {k: v for k, v in SECRETS.items() if k != "TEST_LLM_TOKEN"} config = AgentConfig.from_sources( {**env, "NISSE_LLM_TOKEN": "sk-env"}, write(tmp_path, FILE) ) assert config.llm_token == "sk-env" def test_a_reference_to_an_unset_variable_is_an_error(tmp_path: Path) -> None: env = {k: v for k, v in SECRETS.items() if k != "TEST_GITEA_TOKEN"} with pytest.raises(ConfigError, match=r"gitea.token: references \$\{TEST_GITEA"): AgentConfig.from_sources(env, write(tmp_path, FILE)) def test_tuning_is_read_from_the_file(tmp_path: Path) -> None: text = ( FILE + """ tuning: executor: {max_turns: 40, failure_limit: 3} expert: {consult_budget: 5} delegation: {max_turns: 20, max_tokens: 100000, wall_clock_s: 600} tools: {command_timeout_s: 300, max_output_chars: 2000} """ ) tuning = AgentConfig.from_sources(SECRETS, write(tmp_path, text)).tuning assert (tuning.executor.max_turns, tuning.executor.failure_limit) == (40, 3) assert tuning.expert.consult_budget == 5 assert tuning.delegation.max_tokens == 100_000 assert tuning.delegation.wall_clock_s == 600.0 assert tuning.tools.max_output_chars == 2000 # A section that is only partly given keeps defaults for the rest. partial = FILE + "\ntuning:\n executor: {max_turns: 40}\n" tuning = AgentConfig.from_sources(SECRETS, write(tmp_path, partial)).tuning assert tuning.executor.failure_limit == 2 def test_every_problem_in_a_file_is_reported_at_once(tmp_path: Path) -> None: # A misspelt knob that was silently ignored would look exactly like a knob # with no effect, so unknown keys are errors. text = ( FILE.replace("project: greet\n", "") + """ llm_extra: 1 tuning: executor: {max_turn: 40, failure_limit: 0} expert: {consult_budget: true} bogus: {x: 1} """ ) with pytest.raises(ConfigError) as excinfo: AgentConfig.from_sources(SECRETS, write(tmp_path, text)) message = str(excinfo.value) for expected in ( "project (NISSE_PROJECT)", "llm_extra: unknown setting", "tuning.executor.max_turn: unknown setting", "tuning.executor.failure_limit: must be a positive number", "tuning.expert.consult_budget: must be a positive number", "tuning.bogus: unknown section", ): assert expected in message def test_a_whole_number_is_required_where_one_is_meant(tmp_path: Path) -> None: text = FILE + "\ntuning:\n executor: {max_turns: 2.5}\n" with pytest.raises(ConfigError, match="tuning.executor.max_turns"): AgentConfig.from_sources(SECRETS, write(tmp_path, text)) def test_max_output_tokens_comes_from_the_file_or_the_environment( tmp_path: Path, ) -> None: text = FILE.replace( " base_url: https://litellm.test\n", " base_url: https://litellm.test\n max_output_tokens: 8192\n", ) path = write(tmp_path, text) assert AgentConfig.from_sources(SECRETS, path).max_output_tokens == 8192 env = {**SECRETS, "NISSE_MAX_OUTPUT_TOKENS": "4096"} assert AgentConfig.from_sources(env, path).max_output_tokens == 4096 def test_load_finds_the_named_file_and_refuses_a_missing_one(tmp_path: Path) -> None: path = write(tmp_path, FILE) assert AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(path)}).project == "greet" with pytest.raises(ConfigError, match="missing"): AgentConfig.load({**SECRETS, "NISSE_CONFIG": str(tmp_path / "absent.yaml")}) def test_load_without_a_file_uses_the_environment( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: monkeypatch.chdir(tmp_path) # no ./nisse-dev.yaml here assert AgentConfig.load(COMPLETE).config_file is None def test_a_config_file_inside_the_clone_is_refused(tmp_path: Path) -> None: work = tmp_path / "project" work.mkdir() text = FILE.replace("workspace: ~/demo/work", f"workspace: {work}") path = work / "nisse-dev.yaml" path.write_text(text) with pytest.raises(ConfigError, match="NISSE_CONFIG"): AgentConfig.from_sources(SECRETS, path) def test_the_example_file_is_valid(tmp_path: Path) -> None: # The example documents every knob; it must not drift out of loading. example = Path(__file__).resolve().parents[2] / "nisse-dev.example.yaml" env = { "NISSE_LLM_TOKEN": "sk-x", "NISSE_MATRIX_TOKEN": "syt_x", "NISSE_GITEA_TOKEN": "gt", } config = AgentConfig.from_sources(env, example) assert config.tuning.executor.max_turns == 24