205 lines
6.8 KiB
Python
205 lines
6.8 KiB
Python
"""End-to-end turn flow (T19): user POSTs prose, server parses, streams via SSE.
|
|
|
|
Covers:
|
|
- POST ``/chats/<id>/turns`` returns 404 when the chat doesn't exist.
|
|
- A successful POST appends both a ``user_turn`` and an ``assistant_turn``
|
|
event in chronological order. The assistant payload carries the full
|
|
streamed text and ``truncated=False``.
|
|
- After a turn lands, the chat detail GET renders the user prose and the
|
|
assistant text from the event log.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from chat.app import app
|
|
from chat.db.connection import open_db
|
|
from chat.eventlog.log import append_event
|
|
from chat.eventlog.projector import project
|
|
from chat.llm.mock import MockLLMClient
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
cfg = tmp_path / "config.toml"
|
|
cfg.write_text('featherless_api_key = "test"\n')
|
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
|
db = tmp_path / "test.db"
|
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
|
|
|
canned_parse = json.dumps(
|
|
{"segments": [{"kind": "dialogue", "text": "hello"}]}
|
|
)
|
|
canned_response = "Hi there."
|
|
# Two state-update classifier calls fire after the assistant_turn
|
|
# (one per directed edge: bot->you, you->bot). We feed them benign
|
|
# zero-delta JSON so the existing assertions about ``user_turn`` /
|
|
# ``assistant_turn`` are unaffected.
|
|
canned_state_update = json.dumps(
|
|
{"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []}
|
|
)
|
|
# T26 scene-close detection runs after the state-update pass. These
|
|
# tests don't seed an active scene so the classifier is short-circuited
|
|
# in turns.py — but the canned slot is harmless to leave in place,
|
|
# and adding it documents the order even when the call doesn't fire.
|
|
canned_scene_close = json.dumps(
|
|
{"should_close": False, "reason": "no signal"}
|
|
)
|
|
|
|
# Import here so env vars are visible to the dependency lookup.
|
|
from chat.web.kickoff import get_llm_client
|
|
|
|
mock = MockLLMClient(
|
|
canned=[
|
|
canned_parse,
|
|
canned_response,
|
|
canned_state_update,
|
|
canned_state_update,
|
|
canned_scene_close,
|
|
]
|
|
)
|
|
app.dependency_overrides[get_llm_client] = lambda: mock
|
|
|
|
with TestClient(app) as c:
|
|
# Disable the lifespan-managed background worker — it would
|
|
# otherwise try to score significance through Featherless with
|
|
# a fake test API key. Worker behavior is exercised directly in
|
|
# tests/test_significance.py with a mock LLM factory.
|
|
app.state.background_worker.enabled = False
|
|
c.mock_llm = mock # type: ignore[attr-defined]
|
|
yield c
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
def _seed(db_path: Path) -> None:
|
|
"""Author a bot, create a chat, and seed enough state for prompt assembly."""
|
|
with open_db(db_path) as conn:
|
|
append_event(
|
|
conn,
|
|
kind="bot_authored",
|
|
payload={
|
|
"id": "bot_a",
|
|
"name": "BotA",
|
|
"persona": "thoughtful, observant",
|
|
"voice_samples": [],
|
|
"traits": [],
|
|
"backstory": "",
|
|
"initial_relationship_to_you": "",
|
|
"kickoff_prose": "...",
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="chat_created",
|
|
payload={
|
|
"id": "chat_bot_a",
|
|
"host_bot_id": "bot_a",
|
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
|
"narrative_anchor": "Day 1",
|
|
"weather": "",
|
|
},
|
|
)
|
|
# Seed an edge so the prompt assembler has something to render.
|
|
append_event(
|
|
conn,
|
|
kind="edge_update",
|
|
payload={
|
|
"source_id": "bot_a",
|
|
"target_id": "you",
|
|
"chat_id": "chat_bot_a",
|
|
"knowledge_facts": ["coworker"],
|
|
},
|
|
)
|
|
# Activity for both speakers — required by the prompt assembler.
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": "you",
|
|
"posture": "sitting",
|
|
"action": {
|
|
"verb": "talking",
|
|
"interruptible": True,
|
|
"required_attention": "low",
|
|
"expected_duration": "ongoing",
|
|
},
|
|
"attention": "",
|
|
"holding": [],
|
|
"status": {},
|
|
},
|
|
)
|
|
append_event(
|
|
conn,
|
|
kind="activity_change",
|
|
payload={
|
|
"entity_id": "bot_a",
|
|
"posture": "sitting",
|
|
"action": {
|
|
"verb": "listening",
|
|
"interruptible": True,
|
|
"required_attention": "low",
|
|
"expected_duration": "ongoing",
|
|
},
|
|
"attention": "",
|
|
"holding": [],
|
|
"status": {},
|
|
},
|
|
)
|
|
project(conn)
|
|
|
|
|
|
def test_post_turn_404_when_chat_missing(client):
|
|
response = client.post("/chats/no_such/turns", data={"prose": "hello"})
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_post_turn_appends_user_and_assistant_events(client, tmp_path):
|
|
_seed(tmp_path / "test.db")
|
|
response = client.post(
|
|
"/chats/chat_bot_a/turns", data={"prose": "hello"}
|
|
)
|
|
assert response.status_code == 204
|
|
|
|
with open_db(tmp_path / "test.db") as conn:
|
|
cur = conn.execute(
|
|
"SELECT kind, payload_json FROM event_log "
|
|
"WHERE kind IN ('user_turn', 'assistant_turn') ORDER BY id"
|
|
)
|
|
rows = cur.fetchall()
|
|
assert len(rows) == 2
|
|
assert rows[0][0] == "user_turn"
|
|
assert rows[1][0] == "assistant_turn"
|
|
|
|
user_payload = json.loads(rows[0][1])
|
|
assert user_payload["chat_id"] == "chat_bot_a"
|
|
assert user_payload["prose"] == "hello"
|
|
# Segments come from the canned classifier output.
|
|
assert any(
|
|
s.get("kind") == "dialogue" and s.get("text") == "hello"
|
|
for s in user_payload["segments"]
|
|
)
|
|
|
|
assistant_payload = json.loads(rows[1][1])
|
|
assert assistant_payload["chat_id"] == "chat_bot_a"
|
|
assert assistant_payload["speaker_id"] == "bot_a"
|
|
assert assistant_payload["text"] == "Hi there."
|
|
assert assistant_payload["truncated"] is False
|
|
|
|
|
|
def test_get_chat_renders_existing_turns(client, tmp_path):
|
|
_seed(tmp_path / "test.db")
|
|
post = client.post("/chats/chat_bot_a/turns", data={"prose": "hello"})
|
|
assert post.status_code == 204
|
|
|
|
response = client.get("/chats/chat_bot_a")
|
|
assert response.status_code == 200
|
|
body = response.text
|
|
assert "hello" in body
|
|
assert "Hi there." in body
|