import json import httpx import openai import pytest from store_assistant.assistant import FALLBACK_BUSY, FALLBACK_EMPTY, FALLBACK_ERROR, StoreAssistant from store_assistant.memory import ConversationMemory from store_assistant.tools import ToolExecutor from .conftest import FakeClient, completion, tool_call def make_assistant(store, client, *, history=12, rounds=4): return StoreAssistant( client=client, model="test-model", executor=ToolExecutor(store), memory=ConversationMemory(history), system_prompt="SYSTEM", max_tool_rounds=rounds, max_output_tokens=256, ) async def test_plain_answer(store) -> None: client = FakeClient([completion("Salam! Kifach n3awnek?")]) reply = await make_assistant(store, client).reply(1, "salam") assert reply.text == "Salam! Kifach n3awnek?" assert reply.tools_used == [] request = client.requests[0] assert request["model"] == "test-model" assert request["tool_choice"] == "auto" assert request["messages"][0] == {"role": "system", "content": "SYSTEM"} async def test_tool_call_then_answer(store) -> None: client = FakeClient( [ completion(tool_calls=[tool_call("c1", "get_delivery_fee", {"wilaya": "وهران"})]), completion("التوصيل لوهران: 650 دج للدار، 400 دج للمكتب."), ] ) reply = await make_assistant(store, client).reply(1, "قداه التوصيل لوهران؟") assert reply.tools_used == ["get_delivery_fee"] assert "650" in reply.text second = client.requests[1]["messages"] assistant_turn, tool_turn = second[-2], second[-1] assert assistant_turn["tool_calls"][0]["function"]["name"] == "get_delivery_fee" assert tool_turn["role"] == "tool" and tool_turn["tool_call_id"] == "c1" assert json.loads(tool_turn["content"])["home_delivery_dzd"] == 650 async def test_parallel_tool_calls(store) -> None: client = FakeClient( [ completion( tool_calls=[ tool_call("a", "get_delivery_fee", {"wilaya": "Alger"}), tool_call("b", "search_products", {"query": "sneakers", "size": "42"}), ] ), completion("ok"), ] ) reply = await make_assistant(store, client).reply(1, "sneakers 42 + livraison Alger ?") assert reply.tools_used == ["get_delivery_fee", "search_products"] tool_ids = [m["tool_call_id"] for m in client.requests[1]["messages"] if m["role"] == "tool"] assert tool_ids == ["a", "b"] async def test_tool_rounds_are_capped(store) -> None: looping = completion(tool_calls=[tool_call("x", "get_store_policies", {})]) client = FakeClient([looping, looping, completion("final answer")]) reply = await make_assistant(store, client, rounds=2).reply(1, "policies?") assert reply.text == "final answer" assert [r["tool_choice"] for r in client.requests] == ["auto", "auto", "none"] async def test_ticket_ids_are_returned(store) -> None: client = FakeClient( [ completion(tool_calls=[tool_call("t", "request_human_agent", {"reason": "wants to cancel NS-10242"})]), completion("Un conseiller va vous répondre ici."), ] ) reply = await make_assistant(store, client).reply(9, "je veux annuler", username="amina") assert len(reply.ticket_ids) == 1 async def test_history_is_kept_and_capped(store) -> None: client = FakeClient([completion(f"answer {i}") for i in range(4)]) assistant = make_assistant(store, client, history=4) for i in range(4): await assistant.reply(5, f"question {i}") last = client.requests[-1]["messages"] # system + 4 remembered messages (2 exchanges) + new question assert [m["content"] for m in last] == ["SYSTEM", "question 1", "answer 1", "question 2", "answer 2", "question 3"] async def test_history_is_per_chat_and_resettable(store) -> None: client = FakeClient([completion("a"), completion("b"), completion("c")]) assistant = make_assistant(store, client) await assistant.reply(1, "first chat") await assistant.reply(2, "second chat") assert len(client.requests[1]["messages"]) == 2 assistant.reset(1) await assistant.reply(1, "again") assert len(client.requests[2]["messages"]) == 2 async def test_empty_answer_falls_back_and_is_not_remembered(store) -> None: client = FakeClient([completion(""), completion("ok")]) assistant = make_assistant(store, client) assert (await assistant.reply(1, "hmm")).text == FALLBACK_EMPTY await assistant.reply(1, "hello") assert len(client.requests[1]["messages"]) == 2 def _request() -> httpx.Request: return httpx.Request("POST", "https://api.example.test/v1/chat/completions") @pytest.mark.parametrize( ("error", "expected"), [ (openai.APIConnectionError(request=_request()), FALLBACK_ERROR), (openai.APITimeoutError(request=_request()), FALLBACK_ERROR), ], ) async def test_network_errors_return_fallback(store, error, expected) -> None: reply = await make_assistant(store, FakeClient([error])).reply(1, "salam") assert reply.failed is True assert reply.text == expected async def test_rate_limit_and_status_errors(store) -> None: rate_limited = openai.RateLimitError("slow down", response=httpx.Response(429, request=_request()), body=None) server_error = openai.InternalServerError("boom", response=httpx.Response(500, request=_request()), body=None) assistant = make_assistant(store, FakeClient([rate_limited, server_error])) assert (await assistant.reply(1, "a")).text == FALLBACK_BUSY assert (await assistant.reply(1, "b")).text == FALLBACK_ERROR async def test_repeated_tool_calls_run_once(store) -> None: client = FakeClient( [ completion( tool_calls=[ tool_call("a", "get_store_policies", {}), tool_call("b", "get_store_policies", {}), tool_call("c", "request_human_agent", {"reason": "exchange NS-10242"}), ] ), completion(tool_calls=[tool_call("d", "request_human_agent", {"reason": "exchange NS-10242"})]), completion("ok"), ] ) reply = await make_assistant(store, client).reply(1, "exchange please") assert reply.tools_used == ["get_store_policies", "request_human_agent"] assert len(reply.ticket_ids) == 1 # every call id still gets its own tool message tool_ids = [m["tool_call_id"] for m in client.requests[2]["messages"] if m["role"] == "tool"] assert tool_ids == ["a", "b", "c", "d"] async def test_language_instruction_is_sent_before_the_message_but_not_remembered(store) -> None: client = FakeClient([completion("Yes."), completion("ok")]) assistant = make_assistant(store, client) await assistant.reply(1, "Can you deliver to Oran?") first = client.requests[0]["messages"] assert first[-2]["role"] == "system" and "English" in first[-2]["content"] assert first[-1] == {"role": "user", "content": "Can you deliver to Oran?"} await assistant.reply(1, "4821") second = client.requests[1]["messages"] # history keeps only user/assistant text, and "4821" gets no language instruction assert [m["role"] for m in second] == ["system", "user", "assistant", "user"]