"""Tests for the Groq structured-output path and null-tolerant ChatResponse parsing. Groq can't combine response_format with tools, so the agent uses a `json` tool for structured output and salvages truncated tool calls. These tests lock in: - ChatResponse tolerates explicit `null` on non-nullable boolean fields (the models routinely emit them), instead of discarding the whole response. - The Groq truncation salvage recovers the lead/end_chat fields from a failed tool call. """ import json from app.models.schemas.chat import ChatResponse, EndChatReasonType from app.agents.chat_agent import ( _salvage_groq_json_error, _build_chat_response_from_capture, _lenient_json_load, ensure_nonempty_message, _EMPTY_TURN_FALLBACK, _salvage_groq_answer_text, recover_groq_no_capture, ) class _FakeMsg: def __init__(self, role, content): self.role = role self.content = content class _FakeResp: def __init__(self, messages, content=""): self.messages = messages self.content = content class TestGroqNoCaptureRecovery: """When Groq ends a turn without calling the `json` tool, its answer text survives as the last assistant message even though response.content is empty. Recover it rather than dropping the turn.""" def test_salvage_returns_last_assistant_text(self): resp = _FakeResp([ _FakeMsg("user", "explain the product"), _FakeMsg("assistant", ""), # the knowledge tool-call turn _FakeMsg("tool", "search results..."), _FakeMsg("assistant", "Charcoal, oat and black come in wide."), ]) assert _salvage_groq_answer_text(resp) == "Charcoal, oat and black come in wide." def test_salvage_skips_empty_and_non_assistant(self): resp = _FakeResp([ _FakeMsg("user", "hi"), _FakeMsg("assistant", " "), ]) assert _salvage_groq_answer_text(resp) is None def test_recover_uses_salvaged_text(self): resp = _FakeResp([_FakeMsg("assistant", "Here is the answer.")], content="") rc = recover_groq_no_capture(resp) assert rc.message == "Here is the answer." def test_recover_parses_json_written_as_text(self): # Model wrote the structured JSON as plain text instead of calling the tool. resp = _FakeResp([_FakeMsg("assistant", '{"message": "Hello there", "end_chat": true}')]) rc = recover_groq_no_capture(resp) assert rc.message == "Hello there" assert rc.end_chat is True def test_recover_no_text_yields_empty_for_fallback(self): # Nothing to recover → empty message; ensure_nonempty_message adds the fallback. resp = _FakeResp([_FakeMsg("assistant", "")], content="") rc = recover_groq_no_capture(resp) assert not (rc.message or "").strip() assert ensure_nonempty_message(rc).message == _EMPTY_TURN_FALLBACK class TestEnsureNonemptyMessage: """A model turn that yields no message and no action (e.g. Groq stopping after a tool call) must not be dropped — widget_chat only emits when the message is non-empty, so an empty turn would hang the typing indicator.""" def test_empty_no_action_gets_fallback(self): assert ensure_nonempty_message(ChatResponse(message="")).message == _EMPTY_TURN_FALLBACK def test_blank_whitespace_gets_fallback(self): assert ensure_nonempty_message(ChatResponse(message=" ")).message == _EMPTY_TURN_FALLBACK def test_real_message_untouched(self): assert ensure_nonempty_message(ChatResponse(message="Here you go")).message == "Here you go" def test_empty_with_action_left_alone(self): # The action's own handler owns the message; don't clobber it. for rc in ( ChatResponse(message="", transfer_to_human=True), ChatResponse(message="", end_chat=True), ChatResponse(message="", request_rating=True), ChatResponse(message="", create_ticket=True), ChatResponse(message="", request_lead_capture=True), ): assert ensure_nonempty_message(rc).message == "" def test_chat_response_tolerates_explicit_null_booleans(): """A dict with explicit null on non-nullable bools must still build, with the real flags (end_chat, request_lead_capture) preserved rather than discarded.""" data = { "message": "Thanks, we'll be in touch!", "end_chat": True, "end_chat_reason": "CUSTOMER_REQUEST", "request_lead_capture": True, "lead_email": "sam@example.com", "lead_consent": True, "transfer_to_human": None, # non-nullable bool -> must coerce to default "create_ticket": None, # non-nullable bool -> must coerce to default "request_contact": None, "transfer_reason": None, "lead_name": None, } cr = ChatResponse(**data) assert cr.end_chat is True assert cr.end_chat_reason == EndChatReasonType.CUSTOMER_REQUEST assert cr.request_lead_capture is True assert cr.lead_email == "sam@example.com" assert cr.lead_consent is True assert cr.transfer_to_human is False # null -> default assert cr.create_ticket is False # null -> default def test_build_chat_response_from_capture_drops_none(): cr = _build_chat_response_from_capture( {"message": "hi", "end_chat": True, "transfer_to_human": None, "create_ticket": None} ) assert cr.message == "hi" assert cr.end_chat is True assert cr.transfer_to_human is False def test_salvage_recovers_truncated_groq_tool_call(): """A Groq tool_use_failed error with a message truncated mid-string still yields the completed lead fields.""" failed_generation = ( '{"name": "json", "arguments": {' '"lead_name": "Arun", "lead_email": "yohan@gmail.com", "lead_phone": "7676767676", ' '"lead_consent": true, "request_lead_capture": true, ' '"message": "Thanks for confirming, Arun. Which modules are you most' # truncated ) body = json.dumps({"error": {"code": "tool_use_failed", "failed_generation": failed_generation}}) class _Exc(Exception): def __init__(self, msg): self.message = msg super().__init__(msg) salvaged = _salvage_groq_json_error(_Exc(body)) assert salvaged is not None cr = _build_chat_response_from_capture(salvaged) assert cr.request_lead_capture is True assert cr.lead_email == "yohan@gmail.com" assert cr.lead_name == "Arun" assert cr.lead_consent is True def test_salvage_returns_none_for_non_truncation_errors(): """Rate-limit/auth errors have no failed_generation -> salvage returns None so the caller re-raises them unchanged.""" class _Exc(Exception): def __init__(self, msg): self.message = msg super().__init__(msg) body = json.dumps({"error": {"message": "Rate limit reached", "type": "rate_limit_error"}}) assert _salvage_groq_json_error(_Exc(body)) is None def test_lenient_json_load_handles_complete_and_truncated(): assert _lenient_json_load('{"a": 1, "b": "x"}') == {"a": 1, "b": "x"} # truncated mid-string -> drops the incomplete trailing key, keeps the rest out = _lenient_json_load('{"a": "done", "b": "unterminated') assert out is not None and out.get("a") == "done"