Files
m4 470cf75722 merge: bring upstream v0.3.0 (72 commits) into Ai4Sci fork
Merged upstream/main (418abca, release v0.3.0) into our fork on a
dedicated branch. 21 conflicting files resolved; main worktree untouched.

Resolution policy and key decisions:
- Keep Ai4Sci runtime endpoints, durable dispatch, workspace scopes and
  the HITL/DynamicReview approval chain (approval path is product-critical).
- Adopt upstream model registry (llm/registry.py): our 136 model entries
  are a strict subset of upstream's 180, so dropping our inline table
  loses nothing and gains 44 new models.
- Adopt upstream native EvoChatDeepSeek; drop our obsolete
  _patch_deepseek_reasoning_passback monkey patch.
- Keep our six patches.py additions, ported onto upstream's new
  _OpenAICompatContent class: stable tool-call ids, tool-history
  sanitization, drop_reasoning_metadata, empty-SSE keepalive,
  extracted-document-text patch, _has_assistant_tool_protocol.
- Keep our skill-budget middleware path (skills=None) instead of passing
  skills through, to avoid double loading.
- Keep sanitized error labels (_safe_error_label) while adopting
  upstream's injected MiddlewareEventSink for fallback narration.
- Keep port 3076 and the LANGGRAPH_SERVER_URL override; adopt upstream's
  host/probe-host handling and CONFIG_DRIFT_SINCE_LAUNCH.
- Adopt upstream dependency stack: deepagents 0.7.6, langchain-quickjs
  0.3.7, langgraph-api 0.14; keep our extra deps (rfc8785, pillow,
  firecrawl-anydoc, nest-asyncio).
- Align call sites with upstream APIs: create_tool_selector_middleware
  now takes events= instead of track_stream_selection=.
2026-09-13 16:07:27 +08:00

1192 lines
50 KiB
Python

"""Tests for EvoScientist configuration management."""
import os
from pathlib import Path
import pytest
import yaml
from EvoScientist.config import (
EvoScientistConfig,
MemoryControls,
MemoryObservationTarget,
MemoryObservationWriter,
MemorySkillSynthesisCadence,
MemorySkillSynthesisMode,
apply_config_to_env,
get_config_dir,
get_config_path,
get_config_value,
get_effective_config,
list_config,
load_config,
reset_config,
save_config,
set_config_value,
)
def test_langgraph_dev_port_defaults_to_ai4sci_runtime_port():
assert EvoScientistConfig().langgraph_dev_port == 3076
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture(autouse=True)
def _restore_dangerous_env():
"""Snapshot/restore EVOSCIENTIST_DANGEROUS_MODE around every test.
apply_config_to_env writes this var via direct ``os.environ`` assignment, and
monkeypatch's ``delenv`` of an originally-absent key records no undo — so
without this, a test that turns dangerous mode on would leak the env var into
later tests (an order-dependent landmine, e.g. under pytest-randomly).
"""
_sentinel = object()
_prev = os.environ.get("EVOSCIENTIST_DANGEROUS_MODE", _sentinel)
yield
if _prev is _sentinel:
os.environ.pop("EVOSCIENTIST_DANGEROUS_MODE", None)
else:
os.environ["EVOSCIENTIST_DANGEROUS_MODE"] = _prev
@pytest.fixture
def temp_config_dir(tmp_path, monkeypatch):
"""Use a temporary directory for config during tests."""
config_dir = tmp_path / "evoscientist"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
# Also clear any API keys from environment
for key in [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"ATLASCLOUD_API_KEY",
"TAVILY_API_KEY",
"S2_API_KEY",
"EVOSCIENTIST_DEFAULT_MODE",
"EVOSCIENTIST_WORKSPACE_DIR",
"EVOSCIENTIST_UI_BACKEND",
"EVOSCIENTIST_MEMORY_PROFILE_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATION_WRITER",
"EVOSCIENTIST_MEMORY_WORKERS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
"EVOSCIENTIST_AUXILIARY_MODEL",
"EVOSCIENTIST_AUXILIARY_PROVIDER",
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
"EVOSCIENTIST_DANGEROUS_MODE",
"EVOSCIENTIST_MAX_CACHED_FILES",
]:
monkeypatch.delenv(key, raising=False)
return config_dir
@pytest.fixture
def clean_env(monkeypatch):
"""Remove environment variables that affect config (but keep temp config dir)."""
for key in [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"ATLASCLOUD_API_KEY",
"TAVILY_API_KEY",
"S2_API_KEY",
"EVOSCIENTIST_DEFAULT_MODE",
"EVOSCIENTIST_WORKSPACE_DIR",
"EVOSCIENTIST_UI_BACKEND",
"EVOSCIENTIST_MEMORY_PROFILE_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED",
"EVOSCIENTIST_MEMORY_OBSERVATION_WRITER",
"EVOSCIENTIST_MEMORY_WORKERS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE",
"EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME",
"EVOSCIENTIST_AUXILIARY_MODEL",
"EVOSCIENTIST_AUXILIARY_PROVIDER",
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE",
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
"EVOSCIENTIST_DANGEROUS_MODE",
"EVOSCIENTIST_MAX_CACHED_FILES",
]:
monkeypatch.delenv(key, raising=False)
# =============================================================================
# Test EvoScientistConfig dataclass
# =============================================================================
class TestEvoScientistConfig:
def test_default_values(self):
"""Test that default values are set correctly."""
config = EvoScientistConfig()
assert config.anthropic_api_key == ""
assert config.openai_api_key == ""
assert config.tavily_api_key == ""
assert config.semantic_scholar_api_key == ""
assert config.provider == "anthropic"
assert config.model == "claude-sonnet-4-6"
assert config.default_mode == "daemon"
assert config.default_workdir == ""
assert config.show_thinking is True
assert config.ui_backend == "tui"
assert config.log_level == "warning"
assert config.openrouter_anthropic_prompt_cache is True
assert config.openrouter_http_referer == (
"https://github.com/EvoScientist/EvoScientist"
)
assert config.openrouter_app_title == "EvoScientist"
assert config.openrouter_app_categories == "creative-writing,personal-agent"
assert config.memory_profile_enabled is True
assert config.memory_observations_enabled is True
assert config.memory_observation_writer == MemoryObservationWriter.ALL
assert config.memory_workers_enabled is True
assert config.memory_skill_synthesis_enabled is True
assert config.memory_skill_synthesis_mode == MemorySkillSynthesisMode.REVIEW
assert (
config.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.WEEKLY
)
assert config.memory_skill_synthesis_time == "03:00"
assert config.ollama_base_url == ""
assert config.channel_debug_tracing is False
assert config.imessage_enabled is False
assert config.imessage_allowed_senders == ""
assert config.repetitive_tool_call_threshold == 2
assert config.max_consecutive_tool_errors == 3
def test_bind_hosts_default_to_loopback(self):
"""Both servers stay off the network until asked.
The backend is an unauthenticated API whose agent can run shell
commands; the front-end serves the workspace file/upload and
skill-install endpoints. Neither is a safe default to publish, so
``--host 0.0.0.0`` / ``config set`` opts in to LAN exposure.
"""
config = EvoScientistConfig()
assert config.langgraph_dev_host == "127.0.0.1"
assert config.webui_host == "127.0.0.1"
@pytest.mark.parametrize(
("field", "default"),
[("langgraph_dev_host", "127.0.0.1"), ("webui_host", "127.0.0.1")],
)
@pytest.mark.parametrize("blank", ["", " ", "\t"])
def test_blank_bind_host_falls_back_to_default(self, field, blank, default):
"""A blank host would reach socket.bind() verbatim and surface as an
opaque gaierror; it degrades to the field's own default instead."""
config = EvoScientistConfig(**{field: blank})
assert getattr(config, field) == default
@pytest.mark.parametrize(
("field", "value"),
[("langgraph_dev_host", "0.0.0.0"), ("webui_host", "0.0.0.0")],
)
def test_bind_host_opt_out_is_preserved(self, field, value):
"""The LAN escape hatch — widening either bind to the wildcard — must
survive normalization untouched."""
config = EvoScientistConfig(**{field: value})
assert getattr(config, field) == value
@pytest.mark.parametrize("field", ["langgraph_dev_host", "webui_host"])
def test_bind_host_is_stripped(self, field):
config = EvoScientistConfig(**{field: " 0.0.0.0 "})
assert getattr(config, field) == "0.0.0.0"
def test_auth_mode_default(self):
"""Test that anthropic_auth_mode defaults to api_key."""
config = EvoScientistConfig()
assert config.anthropic_auth_mode == "api_key"
def test_auth_mode_set(self):
"""Test that anthropic_auth_mode can be set."""
config = EvoScientistConfig(anthropic_auth_mode="oauth")
assert config.anthropic_auth_mode == "oauth"
def test_openai_auth_mode_default(self):
"""Test that openai_auth_mode defaults to api_key."""
config = EvoScientistConfig()
assert config.openai_auth_mode == "api_key"
def test_openai_auth_mode_set(self):
"""Test that openai_auth_mode can be set."""
config = EvoScientistConfig(openai_auth_mode="oauth")
assert config.openai_auth_mode == "oauth"
def test_custom_values(self):
"""Test that custom values can be set."""
config = EvoScientistConfig(
anthropic_api_key="sk-ant-test",
provider="openai",
model="gpt-4o",
default_mode="run",
)
assert config.anthropic_api_key == "sk-ant-test"
assert config.provider == "openai"
assert config.model == "gpt-4o"
assert config.default_mode == "run"
def test_dangerous_mode_default(self):
"""dangerous_mode defaults off and does not force auto_approve."""
config = EvoScientistConfig()
assert config.dangerous_mode is False
assert config.auto_approve is False
def test_dangerous_mode_implies_auto_approve(self):
"""Enabling dangerous_mode forces auto_approve via __post_init__."""
config = EvoScientistConfig(dangerous_mode=True)
assert config.dangerous_mode is True
assert config.auto_approve is True
@pytest.mark.parametrize(
"kwargs",
[
{"repetitive_tool_call_threshold": -1},
{"max_consecutive_tool_errors": -1},
{"max_consecutive_tool_errors": True},
],
)
def test_tool_guard_thresholds_must_be_non_negative_integers(self, kwargs):
with pytest.raises(ValueError, match="non-negative integer"):
EvoScientistConfig(**kwargs)
# =============================================================================
# Test config path functions
# =============================================================================
class TestConfigPaths:
def test_get_config_dir_with_explicit_override(self, monkeypatch, tmp_path):
"""An explicit config directory has the highest priority."""
config_dir = tmp_path / "gateway-config"
monkeypatch.setenv("EVOSCIENTIST_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("EVOSCIENTIST_HOME", str(tmp_path / "runtime-home"))
assert get_config_dir() == config_dir.resolve()
def test_get_config_dir_with_evoscientist_home(self, monkeypatch, tmp_path):
"""Runtime home keeps configuration and data under one root."""
home = tmp_path / "runtime-home"
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.setenv("EVOSCIENTIST_HOME", str(home))
assert get_config_dir() == home.resolve() / "config"
def test_get_config_dir_with_xdg(self, monkeypatch, tmp_path):
"""Test config dir uses XDG_CONFIG_HOME when set."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
config_dir = get_config_dir()
assert config_dir == tmp_path / "evoscientist"
def test_get_config_dir_default(self, monkeypatch):
"""Test config dir defaults to ~/.config/evoscientist."""
monkeypatch.delenv("EVOSCIENTIST_CONFIG_DIR", raising=False)
monkeypatch.delenv("EVOSCIENTIST_HOME", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
config_dir = get_config_dir()
assert config_dir == Path.home() / ".config" / "evoscientist"
def test_get_config_path(self, temp_config_dir):
"""Test config file path."""
config_path = get_config_path()
assert config_path == temp_config_dir / "config.yaml"
# =============================================================================
# Test load/save/reset
# =============================================================================
class TestLoadSaveReset:
def test_load_returns_defaults_when_no_file(self, temp_config_dir, clean_env):
"""Test that load returns defaults when config file doesn't exist."""
config = load_config()
assert config.provider == "anthropic"
assert config.model == "claude-sonnet-4-6"
def test_save_creates_file(self, temp_config_dir, clean_env):
"""Test that save creates the config file."""
config = EvoScientistConfig(provider="openai", model="gpt-4o")
save_config(config)
config_path = get_config_path()
assert config_path.exists()
with open(config_path) as f:
data = yaml.safe_load(f)
assert data["provider"] == "openai"
assert data["model"] == "gpt-4o"
def test_save_restricts_config_permissions(self, temp_config_dir, clean_env):
"""Config file permissions should not depend on the process umask."""
original_umask = os.umask(0)
try:
save_config(EvoScientistConfig(anthropic_api_key="test-key"))
finally:
os.umask(original_umask)
config_path = get_config_path()
if os.name == "nt":
assert config_path.exists()
# Windows reports pseudo-permission bits, so we don't test them here.
return
assert config_path.parent.stat().st_mode & 0o777 == 0o700
assert config_path.stat().st_mode & 0o777 == 0o600
def test_load_reads_saved_config(self, temp_config_dir, clean_env):
"""Test that load reads previously saved config."""
original = EvoScientistConfig(
anthropic_api_key="test-key",
provider="openai",
)
save_config(original)
loaded = load_config()
assert loaded.anthropic_api_key == "test-key"
assert loaded.provider == "openai"
def test_load_reads_manually_edited_utf8_config(self, temp_config_dir, clean_env):
"""Config loading handles localized content from manually edited YAML."""
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
"provider: openai\nmodel: gpt-4o\ndefault_workdir: /tmp/café\n",
encoding="utf-8",
)
loaded = load_config()
assert loaded.provider == "openai"
assert loaded.model == "gpt-4o"
assert loaded.default_workdir == "/tmp/café"
def test_save_writes_utf8_config(self, temp_config_dir, clean_env):
"""Config saving writes localized content with UTF-8 encoding."""
save_config(EvoScientistConfig(default_workdir="/tmp/résumé"))
config_path = get_config_path()
raw = config_path.read_text(encoding="utf-8")
assert "default_workdir: /tmp/résumé" in raw
assert load_config().default_workdir == "/tmp/résumé"
def test_reset_deletes_config_file(self, temp_config_dir, clean_env):
"""Test that reset deletes the config file."""
config = EvoScientistConfig(provider="openai")
save_config(config)
config_path = get_config_path()
assert config_path.exists()
reset_config()
assert not config_path.exists()
def test_reset_no_file_is_safe(self, temp_config_dir, clean_env):
"""Test that reset is safe when no config file exists."""
reset_config() # Should not raise
def test_load_ignores_invalid_fields(self, temp_config_dir, clean_env):
"""Test that load ignores unknown fields in config file."""
config_path = get_config_path()
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w") as f:
yaml.safe_dump(
{
"provider": "openai",
"unknown_field": "should_be_ignored",
"another_bad": 123,
},
f,
)
config = load_config()
assert config.provider == "openai"
assert not hasattr(config, "unknown_field")
# =============================================================================
# Test get/set single values
# =============================================================================
class TestGetSetValues:
def test_get_config_value(self, temp_config_dir, clean_env):
"""Test getting a single config value."""
config = EvoScientistConfig(model="gpt-4o-mini")
save_config(config)
assert get_config_value("model") == "gpt-4o-mini"
def test_get_config_value_invalid_key(self, temp_config_dir, clean_env):
"""Test getting an invalid key returns None."""
assert get_config_value("nonexistent_key") is None
def test_set_config_value(self, temp_config_dir, clean_env):
"""Test setting a single config value."""
save_config(EvoScientistConfig())
result = set_config_value("model", "gpt-4o")
assert result is True
config = load_config()
assert config.model == "gpt-4o"
def test_set_config_value_invalid_key(self, temp_config_dir, clean_env):
"""Test setting an invalid key returns False."""
result = set_config_value("nonexistent_key", "value")
assert result is False
def test_set_config_value_type_coercion_bool(self, temp_config_dir, clean_env):
"""Test that boolean values are coerced correctly."""
save_config(EvoScientistConfig())
set_config_value("show_thinking", "false")
assert get_config_value("show_thinking") is False
set_config_value("show_thinking", "1")
assert get_config_value("show_thinking") is True
set_config_value("show_thinking", "yes")
assert get_config_value("show_thinking") is True
def test_set_imessage_enabled_coercion(self, temp_config_dir, clean_env):
"""Test that imessage_enabled is coerced from string to bool."""
save_config(EvoScientistConfig())
set_config_value("imessage_enabled", "true")
assert get_config_value("imessage_enabled") is True
set_config_value("imessage_enabled", "false")
assert get_config_value("imessage_enabled") is False
def test_set_imessage_allowed_senders(self, temp_config_dir, clean_env):
"""Test that imessage_allowed_senders stores comma-separated string."""
save_config(EvoScientistConfig())
set_config_value("imessage_allowed_senders", "+1234567890,+0987654321")
assert get_config_value("imessage_allowed_senders") == "+1234567890,+0987654321"
def test_set_channel_debug_tracing_coercion(self, temp_config_dir, clean_env):
"""Test that channel_debug_tracing is coerced from string to bool."""
save_config(EvoScientistConfig())
set_config_value("channel_debug_tracing", "true")
assert get_config_value("channel_debug_tracing") is True
set_config_value("channel_debug_tracing", "false")
assert get_config_value("channel_debug_tracing") is False
def test_set_memory_observation_writer_validates_value(
self, temp_config_dir, clean_env
):
"""Observation writer mode accepts only the supported product controls."""
save_config(
EvoScientistConfig(memory_observation_writer=MemoryObservationWriter.ALL)
)
assert set_config_value("memory_observation_writer", "worker") is True
assert get_config_value("memory_observation_writer") == "worker"
assert set_config_value("memory_observation_writer", "AGENT") is True
assert get_config_value("memory_observation_writer") == "agent"
assert set_config_value("memory_observation_writer", "subagent") is False
assert get_config_value("memory_observation_writer") == "agent"
def test_memory_controls_observation_writer_targets(self):
"""MemoryControls centralizes observation writer target semantics."""
worker_controls = MemoryControls.from_config(
EvoScientistConfig(
memory_profile_enabled=False,
memory_observations_enabled=True,
memory_observation_writer=MemoryObservationWriter.WORKER,
memory_workers_enabled=True,
)
)
all_controls = MemoryControls.from_config(
EvoScientistConfig(
memory_profile_enabled=False,
memory_observations_enabled=True,
memory_observation_writer=MemoryObservationWriter.ALL,
memory_workers_enabled=True,
)
)
assert worker_controls.observation_tool_enabled(
MemoryObservationTarget.TURN_WORKER
)
assert worker_controls.worker_needed(MemoryObservationTarget.TURN_WORKER)
assert worker_controls.observation_tool_enabled(
MemoryObservationTarget.SUBAGENT_WORKER
)
assert not worker_controls.observation_tool_enabled(
MemoryObservationTarget.AGENT
)
assert all_controls.worker_needed(MemoryObservationTarget.TURN_WORKER)
assert all_controls.observation_tool_enabled(MemoryObservationTarget.AGENT)
assert all_controls.observation_tool_enabled(
MemoryObservationTarget.TURN_WORKER
)
assert all_controls.observation_tool_enabled(
MemoryObservationTarget.SUBAGENT_WORKER
)
def test_set_memory_skill_synthesis_values_validate(
self, temp_config_dir, clean_env
):
save_config(EvoScientistConfig())
assert set_config_value("memory_skill_synthesis_mode", "auto") is True
assert get_config_value("memory_skill_synthesis_mode") == "auto"
assert set_config_value("memory_skill_synthesis_mode", "always") is False
assert get_config_value("memory_skill_synthesis_mode") == "auto"
assert set_config_value("memory_skill_synthesis_cadence", "nightly") is True
assert get_config_value("memory_skill_synthesis_cadence") == "nightly"
assert set_config_value("memory_skill_synthesis_cadence", "hourly") is False
assert get_config_value("memory_skill_synthesis_cadence") == "nightly"
assert set_config_value("memory_skill_synthesis_time", "3:05") is True
assert get_config_value("memory_skill_synthesis_time") == "03:05"
assert set_config_value("memory_skill_synthesis_time", "24:00") is False
assert get_config_value("memory_skill_synthesis_time") == "03:05"
loaded = load_config()
assert loaded.memory_skill_synthesis_mode == MemorySkillSynthesisMode.AUTO
assert (
loaded.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.NIGHTLY
)
def test_list_config(self, temp_config_dir, clean_env):
"""Test listing all config values."""
config = EvoScientistConfig(provider="openai", model="gpt-4o")
save_config(config)
all_config = list_config()
assert isinstance(all_config, dict)
assert all_config["provider"] == "openai"
assert all_config["model"] == "gpt-4o"
# =============================================================================
# Test priority chain (CLI > env > file > default)
# =============================================================================
class TestPriorityChain:
def test_defaults_when_nothing_set(self, temp_config_dir, clean_env, monkeypatch):
"""Test defaults are used when nothing is configured."""
# Ensure no env vars affect the test
for key in [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"TAVILY_API_KEY",
"EVOSCIENTIST_DEFAULT_MODE",
"EVOSCIENTIST_WORKSPACE_DIR",
"EVOSCIENTIST_UI_BACKEND",
]:
monkeypatch.delenv(key, raising=False)
config = get_effective_config()
assert config.provider == "anthropic"
assert config.default_mode == "daemon"
def test_file_overrides_defaults(self, temp_config_dir, clean_env):
"""Test file config overrides defaults."""
save_config(EvoScientistConfig(provider="openai"))
config = get_effective_config()
assert config.provider == "openai"
def test_env_overrides_file(self, temp_config_dir, monkeypatch):
"""Test environment variables override file config."""
save_config(EvoScientistConfig(default_mode="run"))
monkeypatch.setenv("EVOSCIENTIST_DEFAULT_MODE", "daemon")
config = get_effective_config()
assert config.default_mode == "daemon"
def test_cli_overrides_env(self, temp_config_dir, monkeypatch):
"""Test CLI arguments override environment variables."""
monkeypatch.setenv("EVOSCIENTIST_DEFAULT_MODE", "daemon")
config = get_effective_config(cli_overrides={"default_mode": "run"})
assert config.default_mode == "run"
def test_cli_overrides_file(self, temp_config_dir, clean_env):
"""Test CLI arguments override file config."""
save_config(EvoScientistConfig(model="gpt-4o"))
config = get_effective_config(cli_overrides={"model": "claude-opus-4-8"})
assert config.model == "claude-opus-4-8"
def test_env_ui_backend_override(self, temp_config_dir, monkeypatch):
"""UI backend can be selected via environment variable."""
save_config(EvoScientistConfig(ui_backend="cli"))
monkeypatch.setenv("EVOSCIENTIST_UI_BACKEND", "tui")
config = get_effective_config()
assert config.ui_backend == "tui"
def test_env_log_level_override(self, temp_config_dir, monkeypatch):
"""Log level can be selected via environment variable."""
save_config(EvoScientistConfig(log_level="warning"))
monkeypatch.setenv("EVOSCIENTIST_LOG_LEVEL", "DEBUG")
config = get_effective_config()
assert config.log_level == "DEBUG"
def test_env_reasoning_effort_is_not_a_config_override(self, temp_config_dir, monkeypatch):
"""Reasoning is selected by the invocation plan, never deployment env."""
save_config(EvoScientistConfig())
monkeypatch.setenv("EVOSCIENTIST_REASONING_EFFORT", "high")
config = get_effective_config()
assert not hasattr(config, "reasoning_effort")
def test_env_channel_debug_tracing_override(self, temp_config_dir, monkeypatch):
"""Channel tracing can be enabled via environment variable."""
save_config(EvoScientistConfig(channel_debug_tracing=False))
monkeypatch.setenv("EVOSCIENTIST_CHANNEL_DEBUG_TRACING", "true")
config = get_effective_config()
assert config.channel_debug_tracing is True
def test_env_memory_controls_override(self, temp_config_dir, monkeypatch):
"""Memory controls can be selected via environment variables."""
save_config(
EvoScientistConfig(
memory_profile_enabled=True,
memory_observations_enabled=True,
memory_observation_writer=MemoryObservationWriter.ALL,
memory_workers_enabled=True,
)
)
monkeypatch.setenv("EVOSCIENTIST_MEMORY_PROFILE_ENABLED", "false")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_OBSERVATIONS_ENABLED", "false")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_OBSERVATION_WRITER", "worker")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_WORKERS_ENABLED", "false")
config = get_effective_config()
assert config.memory_profile_enabled is False
assert config.memory_observations_enabled is False
assert config.memory_observation_writer == MemoryObservationWriter.WORKER
assert config.memory_workers_enabled is False
def test_sandbox_execute_timeout_default(self, temp_config_dir, clean_env):
"""Sandbox execute timeout defaults to 300 seconds."""
assert EvoScientistConfig().sandbox_execute_timeout == 300
assert get_effective_config().sandbox_execute_timeout == 300
def test_env_sandbox_execute_timeout_override(self, temp_config_dir, monkeypatch):
"""Sandbox execute timeout can be set via env var and coerces to int."""
monkeypatch.setenv("EVOSCIENTIST_SANDBOX_EXECUTE_TIMEOUT", "600")
config = get_effective_config()
assert config.sandbox_execute_timeout == 600
assert isinstance(config.sandbox_execute_timeout, int)
def test_sandbox_execute_timeout_invalid_falls_back(self):
"""Non-positive / non-int values fall back to the default (would
otherwise crash CustomSandboxBackend construction at startup)."""
assert (
EvoScientistConfig(sandbox_execute_timeout=0).sandbox_execute_timeout == 300
)
assert (
EvoScientistConfig(sandbox_execute_timeout=-5).sandbox_execute_timeout
== 300
)
assert (
EvoScientistConfig(sandbox_execute_timeout="abc").sandbox_execute_timeout
== 300
)
assert (
EvoScientistConfig(sandbox_execute_timeout=True).sandbox_execute_timeout
== 300
)
def test_set_sandbox_execute_timeout_rejects_invalid(
self, temp_config_dir, clean_env
):
"""set_config_value must reject (not silently persist) a non-positive timeout."""
save_config(EvoScientistConfig(sandbox_execute_timeout=120))
assert set_config_value("sandbox_execute_timeout", 0) is False
assert set_config_value("sandbox_execute_timeout", -5) is False
# bool is an int subclass; reject it before coercion turns True into 1.
assert set_config_value("sandbox_execute_timeout", True) is False
# The earlier valid value is untouched on disk.
assert get_config_value("sandbox_execute_timeout") == 120
# A valid value still goes through.
assert set_config_value("sandbox_execute_timeout", 600) is True
assert get_config_value("sandbox_execute_timeout") == 600
def test_observation_cache_max_files_default(self, temp_config_dir, clean_env):
"""Observation cache cap defaults to 2048."""
assert EvoScientistConfig().memory_observation_cache_max_files == 2048
assert get_effective_config().memory_observation_cache_max_files == 2048
def test_env_observation_cache_max_files_override(
self, temp_config_dir, monkeypatch
):
"""Observation cache cap can be set via env var."""
monkeypatch.setenv("EVOSCIENTIST_MAX_CACHED_FILES", "16")
config = get_effective_config()
assert config.memory_observation_cache_max_files == 16
def test_observation_cache_max_files_invalid_falls_back(self):
"""Non-positive / non-int values fall back to the default."""
assert (
EvoScientistConfig(
memory_observation_cache_max_files=0
).memory_observation_cache_max_files
== 2048
)
assert (
EvoScientistConfig(
memory_observation_cache_max_files=-1
).memory_observation_cache_max_files
== 2048
)
assert (
EvoScientistConfig(
memory_observation_cache_max_files=True
).memory_observation_cache_max_files
== 2048
)
def test_env_api_key_override(self, temp_config_dir, monkeypatch):
"""Test API keys from env override file."""
save_config(EvoScientistConfig(anthropic_api_key="file-key"))
monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key")
config = get_effective_config()
assert config.anthropic_api_key == "env-key"
def test_env_auth_mode_override(self, temp_config_dir, monkeypatch):
"""Test auth mode from env overrides file."""
save_config(EvoScientistConfig(anthropic_auth_mode="api_key"))
monkeypatch.setenv("EVOSCIENTIST_ANTHROPIC_AUTH_MODE", "oauth")
config = get_effective_config()
assert config.anthropic_auth_mode == "oauth"
def test_env_openai_auth_mode_override(self, temp_config_dir, monkeypatch):
"""Test openai_auth_mode from env overrides file."""
save_config(EvoScientistConfig(openai_auth_mode="api_key"))
monkeypatch.setenv("EVOSCIENTIST_OPENAI_AUTH_MODE", "oauth")
config = get_effective_config()
assert config.openai_auth_mode == "oauth"
def test_env_openrouter_anthropic_prompt_cache_opt_out(
self, temp_config_dir, monkeypatch
):
"""Test OpenRouter Anthropic prompt cache opt-out env overrides file."""
save_config(EvoScientistConfig(openrouter_anthropic_prompt_cache=True))
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "false")
config = get_effective_config()
assert config.openrouter_anthropic_prompt_cache is False
def test_env_openrouter_app_attribution_override(
self, temp_config_dir, monkeypatch
):
"""OpenRouter app-attribution env vars should override file config."""
save_config(EvoScientistConfig())
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_HTTP_REFERER", "https://acme.test")
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "Acme")
monkeypatch.setenv(
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES", "cli-agent,programming-app"
)
config = get_effective_config()
assert config.openrouter_http_referer == "https://acme.test"
assert config.openrouter_app_title == "Acme"
assert config.openrouter_app_categories == "cli-agent,programming-app"
def test_set_openrouter_anthropic_prompt_cache(self, temp_config_dir, clean_env):
"""Test OpenRouter Anthropic prompt cache can be set through config."""
save_config(EvoScientistConfig())
assert set_config_value("openrouter_anthropic_prompt_cache", "false") is True
assert get_config_value("openrouter_anthropic_prompt_cache") is False
# =============================================================================
# Test apply_config_to_env
# =============================================================================
class TestApplyConfigToEnv:
def test_applies_api_keys_when_not_set(self, clean_env):
"""Test that API keys are applied to env when not already set."""
config = EvoScientistConfig(
anthropic_api_key="config-ant-key",
openai_api_key="config-oai-key",
atlascloud_api_key="config-atlas-key",
tavily_api_key="config-tav-key",
semantic_scholar_api_key="config-s2-key",
)
apply_config_to_env(config)
assert os.environ.get("ANTHROPIC_API_KEY") == "config-ant-key"
assert os.environ.get("OPENAI_API_KEY") == "config-oai-key"
assert os.environ.get("ATLASCLOUD_API_KEY") == "config-atlas-key"
assert os.environ.get("TAVILY_API_KEY") == "config-tav-key"
assert os.environ.get("S2_API_KEY") == "config-s2-key"
def test_does_not_override_existing_env(self, monkeypatch):
"""Test that existing env vars are not overridden."""
monkeypatch.setenv("ANTHROPIC_API_KEY", "existing-key")
config = EvoScientistConfig(anthropic_api_key="config-key")
apply_config_to_env(config)
assert os.environ.get("ANTHROPIC_API_KEY") == "existing-key"
def test_empty_config_keys_not_applied(self, clean_env):
"""Test that empty config keys don't create env vars."""
config = EvoScientistConfig() # All empty
apply_config_to_env(config)
assert os.environ.get("ANTHROPIC_API_KEY") is None
assert os.environ.get("OPENAI_API_KEY") is None
assert os.environ.get("ATLASCLOUD_API_KEY") is None
assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") is None
def test_openrouter_anthropic_prompt_cache_opt_out_applied(
self, clean_env, monkeypatch
):
"""Test OpenRouter Anthropic prompt cache opt-out config is applied to env."""
monkeypatch.delenv(
"EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False
)
config = EvoScientistConfig(openrouter_anthropic_prompt_cache=False)
apply_config_to_env(config)
assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") == (
"false"
)
def test_openrouter_app_attribution_applied_to_env(self, clean_env, monkeypatch):
"""Config app-attribution values are exported to env for models.py."""
for env in (
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
):
monkeypatch.delenv(env, raising=False)
config = EvoScientistConfig(
openrouter_http_referer="https://acme.test",
openrouter_app_title="Acme",
openrouter_app_categories="cli-agent,programming-app",
)
apply_config_to_env(config)
assert os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER") == (
"https://acme.test"
)
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") == "Acme"
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES") == (
"cli-agent,programming-app"
)
def test_openrouter_app_attribution_env_not_overwritten(
self, clean_env, monkeypatch
):
"""apply_config_to_env must not clobber an already-set attribution env var."""
monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_APP_TITLE", "existing-title")
config = EvoScientistConfig(openrouter_app_title="config-title")
apply_config_to_env(config)
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") == "existing-title"
def test_openrouter_app_attribution_empty_config_not_applied(
self, clean_env, monkeypatch
):
"""Empty-string attribution config must not create env vars."""
for env in (
"EVOSCIENTIST_OPENROUTER_HTTP_REFERER",
"EVOSCIENTIST_OPENROUTER_APP_TITLE",
"EVOSCIENTIST_OPENROUTER_APP_CATEGORIES",
):
monkeypatch.delenv(env, raising=False)
config = EvoScientistConfig(
openrouter_http_referer="",
openrouter_app_title="",
openrouter_app_categories="",
)
apply_config_to_env(config)
assert os.environ.get("EVOSCIENTIST_OPENROUTER_HTTP_REFERER") is None
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_TITLE") is None
assert os.environ.get("EVOSCIENTIST_OPENROUTER_APP_CATEGORIES") is None
def test_dangerous_mode_round_trips_to_env(self, clean_env, monkeypatch):
"""dangerous_mode set via CLI override must survive a fresh re-read.
Regression: a --dangerous CLI flag is never persisted to file/env, so a
fresh get_effective_config() (warning banner, run_in_background, the
langgraph dev subprocess) would read it back as False while the backend
is already unconfined. apply_config_to_env round-trips it to env.
"""
from EvoScientist.config import get_effective_config
monkeypatch.delenv("EVOSCIENTIST_DANGEROUS_MODE", raising=False)
cfg = get_effective_config({"dangerous_mode": True})
assert cfg.dangerous_mode is True
apply_config_to_env(cfg)
assert os.environ.get("EVOSCIENTIST_DANGEROUS_MODE") == "true"
# The fresh, no-override read now agrees with the backend.
assert get_effective_config().dangerous_mode is True
def test_dangerous_mode_not_applied_when_off(self, clean_env, monkeypatch):
"""dangerous_mode=False must not write the env var."""
monkeypatch.delenv("EVOSCIENTIST_DANGEROUS_MODE", raising=False)
apply_config_to_env(EvoScientistConfig())
assert os.environ.get("EVOSCIENTIST_DANGEROUS_MODE") is None
def test_dangerous_mode_off_clears_stale_env(self, clean_env, monkeypatch):
"""Re-applying a non-dangerous config clears a previously-set env var.
Regression: the round-trip used to be set-only, so once dangerous mode
was applied in a process it could never be lowered — leaking unconfined
access into later non-dangerous reads.
"""
monkeypatch.delenv("EVOSCIENTIST_DANGEROUS_MODE", raising=False)
apply_config_to_env(EvoScientistConfig(dangerous_mode=True))
assert os.environ.get("EVOSCIENTIST_DANGEROUS_MODE") == "true"
apply_config_to_env(EvoScientistConfig()) # dangerous off
assert os.environ.get("EVOSCIENTIST_DANGEROUS_MODE") is None
def test_ollama_base_url_applied(self, clean_env, monkeypatch):
"""Test that ollama_base_url is applied to OLLAMA_BASE_URL env var."""
monkeypatch.delenv("OLLAMA_BASE_URL", raising=False)
config = EvoScientistConfig(ollama_base_url="http://localhost:11434")
apply_config_to_env(config)
assert os.environ.get("OLLAMA_BASE_URL") == "http://localhost:11434"
def test_ollama_base_url_not_overridden(self, monkeypatch):
"""Test that existing OLLAMA_BASE_URL env var is not overridden."""
monkeypatch.setenv("OLLAMA_BASE_URL", "http://existing:11434")
config = EvoScientistConfig(ollama_base_url="http://new:11434")
apply_config_to_env(config)
assert os.environ.get("OLLAMA_BASE_URL") == "http://existing:11434"
class TestAuxiliaryModelConfig:
"""auxiliary_model / auxiliary_provider config fields (plain str, optional)."""
def test_defaults_empty(self):
cfg = EvoScientistConfig()
assert cfg.auxiliary_model == ""
assert cfg.auxiliary_provider == ""
def test_save_and_load_round_trip(self, temp_config_dir, clean_env):
save_config(
EvoScientistConfig(
auxiliary_model="claude-haiku-4-5",
auxiliary_provider="anthropic",
)
)
loaded = load_config()
assert loaded.auxiliary_model == "claude-haiku-4-5"
assert loaded.auxiliary_provider == "anthropic"
def test_get_set_value(self, temp_config_dir, clean_env):
save_config(EvoScientistConfig())
assert set_config_value("auxiliary_model", "qwen3.6-flash") is True
assert set_config_value("auxiliary_provider", "dashscope") is True
assert get_config_value("auxiliary_model") == "qwen3.6-flash"
assert get_config_value("auxiliary_provider") == "dashscope"
def test_env_overrides_file(self, temp_config_dir, monkeypatch):
save_config(EvoScientistConfig(auxiliary_model="claude-haiku-4-5"))
monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_MODEL", "gpt-5.5")
monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_PROVIDER", "openai")
config = get_effective_config()
assert config.auxiliary_model == "gpt-5.5"
assert config.auxiliary_provider == "openai"
def test_scheduler_config_defaults_and_env(monkeypatch):
from EvoScientist.config.settings import EvoScientistConfig, get_effective_config
c = EvoScientistConfig()
assert c.enable_scheduler is True
assert c.scheduler_default_timezone == ""
assert c.memory_skill_synthesis_enabled is True
assert c.memory_skill_synthesis_mode == MemorySkillSynthesisMode.REVIEW
assert c.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.WEEKLY
assert c.memory_skill_synthesis_time == "03:00"
monkeypatch.setenv("EVOSCIENTIST_ENABLE_SCHEDULER", "false")
eff = get_effective_config({})
assert eff.enable_scheduler is False
monkeypatch.setenv("EVOSCIENTIST_SCHEDULER_DEFAULT_TIMEZONE", "America/New_York")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_ENABLED", "false")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", "auto")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE", "monthly")
monkeypatch.setenv("EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME", "4:30")
eff2 = get_effective_config({})
assert eff2.scheduler_default_timezone == "America/New_York"
assert eff2.memory_skill_synthesis_enabled is False
assert eff2.memory_skill_synthesis_mode == MemorySkillSynthesisMode.AUTO
assert eff2.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.MONTHLY
assert eff2.memory_skill_synthesis_time == "04:30"
# =============================================================================
# Dotenv isolation (issue #322)
# =============================================================================
class TestDotenvIsolation:
def test_env_file_not_leaked_into_process_env(self, tmp_path, monkeypatch):
"""A .env in cwd must not leak into os.environ during tests.
Without the suite-wide ``_isolate_dotenv`` fixture,
``get_effective_config`` loads the developer's real .env with
``override=True``; an empty-valued line like ``MINIMAX_BASE_URL=``
then poisons ``os.environ.get(key, default)`` lookups for every
test that runs afterwards in the same process.
"""
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
repro_dir = tmp_path / "repro"
repro_dir.mkdir()
(repro_dir / ".env").write_text("MINIMAX_BASE_URL=\n")
monkeypatch.chdir(repro_dir)
monkeypatch.delenv("MINIMAX_BASE_URL", raising=False)
get_effective_config()
assert "MINIMAX_BASE_URL" not in os.environ
def test_parent_env_wins_over_dotenv_for_mapped_keys(
self, temp_config_dir, tmp_path, monkeypatch
):
"""Parent-process env values for ``EVOSCIENTIST_*`` keys must not be
shadowed by a workspace ``.env``. ``EvoSci deploy --port X`` propagates
the resolved bind port via ``EVOSCIENTIST_LANGGRAPH_DEV_PORT`` on the
subprocess env; without the prefix-based merge, a workspace ``.env``
with the same key would clobber it and every self-loop async task
would target the wrong port.
"""
env_file = tmp_path / ".env"
env_file.write_text("EVOSCIENTIST_LANGGRAPH_DEV_PORT=9999\n")
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *args, **kwargs: str(env_file),
)
monkeypatch.setenv("EVOSCIENTIST_LANGGRAPH_DEV_PORT", "6606")
config = get_effective_config()
assert config.langgraph_dev_port == 6606
assert os.environ["EVOSCIENTIST_LANGGRAPH_DEV_PORT"] == "6606"
@pytest.mark.parametrize(
("field", "env_var", "value"),
[
("langgraph_dev_host", "EVOSCIENTIST_LANGGRAPH_DEV_HOST", "0.0.0.0"),
("webui_host", "EVOSCIENTIST_WEBUI_HOST", "0.0.0.0"),
],
)
def test_bind_hosts_are_env_overridable(
self, temp_config_dir, monkeypatch, field, env_var, value
):
"""``start_langgraph_dev`` propagates the resolved bind host to the
subprocess through ``EVOSCIENTIST_LANGGRAPH_DEV_HOST``, so both host
fields must be declared in ``_ENV_MAPPINGS`` or the subprocess would
fall back to whatever the config file says."""
monkeypatch.setenv(env_var, value)
config = get_effective_config()
assert getattr(config, field) == value
def test_dotenv_wins_over_shell_for_third_party_api_keys(
self, temp_config_dir, tmp_path, monkeypatch
):
"""Third-party API keys (``ANTHROPIC_API_KEY``, ``OPENAI_API_KEY``,
...) are conventionally set per-project via a workspace ``.env`` to
shadow whatever global key sits in the shell (e.g. ``~/.bashrc``).
The prefix-based ``.env`` merge must not disturb that: only
``EVOSCIENTIST_*`` keys are treated as parent-process-authoritative.
"""
env_file = tmp_path / ".env"
env_file.write_text("OPENAI_API_KEY=workspace-key\n")
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *args, **kwargs: str(env_file),
)
monkeypatch.setenv("OPENAI_API_KEY", "shell-key")
config = get_effective_config()
assert config.openai_api_key == "workspace-key"
assert os.environ["OPENAI_API_KEY"] == "workspace-key"
def test_snapshot_covers_evoscientist_keys_not_in_env_mappings(
self, temp_config_dir, tmp_path, monkeypatch
):
"""The snapshot must protect every ``EVOSCIENTIST_*`` key in the shell,
not just the ones declared in ``_ENV_MAPPINGS``. Concrete case: the
langgraph_dev manager sets ``EVOSCIENTIST_DEPLOY_MODE`` on the
subprocess env to dispatch MCP-load / async-subagent behavior, but
that key is read directly via ``os.environ.get(...)`` and never goes
through ``get_effective_config`` — so it never made it into
``_ENV_MAPPINGS``. Without the prefix-based snapshot, a workspace
``.env`` with ``EVOSCIENTIST_DEPLOY_MODE=stripped`` could clobber the
parent-injected ``full`` and silently disable async subagents.
"""
env_file = tmp_path / ".env"
env_file.write_text("EVOSCIENTIST_DEPLOY_MODE=stripped\n")
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *args, **kwargs: str(env_file),
)
monkeypatch.setenv("EVOSCIENTIST_DEPLOY_MODE", "full")
get_effective_config()
assert os.environ["EVOSCIENTIST_DEPLOY_MODE"] == "full"
def test_empty_shell_evoscientist_key_defers_to_dotenv(
self, temp_config_dir, tmp_path, monkeypatch
):
"""A set-but-empty shell export of an ``EVOSCIENTIST_*`` key is
treated as "unset" for merge purposes, so a workspace ``.env`` value
can still populate the config. Matches the ``if env_value:`` truthy
check in the ``_ENV_MAPPINGS`` loop; without this coupling, an empty
parent export would silently regress vs main by causing the key to
fall through to file/defaults instead of ``.env``.
"""
env_file = tmp_path / ".env"
env_file.write_text("EVOSCIENTIST_LANGGRAPH_DEV_PORT=6606\n")
monkeypatch.setattr(
"EvoScientist.config.settings.find_dotenv",
lambda *args, **kwargs: str(env_file),
)
monkeypatch.setenv("EVOSCIENTIST_LANGGRAPH_DEV_PORT", "")
config = get_effective_config()
assert config.langgraph_dev_port == 6606
assert os.environ["EVOSCIENTIST_LANGGRAPH_DEV_PORT"] == "6606"