Files
discord_bot/tests/test_spec_responses.py
T

166 lines
7.8 KiB
Python

"""Unit coverage for the Responses API path (ENV-22..24, D-021)."""
import json
import unittest
from unittest.mock import AsyncMock, Mock, patch
from fjerkroa_bot.openai_responder import ENVELOPE_TEXT_FORMAT, OpenAIResponder
from .test_bdd_envelope import envelope
CONFIG = {
"openai-token": "t",
"model": "main-model",
"system": "s",
"history-limit": 5,
"use-responses-api": True,
"reasoning-effort": "medium",
}
def _msg_item():
part = Mock()
part.type = "output_text"
item = Mock()
item.type = "message"
item.content = [part]
item.model_dump = lambda: {"type": "message"}
return item
def _refusal_item():
part = Mock()
part.type = "refusal"
item = Mock()
item.type = "message"
item.content = [part]
return item
def _reasoning_item():
item = Mock()
item.type = "reasoning"
item.id = "rs_1"
item.summary = []
item.encrypted_content = "opaque-cot"
item.status = "completed" # response-only field; must NOT travel back
return item
def _call_item(name, args, call_id="call-1"):
item = Mock()
item.type = "function_call"
item.id = "fc_1"
item.name = name
item.arguments = json.dumps(args)
item.call_id = call_id
item.status = "completed"
return item
def _response(output, text=""):
result = Mock()
result.output = output
result.output_text = text
result.usage = Mock(prompt_tokens=None, completion_tokens=None, input_tokens=5, output_tokens=7)
return result
class TestResponsesPath(unittest.IsolatedAsyncioTestCase):
def _responder(self, **extra):
return OpenAIResponder(dict(CONFIG, **extra), "chat")
async def test_flag_routes_to_responses_with_reasoning(self):
"""ENV-22: flag on -> /v1/responses with envelope text.format, reasoning from config, stateless kwargs."""
responder = self._responder()
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
responses_mock.return_value = _response([_msg_item()], envelope(answer="hi", answer_needed=True))
answer, _ = await responder.chat([{"role": "user", "content": "hei"}], 10)
chat_mock.assert_not_awaited()
self.assertEqual(json.loads(answer["content"])["answer"], "hi")
kwargs = responses_mock.await_args.kwargs
self.assertEqual(kwargs["text"], ENVELOPE_TEXT_FORMAT)
self.assertEqual(kwargs["reasoning"], {"effort": "medium"})
self.assertFalse(kwargs["store"]) # ENV-23
self.assertIn("reasoning.encrypted_content", kwargs["include"])
async def test_flag_off_stays_on_chat_completions(self):
"""ENV-22: flag off (default) -> openai_responses never called."""
from .test_spec_structured import ok_result
responder = OpenAIResponder({k: v for k, v in CONFIG.items() if k != "use-responses-api"}, "chat")
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
with patch("fjerkroa_bot.openai_responder.openai_chat", new_callable=AsyncMock) as chat_mock:
chat_mock.return_value = ok_result()
await responder.chat([{"role": "user", "content": "hei"}], 10)
responses_mock.assert_not_awaited()
chat_mock.assert_awaited()
async def test_tools_flat_shape(self):
"""ENV-22: tools are sent in the flat Responses shape (name at top level)."""
responder = self._responder(**{"enable-news-tool": True})
responder.store = Mock() # store present -> get_news offered
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
responses_mock.return_value = _response([_msg_item()], envelope(answer="x", answer_needed=True))
await responder.chat([{"role": "user", "content": "hei"}], 10)
tools = responses_mock.await_args.kwargs["tools"]
self.assertTrue(all(tool["type"] == "function" and "name" in tool and "function" not in tool for tool in tools))
async def test_tool_loop_passes_reasoning_and_outputs_back(self):
"""ENV-23: function_call -> dispatch; next call carries reasoning item + function_call_output."""
responder = self._responder(**{"enable-news-tool": True})
responder.store = Mock()
responder._dispatch_tool = AsyncMock(return_value={"results": ["ok"]})
first = _response([_reasoning_item(), _call_item("get_news", {"topic": "x"}, "call-9")])
second = _response([_msg_item()], envelope(answer="done", answer_needed=True))
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
responses_mock.side_effect = [first, second]
answer, _ = await responder.chat([{"role": "user", "content": "news?"}], 10)
self.assertEqual(json.loads(answer["content"])["answer"], "done")
responder._dispatch_tool.assert_awaited_once()
followup_input = responses_mock.await_args_list[1].kwargs["input"]
reasoning = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "reasoning"]
self.assertEqual(len(reasoning), 1)
self.assertEqual(reasoning[0]["encrypted_content"], "opaque-cot")
self.assertNotIn("status", reasoning[0]) # response-only field stripped (live-400 regression)
calls_back = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "function_call"]
self.assertNotIn("status", calls_back[0])
outputs = [item for item in followup_input if isinstance(item, dict) and item.get("type") == "function_call_output"]
self.assertEqual(len(outputs), 1)
self.assertEqual(outputs[0]["call_id"], "call-9")
async def test_exhausted_rounds_force_toolless_answer(self):
"""ENV-23: after responses-tool-rounds rounds the final call drops tools."""
responder = self._responder(**{"enable-news-tool": True, "responses-tool-rounds": 1})
responder.store = Mock()
responder._dispatch_tool = AsyncMock(return_value={"results": []})
looping = _response([_call_item("get_news", {}, "c")])
final = _response([_msg_item()], envelope(answer="forced", answer_needed=True))
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
responses_mock.side_effect = [looping, final]
answer, _ = await responder.chat([{"role": "user", "content": "go"}], 10)
self.assertEqual(json.loads(answer["content"])["answer"], "forced")
self.assertNotIn("tools", responses_mock.await_args_list[1].kwargs)
async def test_refusal_is_failed_attempt(self):
"""ENV-24: a refusal part -> no answer."""
responder = self._responder()
with patch("fjerkroa_bot.openai_responder.openai_responses", new_callable=AsyncMock) as responses_mock:
responses_mock.return_value = _response([_refusal_item()])
answer, _ = await responder.chat([{"role": "user", "content": "hei"}], 10)
self.assertIsNone(answer)
async def test_vision_parts_mapped(self):
"""ENV-22: chat-format image parts become input_image items."""
items = OpenAIResponder._responses_input(
[
{"role": "user", "content": [{"type": "text", "text": "look"}, {"type": "image_url", "image_url": {"url": "data:x"}}]},
{"role": "tool", "content": "dropped"},
{"role": "assistant", "content": "{}"},
]
)
self.assertEqual(items[0]["content"][0], {"type": "input_text", "text": "look"})
self.assertEqual(items[0]["content"][1], {"type": "input_image", "image_url": "data:x"})
self.assertEqual(len(items), 2) # tool row dropped