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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user