fix defects D1-D12 batch 1, structured envelope, saf gates + ops kill-switches

This commit is contained in:
Oleksandr Kozachuk
2026-07-13 12:56:32 +02:00
parent f1578cbd99
commit 6e5abf3d2c
14 changed files with 770 additions and 214 deletions
+5 -29
View File
@@ -6,7 +6,7 @@ import toml
from discord import Message, TextChannel, User
from fjerkroa_bot import FjerkroaBot
from fjerkroa_bot.ai_responder import AIMessage, AIResponse, parse_maybe_json
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
class TestBotBase(unittest.IsolatedAsyncioTestCase):
@@ -26,9 +26,10 @@ class TestBotBase(unittest.IsolatedAsyncioTestCase):
"additional-responders": [],
}
self.history_data = []
with patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data), patch.object(
FjerkroaBot, "user", new_callable=PropertyMock
) as mock_user:
with (
patch.object(FjerkroaBot, "load_config", new=lambda s, c: self.config_data),
patch.object(FjerkroaBot, "user", new_callable=PropertyMock) as mock_user,
):
mock_user.return_value = MagicMock(spec=User)
mock_user.return_value.id = 12
self.bot = FjerkroaBot("config.toml")
@@ -56,31 +57,6 @@ class TestFunctionality(TestBotBase):
result = FjerkroaBot.load_config("config.toml")
self.assertEqual(result, self.config_data)
def test_json_strings(self) -> None:
json_string = '{"key1": "value1", "key2": "value2"}'
expected_output = "value1\nvalue2"
self.assertEqual(parse_maybe_json(json_string), expected_output)
non_json_string = "This is not a JSON string."
self.assertEqual(parse_maybe_json(non_json_string), non_json_string)
json_array = '["value1", "value2", "value3"]'
expected_output = "value1\nvalue2\nvalue3"
self.assertEqual(parse_maybe_json(json_array), expected_output)
json_string = '"value1"'
expected_output = "value1"
self.assertEqual(parse_maybe_json(json_string), expected_output)
json_struct = '{"This is a string."}'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = '["This is a string."]'
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "{This is a string.}"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
json_struct = "[This is a string.]"
expected_output = "This is a string."
self.assertEqual(parse_maybe_json(json_struct), expected_output)
async def test_message_lings(self) -> None:
request = AIMessage(
"Lala",
+3 -9
View File
@@ -26,15 +26,9 @@ class TestOpenAIResponderSimple(unittest.IsolatedAsyncioTestCase):
responder = OpenAIResponder(config)
self.assertIsNotNone(responder.client)
async def test_fix_no_fix_model(self):
"""Test fix when no fix-model is configured."""
config_no_fix = {"openai-key": "test", "model": "gpt-4"}
responder = OpenAIResponder(config_no_fix)
original_answer = '{"answer": "test"}'
result = await responder.fix(original_answer)
self.assertEqual(result, original_answer)
def test_no_repair_path_exists(self):
"""ENV-18: the repair path is gone — no fix() on the responder."""
self.assertFalse(hasattr(self.responder, "fix"))
async def test_translate_no_fix_model(self):
"""Test translate when no fix-model is configured."""
+132
View File
@@ -0,0 +1,132 @@
"""Unit coverage for the FDB-004 defect-fix requirements (ENV-12..17)."""
import threading
import unittest
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import openai
from discord import Message, TextChannel
from fjerkroa_bot.ai_responder import AIMessage, AIResponder
from fjerkroa_bot.openai_responder import OpenAIResponder, openai_chat
from .test_bdd_envelope import FakeModelResponder, envelope
from .test_main import TestBotBase
def make_entry(channel: str, text: str = "x"):
import json
return {"role": "user", "content": json.dumps({"message": text, "channel": channel})}
class TestSendBackoff(unittest.IsolatedAsyncioTestCase):
async def test_send_sleeps_between_retries(self):
"""ENV-12: failed attempts are separated by exponential-backoff sleeps (D1)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertGreaterEqual(sleep.await_count, 2)
for call in sleep.await_args_list:
self.assertGreater(call.args[0], 0)
class TestReactionClear(TestBotBase):
async def test_on_reaction_clear_discord_signature(self):
"""ENV-13: on_reaction_clear(message, reactions) memoizes the clearing (D7)."""
message = MagicMock(spec=Message)
message.content = "Some message text"
message.author.name = "alice"
message.channel = MagicMock(spec=TextChannel)
self.bot.airesponder.memoize = AsyncMock()
await self.bot.on_reaction_clear(message, [Mock()])
self.bot.airesponder.memoize.assert_awaited_once()
class TestUpdateMemory(unittest.TestCase):
def test_update_memory_uses_argument(self):
"""ENV-14: update_memory persists the passed value, not stale state (D12)."""
responder = AIResponder({"system": "s", "history-limit": 5}, "chat")
responder.update_memory("NEW MEMORY")
self.assertEqual(responder.memory, "NEW MEMORY")
class TestRetryModel(unittest.IsolatedAsyncioTestCase):
async def test_retry_model_used_after_rate_limit(self):
"""ENV-15: the attempt after a rate limit uses retry-model, then switches back (D2)."""
config = {
"openai-token": "test",
"model": "main-model",
"retry-model": "fallback-model",
"system": "s",
"history-limit": 5,
}
responder = OpenAIResponder(config, "chat")
rate_limit = openai.RateLimitError(
"rate limited", response=httpx.Response(429, request=httpx.Request("POST", "http://test")), body=None
)
def ok_result():
message = Mock(content=envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None)
return Mock(choices=[Mock(message=message)], usage="usage")
with (
patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock,
patch("asyncio.sleep", new_callable=AsyncMock),
):
chat_mock.side_effect = [rate_limit, ok_result(), ok_result()]
messages = [{"role": "user", "content": "hi"}]
first, _ = await responder.chat(list(messages), 10)
self.assertIsNone(first)
second, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(second)
third, _ = await responder.chat(list(messages), 10)
self.assertIsNotNone(third)
self.assertEqual(chat_mock.await_args_list[0].kwargs["model"], "main-model")
self.assertEqual(chat_mock.await_args_list[1].kwargs["model"], "fallback-model")
self.assertEqual(chat_mock.await_args_list[2].kwargs["model"], "main-model")
class TestNoDiskCache(unittest.IsolatedAsyncioTestCase):
async def test_chat_hits_client_every_time(self):
"""ENV-16: identical chat calls reach the client every time — no pickle cache (D3)."""
client = Mock()
client.chat.completions.create = AsyncMock(return_value="RESPONSE")
kwargs = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
await openai_chat(client, **kwargs)
await openai_chat(client, **kwargs)
self.assertEqual(client.chat.completions.create.await_count, 2)
class TestIgdbOffEventLoop(unittest.IsolatedAsyncioTestCase):
async def test_igdb_search_runs_in_worker_thread(self):
"""ENV-17: IGDB lookups run in a worker thread, results unchanged (D5)."""
responder = OpenAIResponder({"openai-token": "test", "model": "m", "system": "s", "history-limit": 5}, "chat")
responder.igdb = Mock()
calling_thread = {}
def capture_search(query, limit):
calling_thread["thread"] = threading.current_thread()
return [{"name": "Zelda"}]
responder.igdb.search_games = capture_search
result = await responder._execute_igdb_function("search_games", {"query": "zelda"})
self.assertEqual(result, {"games": [{"name": "Zelda"}]})
self.assertIsNot(calling_thread["thread"], threading.main_thread())
class TestShrinkTolerance(unittest.TestCase):
def test_shrink_tolerates_non_json_entries(self):
"""ENV-11: non-JSON history entries do not crash shrinking (D10 rewrite)."""
responder = AIResponder({"system": "s", "history-limit": 4}, "chat")
responder.history = [
{"role": "user", "content": "plain, not json"},
make_entry("a", "a0"),
make_entry("a", "a1"),
make_entry("a", "a2"),
make_entry("a", "a3"),
]
responder.shrink_history_by_one()
self.assertEqual(len(responder.history), 4)
+135
View File
@@ -0,0 +1,135 @@
"""Unit coverage for SPEC-006 operator controls (OPS-01..09)."""
import time
from unittest.mock import AsyncMock, MagicMock
from discord import Message, TextChannel, User
from fjerkroa_bot.ai_responder import AIMessage, AIResponse
from .test_main import TestBotBase
class OpsBase(TestBotBase):
def staff_msg(self, content: str) -> Message:
message = MagicMock(spec=Message)
message.content = content
message.author = MagicMock(spec=User)
message.author.bot = False
message.author.id = 999
message.channel = self.bot.staff_channel
return message
def public_msg(self, content: str) -> Message:
message = self.create_message(content)
message.content = content
return message
class TestStaffCommandAuth(OpsBase):
async def test_commands_ignored_outside_staff_channel(self):
"""OPS-01: !bot commands in a public channel do not flip flags."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.public_msg("!bot pause"))
self.assertTrue(self.bot.replies_enabled)
self.bot.handle_message_through_responder.assert_awaited_once()
async def test_commands_honored_in_staff_channel(self):
"""OPS-01: !bot commands in the staff channel are executed."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.replies_enabled)
class TestPauseResume(OpsBase):
async def test_pause_blocks_public_replies(self):
"""OPS-02: paused bot ignores public messages; resume restores replying."""
self.bot.handle_message_through_responder = AsyncMock()
await self.bot.on_message(self.staff_msg("!bot pause"))
await self.bot.on_message(self.public_msg("hello?"))
self.bot.handle_message_through_responder.assert_not_awaited()
await self.bot.on_message(self.staff_msg("!bot resume"))
self.assertTrue(self.bot.replies_enabled)
await self.bot.on_message(self.public_msg("hello again"))
self.bot.handle_message_through_responder.assert_awaited_once()
class TestImageKillSwitch(OpsBase):
async def test_images_off_strips_picture(self):
"""OPS-03: with images off the picture request is dropped, answer still sent."""
await self.bot.on_message(self.staff_msg("!bot images off"))
self.assertFalse(self.bot.images_enabled)
response = AIResponse("here is your cat", True, "chat", None, "a cat", False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
origin = MagicMock(spec=TextChannel)
await self.bot.respond(AIMessage("alice", "draw a cat", "chat"), origin)
sent = self.bot.send_answer_with_typing.await_args.args[0]
self.assertIsNone(sent.picture)
self.assertEqual(sent.answer, "here is your cat")
class TestQuietMode(OpsBase):
async def test_quiet_pauses_then_auto_resumes(self):
"""OPS-04: !bot quiet N silences replies for N minutes, then auto-resumes."""
await self.bot.on_message(self.staff_msg("!bot quiet 10"))
self.assertFalse(self.bot.replies_allowed())
self.bot.quiet_until = time.monotonic() - 1
self.assertTrue(self.bot.replies_allowed())
class TestStatus(OpsBase):
async def test_status_reports_flags(self):
"""OPS-05: !bot status answers in the staff channel with the flag state."""
await self.bot.on_message(self.staff_msg("!bot status"))
self.bot.staff_channel.send.assert_awaited()
text = self.bot.staff_channel.send.await_args.args[0]
self.assertIn("replies", text)
self.assertIn("images", text)
self.assertIn("tasks", text)
class TestKeywordAlerts(OpsBase):
async def test_keyword_forces_staff_alert(self):
"""OPS-06: staff-alert-keywords match forces an alert when the model set none."""
self.bot.config["staff-alert-keywords"] = ["(?i)hjelp|help"]
response = AIResponse("ok", True, "chat", None, None, False, False)
self.bot.send_message_with_typing = AsyncMock(return_value=response)
self.bot.send_answer_with_typing = AsyncMock()
await self.bot.respond(AIMessage("guest", "HELP at table 4", "chat"), MagicMock(spec=TextChannel))
self.bot.staff_channel.send.assert_awaited_once()
self.assertIn("guest", self.bot.staff_channel.send.await_args.args[0])
class TestAlertRateLimit(OpsBase):
async def test_alerts_rate_limited(self):
"""OPS-07: staff alerts above the hourly cap are logged, not sent."""
self.bot.config["staff-alert-max-per-hour"] = 2
await self.bot.send_staff_alert("one")
await self.bot.send_staff_alert("two")
await self.bot.send_staff_alert("three")
self.assertEqual(self.bot.staff_channel.send.await_count, 2)
class TestAlertFallback(OpsBase):
async def test_lost_alert_is_logged_not_raised(self):
"""OPS-08: no staff channel -> alert goes to the error log, no crash."""
self.bot.staff_channel = None
with self.assertLogs(level="ERROR") as logs:
await self.bot.send_staff_alert("nobody hears this")
self.assertTrue(any("nobody hears this" in line for line in logs.output))
class TestTasksKillSwitch(OpsBase):
async def test_tasks_off_blocks_bot_initiated(self):
"""OPS-09: !bot tasks off disables bot-initiated posting; on restores."""
self.assertTrue(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks off"))
self.assertFalse(self.bot.tasks_enabled)
self.assertFalse(self.bot.bot_initiated_allowed())
await self.bot.on_message(self.staff_msg("!bot tasks on"))
self.assertTrue(self.bot.bot_initiated_allowed())
async def test_pause_also_blocks_bot_initiated(self):
"""OPS-09: bot-initiated posts respect pause/quiet."""
await self.bot.on_message(self.staff_msg("!bot pause"))
self.assertFalse(self.bot.bot_initiated_allowed())
+64
View File
@@ -0,0 +1,64 @@
"""Unit coverage for SPEC-003 injection gates (SAF-01..03)."""
import tempfile
import unittest
from fjerkroa_bot.ai_responder import AIMessage, AIResponder, sanitize_external_text
from .test_main import TestBotBase
class TestChannelRoutingGate(TestBotBase):
async def test_default_allowed_set_from_config_names(self):
"""SAF-01: default allowed set = channels named in config; everything else refused."""
self.bot.config = dict(self.bot.config)
self.bot.config.update(
{"chat-channel": "general", "staff-channel": "staff", "welcome-channel": "welcome", "additional-responders": ["games"]}
)
self.assertTrue(self.bot.routing_allowed("general"))
self.assertTrue(self.bot.routing_allowed("staff"))
self.assertTrue(self.bot.routing_allowed("games"))
self.assertFalse(self.bot.routing_allowed("random-channel"))
self.assertFalse(self.bot.routing_allowed(None))
async def test_explicit_allowlist_wins(self):
"""SAF-01: configured allowed-channels replaces the default set."""
self.bot.config = dict(self.bot.config)
self.bot.config.update({"chat-channel": "general", "allowed-channels": ["announcements"]})
self.assertTrue(self.bot.routing_allowed("announcements"))
self.assertFalse(self.bot.routing_allowed("general"))
class TestAllowedMentions(TestBotBase):
async def test_outbound_pings_disabled(self):
"""SAF-02: the bot is constructed with allowed_mentions = none."""
mentions = self.bot.allowed_mentions
self.assertIsNotNone(mentions)
self.assertFalse(mentions.everyone)
self.assertFalse(mentions.users)
self.assertFalse(mentions.roles)
class TestSanitizeExternalText(unittest.TestCase):
def test_sanitizer_strips_and_caps(self):
"""SAF-03: control chars stripped, @everyone/@here neutralized, length capped."""
dirty = "hei\x00\x1b[31m @everyone @here " + "x" * 5000
clean = sanitize_external_text(dirty, max_len=4000)
self.assertNotIn("\x00", clean)
self.assertNotIn("\x1b", clean)
self.assertNotIn("@everyone", clean)
self.assertNotIn("@here", clean)
self.assertLessEqual(len(clean), 4000)
self.assertIn("hei", clean)
def test_news_content_sanitized_into_prompt(self):
"""SAF-03: news file content passes the sanitizer before prompt injection."""
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fd:
fd.write("Breaking: @everyone \x00 click here")
news_path = fd.name
config = {"system": "News: {news}", "history-limit": 5, "news": news_path}
responder = AIResponder(config, "chat")
system = responder.message(AIMessage("alice", "hei"))[0]["content"]
self.assertNotIn("@everyone", system)
self.assertNotIn("\x00", system)
self.assertIn("Breaking:", system)
+60
View File
@@ -0,0 +1,60 @@
"""Unit coverage for FDB-005 structured outputs (ENV-18, ENV-19)."""
import unittest
from unittest.mock import AsyncMock, Mock, patch
from fjerkroa_bot.ai_responder import AIMessage
from fjerkroa_bot.openai_responder import ENVELOPE_RESPONSE_FORMAT, OpenAIResponder
from .test_bdd_envelope import FakeModelResponder, envelope
RESPONDER_CONFIG = {"openai-token": "test", "model": "main-model", "system": "s", "history-limit": 5}
def ok_result(content=None):
message = Mock(content=content or envelope(answer="x", answer_needed=True), role="assistant", tool_calls=None, refusal=None)
return Mock(choices=[Mock(message=message)], usage="usage")
class TestNoRepairPath(unittest.IsolatedAsyncioTestCase):
async def test_malformed_output_is_failed_attempt(self):
"""ENV-18: malformed output is a failed attempt with backoff — no repair path (replaces ENV-06)."""
responder = FakeModelResponder({"system": "s", "history-limit": 5}, "chat")
responder.scripted = ["definitely not json", "definitely not json", "definitely not json"]
with patch("fjerkroa_bot.ai_responder.asyncio.sleep", new_callable=AsyncMock) as sleep:
with self.assertRaises(RuntimeError):
await responder.send(AIMessage("alice", "hei", "chat"))
self.assertEqual(responder.chat_calls, 3)
self.assertGreaterEqual(sleep.await_count, 2)
self.assertFalse(hasattr(responder, "fix"))
async def test_refusal_is_failed_attempt(self):
"""ENV-18: a model refusal yields no answer from chat()."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
refusal_message = Mock(content=None, refusal="I cannot help with that.", tool_calls=None, role="assistant")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = Mock(choices=[Mock(message=refusal_message)], usage="usage")
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNone(answer)
class TestEnvelopeSchema(unittest.IsolatedAsyncioTestCase):
def test_schema_shape_pinned(self):
"""ENV-19: strict envelope schema — exact fields, all required, closed object."""
json_schema = ENVELOPE_RESPONSE_FORMAT["json_schema"]
schema = json_schema["schema"]
expected = {"answer", "answer_needed", "channel", "staff", "picture", "picture_edit", "hack"}
self.assertEqual(set(schema["properties"]), expected)
self.assertEqual(set(schema["required"]), expected)
self.assertFalse(schema["additionalProperties"])
self.assertTrue(json_schema["strict"])
self.assertEqual(json_schema["name"], "envelope")
async def test_chat_carries_response_format(self):
"""ENV-19: chat calls pass the pinned response_format to the API."""
responder = OpenAIResponder(RESPONDER_CONFIG, "chat")
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = ok_result()
answer, _ = await responder.chat([{"role": "user", "content": "hi"}], 10)
self.assertIsNotNone(answer)
self.assertEqual(chat_mock.await_args.kwargs["response_format"], ENVELOPE_RESPONSE_FORMAT)