"""End-to-end turn flow (T19): user POSTs prose, server parses, streams via SSE. Covers: - POST ``/chats//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