Make the config file the only home for settings
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s

With no production instance yet, there is no reason to carry two ways
of configuring the agent. Every environment variable that duplicated a
config key is gone; the environment now supplies only the secrets the
file references by ${…}, plus NISSE_CONFIG and NISSE_ENV to find the
files.

- AgentConfig loads from a file (load, from_file) or a parsed tree
  (from_tree, for tests); a missing file is an error naming the example
- The from_env constructors on LiteLLMClient, AnthropicPassthroughClient,
  GiteaClient, MatrixConfig and RoleMap are removed, with the env
  reading for max output tokens; clients take their settings from config
- The live integration tests share a committed agent config,
  tests/integration/nisse-test.yaml, aimed at test resources, and one
  conftest that loads it and builds clients. NISSE_LIVE_PLANNER_MODEL
  becomes its planner role; NISSE_CAPABILITY_MODELS stays as a per-run
  switch
- A missing secret now fails the live tests instead of skipping them
- CI passes only the three tokens
- .env.example is secrets only; spec A5 says config comes from the file

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-09-23 23:25:58 +00:00
1 parent df449c7e34
commit 15d69799bf
28 files changed
+571 -777

No files matched your search

+13 -12
View File
@@ -8,7 +8,7 @@ from typing import Any
import httpx
import pytest
from test_config import COMPLETE
from test_config import SECRETS, tree
from nisse_dev.agent import build_agent, discover_models, prepare_workspace
from nisse_dev.catalog import ModelCatalog, UnknownModel
@@ -34,14 +34,15 @@ TEST_CATALOG = ModelCatalog.from_payload(
)
def config_for(tmp_path: Path, **overrides: str) -> AgentConfig:
env = {
**COMPLETE,
"NISSE_STATE_DB": str(tmp_path / "state.db"),
"NISSE_WORKSPACE": str(tmp_path / "work"),
**overrides,
}
return AgentConfig.from_env(env)
def config_for(tmp_path: Path, **changes: str) -> AgentConfig:
return AgentConfig.from_tree(
tree(
state_db=str(tmp_path / "state.db"),
workspace=str(tmp_path / "work"),
**changes,
),
SECRETS,
)
@pytest.fixture
@@ -59,7 +60,7 @@ def origin(tmp_path: Path) -> Path:
def test_workspace_is_cloned_when_absent(tmp_path: Path, origin: Path) -> None:
config = config_for(tmp_path, NISSE_GITEA_URL=f"file://{origin}")
config = config_for(tmp_path, gitea__url=f"file://{origin}")
# authenticated_url would mangle a file:// remote, so clone directly.
GitRepo(tmp_path).clone(str(origin), config.workspace)
repo = prepare_workspace(config, "nisse-dev-testing")
@@ -206,7 +207,7 @@ def test_httpx_clients_are_not_shared_between_routes(
def test_a_typod_role_refuses_to_boot(tmp_path: Path, monkeypatch) -> None:
# Misconfiguration should cost a second at startup, not be discovered ten
# minutes into a milestone.
config = config_for(tmp_path, NISSE_ROLE_PLANNER="clause-opus-5")
config = config_for(tmp_path, roles__planner="clause-opus-5")
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
@@ -220,7 +221,7 @@ def test_a_role_this_key_cannot_reach_refuses_to_boot(
) -> None:
# /model/info is scoped per key, so the same check catches a role pointed
# at a real model that this token has no access to.
config = config_for(tmp_path, NISSE_ROLE_EXECUTOR="local-fast")
config = config_for(tmp_path, roles__executor="local-fast")
monkeypatch.setattr(
"nisse_dev.agent.ModelCatalog.discover",
classmethod(lambda cls, *a, **kw: TEST_CATALOG),
+225 -235
View File
@@ -1,163 +1,37 @@
import copy
from pathlib import Path
from typing import Any
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",
# A complete config as the YAML file would give it, with secrets by reference.
COMPLETE: dict[str, Any] = {
"state_db": "~/demo/state.db",
"workspace": "~/demo/work",
"project": "greet",
"llm": {"base_url": "https://litellm.test", "token": "${TEST_LLM_TOKEN}"},
"roles": {
"executor": "claude-haiku-4-5",
"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"],
},
}
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",
@@ -165,86 +39,133 @@ SECRETS = {
}
def write(tmp_path: Path, text: str) -> Path:
path = tmp_path / "nisse-dev.yaml"
path.write_text(text)
return path
def tree(**changes: Any) -> dict[str, Any]:
"""COMPLETE with dotted keys changed; a value of None removes the key."""
result = copy.deepcopy(COMPLETE)
for dotted, value in changes.items():
*parents, leaf = dotted.split("__")
node = result
for part in parents:
node = node.setdefault(part, {})
if value is None:
node.pop(leaf, None)
else:
node[leaf] = value
return result
def test_a_file_configures_everything_with_secrets_by_reference(
tmp_path: Path,
) -> None:
config = AgentConfig.from_sources(SECRETS, write(tmp_path, FILE))
def build(**changes: Any) -> AgentConfig:
return AgentConfig.from_tree(tree(**changes), SECRETS)
def test_complete_config_builds_with_secrets_by_reference() -> None:
config = build()
assert config.project == "greet"
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.roles.planner == "claude-opus-5"
assert config.matrix.room_id == "!room:test"
assert config.matrix.homeserver == "https://matrix.test"
assert config.gitea_repo == "don/demo"
assert config.base_branch == "main"
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_paths_expand_the_home_directory() -> None:
config = build()
assert config.workspace.is_absolute()
assert "~" not in str(config.state_db)
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_every_missing_setting_is_reported_at_once() -> None:
# Failing at first use would surface a missing token twenty minutes into
# a milestone, on the call that mattered.
with pytest.raises(ConfigError) as excinfo:
build(matrix__token=None, gitea__repo=None)
message = str(excinfo.value)
assert "matrix.token" in message
assert "gitea.repo" in message
def test_a_reference_to_an_unset_variable_is_an_error(tmp_path: Path) -> None:
def test_empty_is_treated_as_missing() -> None:
with pytest.raises(ConfigError, match="gitea.repo"):
build(gitea__repo=" ")
def test_a_reference_to_an_unset_variable_is_an_error() -> None:
# Never an empty string: an empty token would pass for a value and fail
# at the first request.
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))
AgentConfig.from_tree(tree(), env)
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}
"""
def test_operator_lists_drop_blanks() -> None:
config = build(matrix__operators=[" @don:test ", "@other:test", " "])
assert config.operators == {"@don:test", "@other:test"}
# A blank entry must not admit an empty identity into an allowlist.
assert "" not in config.operators
def test_an_operator_list_of_only_blanks_is_missing() -> None:
with pytest.raises(ConfigError, match="gitea.operators"):
build(gitea__operators=[" "])
def test_base_branch_is_configurable() -> None:
assert build(gitea__base_branch="trunk").base_branch == "trunk"
def test_max_output_tokens_is_configurable_and_checked() -> None:
assert build(llm__max_output_tokens=8192).max_output_tokens == 8192
with pytest.raises(ConfigError, match="llm.max_output_tokens"):
build(llm__max_output_tokens=0)
def test_redacted_summary_carries_no_secrets() -> None:
summary = build().redacted()
flat = " ".join(summary.values())
for secret in SECRETS.values():
assert secret not in flat
assert summary["project"] == "greet"
def test_tuning_is_read() -> None:
config = build(
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
tuning = config.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
def test_a_partly_given_tuning_section_keeps_the_other_defaults() -> None:
tuning = build(tuning={"executor": {"max_turns": 40}}).tuning
assert tuning.executor.failure_limit == 2
def test_every_problem_in_a_file_is_reported_at_once(tmp_path: Path) -> None:
def test_every_problem_is_reported_at_once() -> 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}
"""
)
bad = tree(project=None, llm_extra=1)
bad["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))
AgentConfig.from_tree(bad, SECRETS)
message = str(excinfo.value)
for expected in (
"project (NISSE_PROJECT)",
"project",
"llm_extra: unknown setting",
"tuning.executor.max_turn: unknown setting",
"tuning.executor.failure_limit: must be a positive number",
@@ -254,50 +175,76 @@ tuning:
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"
def test_a_whole_number_is_required_where_one_is_meant() -> None:
with pytest.raises(ConfigError, match="tuning.executor.max_turns"):
AgentConfig.from_sources(SECRETS, write(tmp_path, text))
build(tuning={"executor": {"max_turns": 2.5}})
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
# --- the file --------------------------------------------------------------
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]
"""
def test_load_finds_the_named_file_and_refuses_a_missing_one(tmp_path: Path) -> None:
path = write(tmp_path, FILE)
def write(directory: Path, text: str = FILE) -> Path:
path = directory / "nisse-dev.yaml"
path.write_text(text)
return path
def test_a_file_is_read(tmp_path: Path) -> None:
config = AgentConfig.from_file(write(tmp_path), SECRETS)
assert config.roles.executor == "local-fast"
assert config.config_file == tmp_path / "nisse-dev.yaml"
def test_invalid_yaml_is_reported(tmp_path: Path) -> None:
with pytest.raises(ConfigError, match="not valid YAML"):
AgentConfig.from_file(write(tmp_path, "roles: [unclosed"), SECRETS)
def test_load_uses_nisse_config(tmp_path: Path) -> None:
path = write(tmp_path)
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(
def test_load_defaults_to_the_working_directory(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path) # no ./nisse-dev.yaml here
assert AgentConfig.load(COMPLETE).config_file is None
write(tmp_path)
monkeypatch.chdir(tmp_path)
assert AgentConfig.load(SECRETS).project == "greet"
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_load_without_a_file_says_how_to_make_one(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.chdir(tmp_path)
with pytest.raises(ConfigError, match="nisse-dev.example.yaml"):
AgentConfig.load(SECRETS)
def test_the_example_file_is_valid(tmp_path: Path) -> None:
def test_the_example_file_is_valid() -> None:
# The example documents every knob; it must not drift out of loading.
example = Path(__file__).resolve().parents[2] / "nisse-dev.example.yaml"
env = {
@@ -305,5 +252,48 @@ def test_the_example_file_is_valid(tmp_path: Path) -> None:
"NISSE_MATRIX_TOKEN": "syt_x",
"NISSE_GITEA_TOKEN": "gt",
}
config = AgentConfig.from_sources(env, example)
assert config.tuning.executor.max_turns == 24
assert AgentConfig.from_file(example, env).tuning.executor.max_turns == 24
# --- nothing that carries secrets may sit inside the clone -----------------
def test_state_db_and_workspace_are_separate() -> None:
# A2: build artefacts must not share a volume with agent state.
config = build()
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"):
build(workspace=str(work), 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_tree(
tree(workspace=str(work), state_db=str(tmp_path / "state.db")),
{**SECRETS, "NISSE_ENV": str(work / ".env")},
)
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}")
with pytest.raises(ConfigError, match="NISSE_CONFIG"):
AgentConfig.from_file(write(work, text), SECRETS)
def test_sibling_paths_are_fine(tmp_path: Path) -> None:
config = AgentConfig.from_tree(
tree(workspace=str(tmp_path / "project"), state_db=str(tmp_path / "state.db")),
{**SECRETS, "NISSE_ENV": str(tmp_path / ".env")},
)
assert config.workspace.name == "project"
+7 -9
View File
@@ -2,25 +2,23 @@ from pathlib import Path
import pytest
from nisse_dev.config import REQUIRED
from nisse_dev.main import main
def test_main_refuses_to_start_without_configuration(
def test_main_refuses_to_start_without_a_config_file(
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
# Clear the real variables rather than swapping os.environ: from_env binds
# the environment object as a default argument, so a replacement would be
# ignored. And point NISSE_ENV at a file that does not exist, because the
# working directory's .env holds live credentials.
for name in REQUIRED:
monkeypatch.delenv(name, raising=False)
# Run from an empty directory, and point NISSE_ENV at a file that does not
# exist: the repository's .env holds live credentials, and a test must
# never be one cwd away from starting a real agent.
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("NISSE_CONFIG", raising=False)
monkeypatch.setenv("NISSE_ENV", str(tmp_path / "absent.env"))
assert main() == 2
err = capsys.readouterr().err
assert "cannot start" in err
assert "NISSE_LLM_TOKEN" in err
assert "nisse-dev.example.yaml" in err
-5
View File
@@ -162,11 +162,6 @@ def test_list_comments_passes_since() -> None:
assert seen["since"] == "2026-09-07T00:00:00Z"
def test_from_env_requires_config() -> None:
with pytest.raises(GiteaError, match="NISSE_GITEA_URL"):
GiteaClient.from_env({})
# --- reconciler (A3 external truth) --------------------------------------
+11 -23
View File
@@ -21,7 +21,6 @@ from nisse_dev.llm import (
RoleMap,
TruncatedResponse,
UpstreamUnavailable,
max_output_tokens,
)
EVENTS = [StoredEvent(id=1, ts="t", event=UserMessage(text="hi", source="m:1"))]
@@ -165,16 +164,16 @@ def test_malformed_response_raises_llm_error() -> None:
client.complete(EVENTS, tools=None, model="local/qwen")
ROLE_ENV = {
"NISSE_ROLE_EXECUTOR": "local/qwen",
"NISSE_ROLE_PLANNER": "anthropic/claude-opus-5",
"NISSE_ROLE_DELEGATE": "anthropic/claude-opus-5",
"NISSE_ROLE_CONSULTANT": "anthropic/claude-opus-5",
}
ROLES = RoleMap(
executor="local/qwen",
planner="anthropic/claude-opus-5",
delegate="anthropic/claude-opus-5",
consultant="anthropic/claude-opus-5",
)
def test_role_map_resolves_and_pins() -> None:
roles = RoleMap.from_env(ROLE_ENV)
roles = ROLES
assert roles.resolve("executor") == "local/qwen"
assert roles.pinned() == {
"executor": "local/qwen",
@@ -184,19 +183,9 @@ def test_role_map_resolves_and_pins() -> None:
}
def test_role_map_missing_env_raises() -> None:
with pytest.raises(LLMError, match="NISSE_ROLE_PLANNER"):
RoleMap.from_env({"NISSE_ROLE_EXECUTOR": "local/qwen"})
def test_unknown_role_raises() -> None:
with pytest.raises(LLMError, match="unknown role"):
RoleMap.from_env(ROLE_ENV).resolve("boss")
def test_client_from_env_requires_config() -> None:
with pytest.raises(LLMError, match="NISSE_LLM_BASE_URL"):
LiteLLMClient.from_env({})
ROLES.resolve("boss")
def test_load_env_file(tmp_path: Path) -> None:
@@ -306,12 +295,11 @@ def test_a_complete_answer_is_not_mistaken_for_truncation() -> None:
assert call.arguments["content"] == "a real plan"
def test_output_ceiling_is_configurable_and_generous_by_default() -> None:
def test_output_ceiling_is_generous_by_default() -> None:
# Output is billed by what is produced, so a low ceiling buys nothing and
# costs silent truncation.
# costs silent truncation. The configured value is tested in test_config.
assert DEFAULT_MAX_OUTPUT_TOKENS >= 8192
assert max_output_tokens({}) == DEFAULT_MAX_OUTPUT_TOKENS
assert max_output_tokens({"NISSE_MAX_OUTPUT_TOKENS": "2048"}) == 2048
assert LiteLLMClient("http://x", "t")._max_tokens == DEFAULT_MAX_OUTPUT_TOKENS
def test_truncation_is_an_llm_error_so_callers_already_handle_it() -> None:
-17
View File
@@ -143,20 +143,3 @@ def test_start_without_handler_raises() -> None:
)
with pytest.raises(TransportError, match="no message handler"):
transport.start()
def test_config_from_env_reports_missing() -> None:
with pytest.raises(TransportError, match="NISSE_MATRIX_TOKEN"):
MatrixConfig.from_env({"NISSE_MATRIX_HOMESERVER": "https://m"})
def test_config_from_env_custom_room_var() -> None:
env = {
"NISSE_MATRIX_HOMESERVER": "https://m/",
"NISSE_MATRIX_USER_ID": "@nisse-dev:test",
"NISSE_MATRIX_TOKEN": "t",
"NISSE_MATRIX_TEST_ROOM_ID": "!t:test",
}
config = MatrixConfig.from_env(env, room_var="NISSE_MATRIX_TEST_ROOM_ID")
assert config.room_id == "!t:test"
assert config.homeserver == "https://m" # trailing slash stripped