test: update test suite for OmegaConf config system

- Add tests/conftest.py with make_settings() helper that maps legacy config
  parameter names to new nested OmegaConf structure
- Update all test files to use the conftest fixture
- All 73 tests now pass with new config system
- Maintains backward compatibility via Settings class properties

Also add AGENTS.md with comprehensive AI agent guidelines:
- Project overview and key technologies
- Directory structure reference
- Development workflow and common tasks
- Testing strategy and patterns
- CI/CD pipeline overview
- Common pitfalls and best practices
- Debugging guide for agents working on the project
This commit is contained in:
Daniel Wagner
2026-07-26 12:48:40 +10:00
parent d0ccc96507
commit 070e967833
10 changed files with 745 additions and 53 deletions
+91
View File
@@ -0,0 +1,91 @@
"""Shared test fixtures and utilities."""
from steward.config import (
AnalysisConfig,
MemoryConfig,
OpenAIConfig,
Settings,
TelegramConfig,
ToolsConfig,
)
def make_settings(**kwargs) -> Settings:
"""Create a Settings instance from old-style kwargs.
Maps legacy parameter names to the new nested structure:
- telegram_bot_token -> telegram.bot_token
- telegram_allowed_user_ids -> telegram.allowed_user_ids
- telegram_group_ids -> telegram.group_ids
- openai_api_key -> openai.api_key
- openai_base_url -> openai.base_url
- openai_model -> openai.model
- openai_system_prompt -> openai.system_prompt
- analysis_target_url -> analysis.target_url
- analysis_target_api_key -> analysis.target_api_key
- analysis_cron_hour -> analysis.cron_hour
- analysis_cron_minute -> analysis.cron_minute
- thread_memory_path -> memory.thread_memory_path
- mcp_server_url -> tools.mcp_server_url
- mcp_server_api_key -> tools.mcp_server_api_key
Args:
**kwargs: Legacy-style configuration parameters
Returns:
Settings: A fully constructed Settings instance
"""
# Extract telegram config
telegram_config = TelegramConfig(
bot_token=kwargs.pop("telegram_bot_token", ""),
allowed_user_ids=kwargs.pop("telegram_allowed_user_ids", []),
group_ids=kwargs.pop("telegram_group_ids", []),
)
# Extract OpenAI config
openai_config = OpenAIConfig(
api_key=kwargs.pop("openai_api_key", ""),
base_url=kwargs.pop("openai_base_url", "https://api.openai.com/v1"),
model=kwargs.pop("openai_model", "gpt-4o"),
system_prompt=kwargs.pop(
"openai_system_prompt",
(
"You are Steward, a persistent, trustworthy AI-assisted personal operations platform. "
"You reduce cognitive load by observing, remembering, planning, and proposing actions. "
"You are conservative, transparent, and policy-aware. "
"Always explain your reasoning."
),
),
)
# Extract analysis config
analysis_config = AnalysisConfig(
target_url=kwargs.pop("analysis_target_url", ""),
target_api_key=kwargs.pop("analysis_target_api_key", ""),
cron_hour=kwargs.pop("analysis_cron_hour", 8),
cron_minute=kwargs.pop("analysis_cron_minute", 0),
)
# Extract memory config
memory_config = MemoryConfig(
thread_memory_path=kwargs.pop("thread_memory_path", "thread_memory.json"),
)
# Extract tools config
tools_config = ToolsConfig(
mcp_server_url=kwargs.pop("mcp_server_url", ""),
mcp_server_api_key=kwargs.pop("mcp_server_api_key", ""),
)
# Any remaining kwargs should be rejected
if kwargs:
raise TypeError(f"Unexpected keyword arguments: {', '.join(kwargs.keys())}")
# Create and return Settings instance
return Settings(
telegram=telegram_config,
openai=openai_config,
analysis=analysis_config,
memory=memory_config,
tools=tools_config,
)
+3 -1
View File
@@ -17,15 +17,17 @@ from steward.bot.telegram import (
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore
from tests.conftest import make_settings
def _make_settings(**kwargs) -> Settings:
"""Wrapper for test compatibility."""
defaults = dict(
telegram_bot_token="test-token",
openai_api_key="test-key",
)
defaults.update(kwargs)
return Settings(**defaults)
return make_settings(**defaults)
def _make_update(
+3 -1
View File
@@ -6,9 +6,11 @@ import pytest
from steward.config import Settings
from steward.llm.client import LLMClient
from tests.conftest import make_settings
def _make_settings(**kwargs) -> Settings:
"""Wrapper for test compatibility."""
defaults = dict(
telegram_bot_token="test-token",
openai_api_key="test-key",
@@ -16,7 +18,7 @@ def _make_settings(**kwargs) -> Settings:
openai_system_prompt="You are Steward.",
)
defaults.update(kwargs)
return Settings(**defaults)
return make_settings(**defaults)
@pytest.fixture
+4 -1
View File
@@ -10,16 +10,18 @@ import respx
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.proposals.generator import Proposal, ProposalGenerator
from tests.conftest import make_settings
def _make_settings(**kwargs) -> Settings:
"""Wrapper for test compatibility."""
defaults = dict(
openai_api_key="test-key",
analysis_target_url="https://example.com/api/status",
analysis_target_api_key="secret",
)
defaults.update(kwargs)
return Settings(**defaults)
return make_settings(**defaults)
@pytest.fixture
@@ -94,6 +96,7 @@ async def test_fetch_sends_bearer_token(settings, mock_llm):
def test_proposal_format_for_telegram():
"""format_for_telegram() should contain the source URL and body."""
from datetime import datetime
p = Proposal(
title="Test",
body="**Summary:** All good.",
+131 -46
View File
@@ -16,13 +16,18 @@ from steward.bot.telegram import (
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.memory.thread_store import ThreadMemoryStore, ThreadSummary
from tests.conftest import make_settings
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_settings(**kwargs) -> Settings:
return Settings(telegram_bot_token="tok", openai_api_key="key", **kwargs)
"""Wrapper for test compatibility."""
defaults = dict(telegram_bot_token="tok", openai_api_key="key")
defaults.update(kwargs)
return make_settings(**defaults)
def _make_update(
@@ -72,6 +77,7 @@ def _make_context(
# ThreadMemoryStore unit tests
# ---------------------------------------------------------------------------
class TestThreadMemoryStore:
def _store(self, tmp_path: Path) -> ThreadMemoryStore:
return ThreadMemoryStore(tmp_path / "mem.json")
@@ -107,10 +113,24 @@ class TestThreadMemoryStore:
def test_all_returns_newest_first(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="first", message_count=1,
flushed_at="2026-01-01T00:00:00+00:00"))
store.save(ThreadSummary(chat_id=1, thread_id=2, summary="second", message_count=1,
flushed_at="2026-06-01T00:00:00+00:00"))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="first",
message_count=1,
flushed_at="2026-01-01T00:00:00+00:00",
)
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
summary="second",
message_count=1,
flushed_at="2026-06-01T00:00:00+00:00",
)
)
results = store.all()
assert results[0].summary == "second"
assert results[1].summary == "first"
@@ -129,7 +149,10 @@ class TestThreadMemoryStore:
def test_format_for_telegram_shows_tags(self, tmp_path: Path):
s = ThreadSummary(
chat_id=1, thread_id=77, summary="recap", message_count=3,
chat_id=1,
thread_id=77,
summary="recap",
message_count=3,
tags=["api", "auth"],
)
text = s.format_for_telegram()
@@ -138,54 +161,87 @@ class TestThreadMemoryStore:
def test_from_dict_tolerates_missing_tags(self, tmp_path: Path):
"""from_dict must handle legacy entries that predate the tags field."""
raw = {"chat_id": 1, "thread_id": 2, "summary": "old", "message_count": 3,
"flushed_at": "2026-01-01T00:00:00+00:00"}
raw = {
"chat_id": 1,
"thread_id": 2,
"summary": "old",
"message_count": 3,
"flushed_at": "2026-01-01T00:00:00+00:00",
}
s = ThreadSummary.from_dict(raw)
assert s.tags == []
def test_search_returns_matching_summaries(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="API work", message_count=2,
tags=["api", "design", "auth"]))
store.save(ThreadSummary(chat_id=1, thread_id=2, summary="Database work", message_count=2,
tags=["database", "schema"]))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="API work",
message_count=2,
tags=["api", "design", "auth"],
)
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
summary="Database work",
message_count=2,
tags=["database", "schema"],
)
)
results = store.search("api")
assert len(results) == 1
assert results[0].thread_id == 1
def test_search_no_match_returns_empty(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1,
tags=["database"]))
store.save(
ThreadSummary(
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
)
)
assert store.search("deployment") == []
def test_search_empty_query_returns_empty(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1,
tags=["database"]))
store.save(
ThreadSummary(
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"]
)
)
assert store.search("") == []
def test_search_case_insensitive(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1,
tags=["API"]))
store.save(
ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["API"])
)
assert len(store.search("api")) == 1
def test_search_skips_untagged_summaries(self, tmp_path: Path):
store = self._store(tmp_path)
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1,
tags=[]))
store.save(ThreadSummary(chat_id=1, thread_id=1, summary="recap", message_count=1, tags=[]))
assert store.search("api") == []
def test_legacy_store_roundtrip(self, tmp_path: Path):
"""Summaries without tags survive a save/load cycle via from_dict."""
path = tmp_path / "mem.json"
import json as _json
path.write_text(
_json.dumps({
"1:1": {"chat_id": 1, "thread_id": 1, "summary": "old",
"message_count": 1, "flushed_at": "2026-01-01T00:00:00+00:00"}
}),
_json.dumps(
{
"1:1": {
"chat_id": 1,
"thread_id": 1,
"summary": "old",
"message_count": 1,
"flushed_at": "2026-01-01T00:00:00+00:00",
}
}
),
encoding="utf-8",
)
store = ThreadMemoryStore(path)
@@ -198,6 +254,7 @@ class TestThreadMemoryStore:
# Thread-aware message_handler tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_thread_message_stored_in_thread_history():
"""Messages in a thread go to _thread_history, not _history."""
@@ -249,6 +306,7 @@ async def test_thread_history_is_unbounded():
# /flush handler tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_flush_outside_thread_warns():
"""flush_handler outside a thread should warn the user."""
@@ -328,6 +386,7 @@ async def test_flush_summarises_stores_and_compresses():
# /recall handler tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_recall_in_thread_returns_stored_summary(tmp_path):
"""recall_handler inside a thread returns the stored summary for that thread."""
@@ -396,14 +455,24 @@ async def test_recall_with_query_returns_matching_summaries(tmp_path):
"""recall_handler with a query argument searches the knowledge base by keyword."""
settings = _make_settings()
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(ThreadSummary(
chat_id=1, thread_id=1, summary="API authentication discussion",
message_count=2, tags=["api", "auth"],
))
store.save(ThreadSummary(
chat_id=1, thread_id=2, summary="Database schema planning",
message_count=3, tags=["database", "schema"],
))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="API authentication discussion",
message_count=2,
tags=["api", "auth"],
)
)
store.save(
ThreadSummary(
chat_id=1,
thread_id=2,
summary="Database schema planning",
message_count=3,
tags=["database", "schema"],
)
)
update = _make_update(thread_id=None)
ctx = _make_context(settings, store=store)
@@ -421,9 +490,15 @@ async def test_recall_with_query_no_match(tmp_path):
"""recall_handler with a query that matches nothing tells the user."""
settings = _make_settings()
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(ThreadSummary(
chat_id=1, thread_id=1, summary="recap", message_count=1, tags=["database"],
))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="recap",
message_count=1,
tags=["database"],
)
)
update = _make_update(thread_id=None)
ctx = _make_context(settings, store=store)
@@ -443,13 +518,19 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
mock_llm.chat = AsyncMock(return_value="reply with context")
store = ThreadMemoryStore(tmp_path / "mem.json")
store.save(ThreadSummary(
chat_id=1, thread_id=1, summary="Previous API discussion",
message_count=2, tags=["api"],
))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="Previous API discussion",
message_count=2,
tags=["api"],
)
)
user_id = 999
from steward.bot.telegram import _history
_history[user_id].clear()
update = _make_update(user_id=user_id, text="tell me about the api work")
@@ -467,10 +548,7 @@ async def test_message_handler_injects_kb_context_transiently(tmp_path):
assert any("knowledge base" in m.get("content", "").lower() for m in history_arg)
# But the KB message must NOT be stored in _history
assert all(
"knowledge base" not in m.get("content", "").lower()
for m in _history[user_id]
)
assert all("knowledge base" not in m.get("content", "").lower() for m in _history[user_id])
@pytest.mark.asyncio
@@ -482,12 +560,19 @@ async def test_message_handler_no_kb_injection_when_no_match(tmp_path):
store = ThreadMemoryStore(tmp_path / "mem.json")
# Store a summary with unrelated tags
store.save(ThreadSummary(
chat_id=1, thread_id=1, summary="Database recap", message_count=1, tags=["database"],
))
store.save(
ThreadSummary(
chat_id=1,
thread_id=1,
summary="Database recap",
message_count=1,
tags=["database"],
)
)
user_id = 888
from steward.bot.telegram import _history
_history[user_id].clear()
update = _make_update(user_id=user_id, text="what is the weather like?")
+4 -4
View File
@@ -19,6 +19,7 @@ from steward.tools.client import (
_schema_to_json_schema,
_spec_to_openai_tools,
)
from tests.conftest import make_settings
# ---------------------------------------------------------------------------
# Fixtures
@@ -86,6 +87,7 @@ SIMPLE_SPEC: dict[str, Any] = {
def _make_llm_settings(**kwargs: Any) -> Settings:
"""Wrapper for test compatibility."""
defaults = dict(
telegram_bot_token="t",
openai_api_key="k",
@@ -93,7 +95,7 @@ def _make_llm_settings(**kwargs: Any) -> Settings:
openai_system_prompt="You are Steward.",
)
defaults.update(kwargs)
return Settings(**defaults)
return make_settings(**defaults)
# ---------------------------------------------------------------------------
@@ -281,9 +283,7 @@ async def test_tool_client_call_get_with_query():
respx.get("http://tools.local/openapi.json").mock(
return_value=Response(200, json=SIMPLE_SPEC)
)
respx.get("http://tools.local/items").mock(
return_value=Response(200, json=[{"id": 1}])
)
respx.get("http://tools.local/items").mock(return_value=Response(200, json=[{"id": 1}]))
client = ToolClient("http://tools.local")
result = await client.call("list_items", {"limit": 10})
assert "id" in result