"""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