feat: add NORTH_STAR.md and MVP core loop (Telegram bot + LLM + proposal generator)

This commit is contained in:
copilot-swe-agent[bot]
2026-07-25 11:47:52 +00:00
committed by GitHub
parent c071fe6bf7
commit 6862c7d0b8
17 changed files with 1392 additions and 0 deletions
View File
+119
View File
@@ -0,0 +1,119 @@
"""Tests for steward.bot.telegram (handler logic)."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from telegram import Message, Update, User
from telegram.ext import CallbackContext
from steward.bot.telegram import (
_history,
_is_allowed,
clear_handler,
message_handler,
start_handler,
)
from steward.config import Settings
from steward.llm.client import LLMClient
def _make_settings(**kwargs) -> Settings:
defaults = dict(
telegram_bot_token="test-token",
openai_api_key="test-key",
)
defaults.update(kwargs)
return Settings(**defaults)
def _make_update(user_id: int = 12345, text: str = "hello") -> Update:
user = MagicMock(spec=User)
user.id = user_id
message = MagicMock(spec=Message)
message.text = text
message.reply_text = AsyncMock()
update = MagicMock(spec=Update)
update.effective_user = user
update.message = message
return update
def _make_context(settings: Settings, llm: LLMClient | None = None) -> CallbackContext: # type: ignore[type-arg]
ctx = MagicMock(spec=CallbackContext)
ctx.bot_data = {"settings": settings, "llm": llm}
return ctx
class TestIsAllowed:
def test_no_allowlist_allows_everyone(self):
settings = _make_settings(telegram_allowed_user_ids=[])
assert _is_allowed(999, settings) is True
def test_allowlist_accepts_known_user(self):
settings = _make_settings(telegram_allowed_user_ids=[1, 2, 3])
assert _is_allowed(2, settings) is True
def test_allowlist_rejects_unknown_user(self):
settings = _make_settings(telegram_allowed_user_ids=[1, 2, 3])
assert _is_allowed(999, settings) is False
@pytest.mark.asyncio
async def test_start_handler_replies(monkeypatch):
settings = _make_settings()
update = _make_update()
ctx = _make_context(settings)
await start_handler(update, ctx)
update.message.reply_text.assert_awaited_once()
text = update.message.reply_text.call_args.args[0]
assert "Steward" in text
@pytest.mark.asyncio
async def test_start_handler_ignores_disallowed_user():
settings = _make_settings(telegram_allowed_user_ids=[9999])
update = _make_update(user_id=1111)
ctx = _make_context(settings)
await start_handler(update, ctx)
update.message.reply_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_clear_handler_clears_history():
settings = _make_settings()
user_id = 42
_history[user_id] = [{"role": "user", "content": "old msg"}]
update = _make_update(user_id=user_id)
ctx = _make_context(settings)
await clear_handler(update, ctx)
assert _history[user_id] == []
update.message.reply_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_message_handler_calls_llm_and_replies():
settings = _make_settings()
mock_llm = MagicMock(spec=LLMClient)
mock_llm.chat = AsyncMock(return_value="LLM response")
update = _make_update(user_id=77, text="What is the weather?")
ctx = _make_context(settings, llm=mock_llm)
_history[77].clear()
await message_handler(update, ctx)
mock_llm.chat.assert_awaited_once()
update.message.reply_text.assert_awaited_once()
# History should now contain the user/assistant turn
assert len(_history[77]) == 2
assert _history[77][0]["role"] == "user"
assert _history[77][1]["role"] == "assistant"
+92
View File
@@ -0,0 +1,92 @@
"""Tests for steward.llm.client."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from steward.config import Settings
from steward.llm.client import LLMClient
def _make_settings(**kwargs) -> Settings:
defaults = dict(
telegram_bot_token="test-token",
openai_api_key="test-key",
openai_model="gpt-4o",
openai_system_prompt="You are Steward.",
)
defaults.update(kwargs)
return Settings(**defaults)
@pytest.fixture
def settings():
return _make_settings()
@pytest.fixture
def llm_client(settings):
return LLMClient(settings)
@pytest.mark.asyncio
async def test_chat_sends_correct_messages(llm_client):
"""chat() should prepend the system prompt and append the user message."""
mock_response = MagicMock()
mock_response.choices[0].message.content = "Hello from the LLM!"
with patch.object(
llm_client._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_create:
result = await llm_client.chat("Hi there")
assert result == "Hello from the LLM!"
call_kwargs = mock_create.call_args.kwargs
messages = call_kwargs["messages"]
assert messages[0]["role"] == "system"
assert messages[-1]["role"] == "user"
assert messages[-1]["content"] == "Hi there"
@pytest.mark.asyncio
async def test_chat_with_history(llm_client):
"""chat() should include history between system and user messages."""
history = [
{"role": "user", "content": "previous question"},
{"role": "assistant", "content": "previous answer"},
]
mock_response = MagicMock()
mock_response.choices[0].message.content = "reply"
with patch.object(
llm_client._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_create:
await llm_client.chat("new question", history=history)
messages = mock_create.call_args.kwargs["messages"]
roles = [m["role"] for m in messages]
assert roles == ["system", "user", "assistant", "user"]
@pytest.mark.asyncio
async def test_chat_custom_system_prompt(llm_client):
"""chat() should use a custom system prompt when provided."""
mock_response = MagicMock()
mock_response.choices[0].message.content = "ok"
with patch.object(
llm_client._client.chat.completions,
"create",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_create:
await llm_client.chat("msg", system_prompt="Custom prompt")
messages = mock_create.call_args.kwargs["messages"]
assert messages[0]["content"] == "Custom prompt"
+106
View File
@@ -0,0 +1,106 @@
"""Tests for steward.proposals.generator."""
from datetime import UTC
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
import respx
from steward.config import Settings
from steward.llm.client import LLMClient
from steward.proposals.generator import Proposal, ProposalGenerator
def _make_settings(**kwargs) -> Settings:
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)
@pytest.fixture
def settings():
return _make_settings()
@pytest.fixture
def mock_llm():
llm = MagicMock(spec=LLMClient)
llm.chat = AsyncMock(
return_value="**Summary:** Everything is healthy.\n**Why:** No issues detected."
)
return llm
@pytest.mark.asyncio
async def test_run_generates_proposal(settings, mock_llm):
"""run() should fetch the URL and return a Proposal."""
with respx.mock:
respx.get("https://example.com/api/status").mock(
return_value=httpx.Response(200, text='{"status": "ok"}')
)
generator = ProposalGenerator(settings, mock_llm)
proposal = await generator.run()
assert proposal is not None
assert isinstance(proposal, Proposal)
assert proposal.source_url == "https://example.com/api/status"
assert proposal.raw_data == '{"status": "ok"}'
mock_llm.chat.assert_awaited_once()
@pytest.mark.asyncio
async def test_run_no_url_returns_none(mock_llm):
"""run() should return None when analysis_target_url is not configured."""
settings = _make_settings(analysis_target_url="")
generator = ProposalGenerator(settings, mock_llm)
result = await generator.run()
assert result is None
mock_llm.chat.assert_not_called()
@pytest.mark.asyncio
async def test_run_http_error_returns_none(settings, mock_llm):
"""run() should return None when the HTTP request fails."""
with respx.mock:
respx.get("https://example.com/api/status").mock(
return_value=httpx.Response(500, text="Internal Server Error")
)
generator = ProposalGenerator(settings, mock_llm)
proposal = await generator.run()
assert proposal is None
mock_llm.chat.assert_not_called()
@pytest.mark.asyncio
async def test_fetch_sends_bearer_token(settings, mock_llm):
"""_fetch() should include ****** when api_key is configured."""
with respx.mock:
route = respx.get("https://example.com/api/status").mock(
return_value=httpx.Response(200, text="data")
)
generator = ProposalGenerator(settings, mock_llm)
await generator._fetch("https://example.com/api/status")
request = route.calls.last.request
assert request.headers["Authorization"].startswith("Bearer ")
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.",
source_url="https://example.com",
generated_at=datetime(2026, 1, 1, 8, 0, 0, tzinfo=UTC),
)
text = p.format_for_telegram()
assert "https://example.com" in text
assert "All good." in text
assert "2026-01-01" in text