import json from fastapi.testclient import TestClient from duck_core.api import create_app from duck_core.model_client import ModelResponse def test_conversations_api_stores_different_workspaces(tmp_path, monkeypatch): monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3")) client = TestClient(create_app()) first = client.post( "/v1/conversations", json={"title": "Project A", "workspace": "/tmp/project-a"}, ).json() second = client.post( "/v1/conversations", json={"title": "Project B", "workspace": "/tmp/project-b"}, ).json() listed = client.get("/v1/conversations").json() assert first["conversation_id"] != second["conversation_id"] assert {item["workspace"] for item in listed} >= {"/tmp/project-a", "/tmp/project-b"} def test_chat_api_persists_messages_in_conversation(tmp_path, monkeypatch): monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3")) async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None): if role == "action": return ModelResponse( role=role, model="local-main", content=json.dumps( { "kind": "action_directive", "intent": "answer directly", "risk_level": "none", "actions": [], } ), reasoning_content=None, raw={}, latency_ms=1.0, ) return ModelResponse( role=role, model="local-main", content="Первый ответ сохранен.", reasoning_content="short reasoning", raw={}, latency_ms=1.0, ) monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat) client = TestClient(create_app()) conversation = client.post( "/v1/conversations", json={"title": "Saved chat", "workspace": str(tmp_path)}, ).json() response = client.post( "/v1/chat", json={ "conversation_id": conversation["conversation_id"], "message": "Запомни это сообщение", "debug": True, }, ).json() loaded = client.get(f"/v1/conversations/{conversation['conversation_id']}").json() assert response["conversation_id"] == conversation["conversation_id"] assert loaded["workspace"] == str(tmp_path) assert [message["role"] for message in loaded["messages"]] == ["user", "assistant"] assert loaded["messages"][0]["content"] == "Запомни это сообщение" assert loaded["messages"][1]["content"] == "Первый ответ сохранен." assert loaded["messages"][1]["reasoning_content"] == "short reasoning" def test_conversation_history_is_sent_to_model(tmp_path, monkeypatch): monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3")) seen_thinker_messages = [] async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None): if role == "action": return ModelResponse( role=role, model="local-main", content=json.dumps( { "kind": "action_directive", "intent": "answer directly", "risk_level": "none", "actions": [], } ), reasoning_content=None, raw={}, latency_ms=1.0, ) seen_thinker_messages.append(messages) return ModelResponse( role=role, model="local-main", content=f"answer {len(seen_thinker_messages)}", reasoning_content=None, raw={}, latency_ms=1.0, ) monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat) client = TestClient(create_app()) conversation = client.post( "/v1/conversations", json={"title": "History", "workspace": str(tmp_path)}, ).json() client.post( "/v1/chat", json={"conversation_id": conversation["conversation_id"], "message": "first"}, ) client.post( "/v1/chat", json={"conversation_id": conversation["conversation_id"], "message": "second"}, ) # Filter out memory_policy and reflection calls — they use critic role # with different message patterns thinker_calls = [msgs for msgs in seen_thinker_messages if any( msg.get("role") == "user" and not msg.get("content", "").startswith("Task ID:") and not msg.get("content", "").startswith("Reflect on this DuckLM task") for msg in msgs )] second_call_content = [message["content"] for message in thinker_calls[-1]] assert second_call_content == ["first", "answer 1", "second"]