ducklm/tests/smoke/test_conversations.py

138 lines
4.9 KiB
Python

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