diff --git a/startupintel/api/dependencies/__init__.py b/startupintel/api/dependencies/__init__.py
index fb615cd..3966f57 100644
--- a/startupintel/api/dependencies/__init__.py
+++ b/startupintel/api/dependencies/__init__.py
@@ -1,22 +1,43 @@
"""Shared FastAPI dependencies.
-This module intentionally stays DB-only: a single async session provider and
-``get_*_or_404`` helpers used by the CRUD routes. Auth dependencies live in
-``startupintel.api.dependencies.auth`` so the CRUD layer can be exercised without
-pulling in the auth stack.
+This module stays auth-free: a single async session provider, the
+``get_*_or_404`` CRUD helpers, and lightweight Redis/LLM/rate-limit providers.
+Auth dependencies live in ``startupintel.api.dependencies.auth`` so the CRUD
+layer can be exercised without pulling in the auth stack.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
-from typing import Annotated
+from datetime import UTC, datetime, timedelta
+from typing import TYPE_CHECKING, Annotated
from uuid import UUID
-from fastapi import Depends, HTTPException, status
+from fastapi import Depends, HTTPException, Request, status
from sqlalchemy.ext.asyncio import AsyncSession
from startupintel.db.models import Accelerator, Investor, Startup
from startupintel.db.postgres import get_session
+from startupintel.db.redis import get_redis
+from startupintel.llm.client import get_llm_client
+
+if TYPE_CHECKING:
+ from redis.asyncio import Redis
+
+ from startupintel.llm.client import BaseLLMClient
+
+__all__ = [
+ "DbDep",
+ "RateLimiter",
+ "get_accelerator_or_404",
+ "get_db",
+ "get_investor_or_404",
+ "get_llm",
+ "get_llm_client",
+ "get_redis_client",
+ "get_startup_or_404",
+ "rate_limit_dependency",
+]
async def get_db() -> AsyncIterator[AsyncSession]:
@@ -28,6 +49,16 @@ async def get_db() -> AsyncIterator[AsyncSession]:
DbDep = Annotated[AsyncSession, Depends(get_db)]
+async def get_redis_client() -> "Redis":
+ """Provide a Redis client for routes that need a cache/session store."""
+ return get_redis()
+
+
+async def get_llm() -> "BaseLLMClient":
+ """Provide the configured LLM client."""
+ return get_llm_client()
+
+
async def get_startup_or_404(db: DbDep, startup_id: UUID) -> Startup:
startup = await db.get(Startup, startup_id)
if startup is None:
@@ -56,3 +87,56 @@ async def get_accelerator_or_404(db: DbDep, accelerator_id: UUID) -> Accelerator
detail=f"Accelerator {accelerator_id} not found",
)
return accelerator
+
+
+# Simple in-process rate limiter. For distributed deployments, back this with
+# Redis; the in-memory window is sufficient for single-instance/dev use.
+_rate_limit_store: dict[str, list[datetime]] = {}
+
+
+class RateLimiter:
+ """Fixed-window-per-key in-memory rate limiter."""
+
+ def __init__(self, requests_per_minute: int = 60) -> None:
+ self.requests_per_minute = requests_per_minute
+ self.window = timedelta(minutes=1)
+
+ def check(self, key: str) -> tuple[bool, int, int]:
+ """Return ``(allowed, remaining, reset_in_seconds)`` for ``key``."""
+ now = datetime.now(UTC)
+ cutoff = now - self.window
+ recent = [ts for ts in _rate_limit_store.get(key, []) if ts > cutoff]
+
+ if len(recent) >= self.requests_per_minute:
+ reset_in = int((recent[0] + self.window - now).total_seconds())
+ _rate_limit_store[key] = recent
+ return False, 0, max(reset_in, 1)
+
+ recent.append(now)
+ _rate_limit_store[key] = recent
+ return True, self.requests_per_minute - len(recent), 60
+
+
+async def rate_limit_dependency(
+ request: Request,
+ requests_per_minute: int = 60,
+) -> None:
+ """FastAPI dependency enforcing a per-client-IP, per-path rate limit."""
+ client_ip = request.client.host if request.client else "unknown"
+ key = f"{client_ip}:{request.url.path}"
+
+ allowed, remaining, reset_in = RateLimiter(requests_per_minute).check(key)
+ if not allowed:
+ raise HTTPException(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ detail=f"Rate limit exceeded. Try again in {reset_in} seconds.",
+ headers={
+ "X-RateLimit-Limit": str(requests_per_minute),
+ "X-RateLimit-Remaining": "0",
+ "X-RateLimit-Reset": str(reset_in),
+ "Retry-After": str(reset_in),
+ },
+ )
+
+ request.state.rate_limit_remaining = remaining
+ request.state.rate_limit_limit = requests_per_minute
diff --git a/startupintel/api/main.py b/startupintel/api/main.py
index 10c444c..46fe851 100644
--- a/startupintel/api/main.py
+++ b/startupintel/api/main.py
@@ -3,6 +3,7 @@
from startupintel.api.routes import (
accelerator,
auth,
+ chat,
export,
feature_flags,
health,
@@ -30,6 +31,7 @@ def create_app() -> FastAPI:
app.include_router(export.router)
app.include_router(metrics.router)
app.include_router(feature_flags.router)
+ app.include_router(chat.router)
return app
diff --git a/startupintel/api/routes/chat.py b/startupintel/api/routes/chat.py
new file mode 100644
index 0000000..f53e98b
--- /dev/null
+++ b/startupintel/api/routes/chat.py
@@ -0,0 +1,491 @@
+"""Chat API routes for a conversational interface over the bots."""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+from collections.abc import AsyncGenerator
+from datetime import UTC, datetime, timedelta
+from uuid import uuid4
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel, Field
+from sqlalchemy import select
+
+from startupintel.api.dependencies import (
+ DbDep,
+ get_llm_client,
+ get_redis_client,
+ rate_limit_dependency,
+)
+from startupintel.db.models import Startup
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/chat", tags=["chat"])
+
+_MAX_MESSAGE_LENGTH = 4000
+_CONVERSATION_TTL_HOURS = 24
+_USE_REDIS = os.getenv("USE_REDIS_CONVERSATIONS", "false").lower() == "true"
+
+# In-memory fallback conversation store: id -> (context, last_seen).
+_conversations: dict[str, tuple["ConversationContext", datetime]] = {}
+
+
+def utc_now() -> datetime:
+ """Return the current UTC time."""
+ return datetime.now(UTC)
+
+
+def sanitize_input(text: str) -> str:
+ """Strip HTML/script vectors and normalize whitespace in user input."""
+ if not text:
+ return ""
+ text = text.replace("\x00", "")
+ text = re.sub(r"<[^>]+>", "", text)
+ text = re.sub(r"on\w+\s*=", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"javascript:", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"data:", "", text, flags=re.IGNORECASE)
+ text = re.sub(r"[\t\r\f\v]+", " ", text)
+ text = re.sub(r" {2,}", " ", text)
+ return text.strip()
+
+
+class ChatMessage(BaseModel):
+ """A single chat message."""
+
+ role: str = Field(..., pattern="^(user|assistant|system)$")
+ content: str
+ timestamp: datetime = Field(default_factory=utc_now)
+ metadata: dict = Field(default_factory=dict)
+
+
+class ChatRequest(BaseModel):
+ """Request body for a chat turn."""
+
+ message: str
+ conversation_id: str | None = None
+ context: dict = Field(default_factory=dict)
+ role: str = Field(
+ default="founder",
+ pattern="^(founder|engineer|product|investor|analyst)$",
+ )
+ stream: bool = True
+
+
+class ChatResponse(BaseModel):
+ """Response for a completed (non-streaming) chat turn."""
+
+ message: ChatMessage
+ conversation_id: str
+ suggested_actions: list[dict] = Field(default_factory=list)
+ related_insights: list[dict] = Field(default_factory=list)
+ bot_results: dict | None = None
+
+
+_INTENT_KEYWORDS: dict[str, list[str]] = {
+ "runway_analysis": ["runway", "stress", "financial", "funding", "cash", "burn"],
+ "obituary_analysis": ["obituary", "failure", "risk", "danger", "problem"],
+ "pmf_analysis": ["pmf", "product market fit", "traction", "growth", "adoption"],
+ "pivot_analysis": ["pivot", "change", "direction", "strategy", "shift"],
+ "acqui_analysis": ["acqui", "acquisition", "exit", "sell", "buyout"],
+ "investor_analysis": ["investor", "network", "vc", "funding round"],
+ "accelerator_analysis": ["accelerator", "incubator", "program", "yc"],
+ "term_analysis": ["term sheet", "terms", "valuation", "equity", "clause"],
+ "startup_search": ["find", "search", "startup", "company", "look up"],
+ "compare": ["compare", "versus", "vs", "better", "difference"],
+}
+
+_SYSTEM_PROMPTS: dict[str, str] = {
+ "founder": (
+ "You are a startup intelligence advisor helping founders understand "
+ "their company's health, risks, and opportunities. Provide actionable "
+ "business insights and strategic recommendations."
+ ),
+ "engineer": (
+ "You are a technical intelligence advisor helping engineering teams "
+ "understand architecture, technical debt, and technology decisions."
+ ),
+ "product": (
+ "You are a product intelligence advisor helping product teams "
+ "understand adoption, feature decisions, and product strategy."
+ ),
+ "investor": (
+ "You are an investment intelligence advisor helping investors evaluate "
+ "startups with financial analysis and market insights."
+ ),
+ "analyst": (
+ "You are a research analyst providing comprehensive, data-driven "
+ "startup intelligence across all dimensions."
+ ),
+}
+
+
+class ConversationContext:
+ """Holds the message history and detected state for a conversation."""
+
+ def __init__(self) -> None:
+ self.history: list[ChatMessage] = []
+ self.detected_intent: str | None = None
+ self.user_role: str = "founder"
+ self.entities: dict = {}
+
+ def add_message(self, message: ChatMessage) -> None:
+ self.history.append(message)
+
+ def get_recent_context(self, n: int = 5) -> list[ChatMessage]:
+ return self.history[-n:]
+
+ def detect_intent(self, message: str) -> str:
+ lowered = message.lower()
+ for intent, keywords in _INTENT_KEYWORDS.items():
+ if any(kw in lowered for kw in keywords):
+ return intent
+ return "general"
+
+
+def _serialize_context(context: ConversationContext) -> str:
+ return json.dumps(
+ {
+ "history": [msg.model_dump(mode="json") for msg in context.history],
+ "detected_intent": context.detected_intent,
+ "user_role": context.user_role,
+ "entities": context.entities,
+ "updated_at": utc_now().isoformat(),
+ }
+ )
+
+
+def _deserialize_context(data: str) -> ConversationContext:
+ parsed = json.loads(data)
+ context = ConversationContext()
+ context.history = [ChatMessage(**msg) for msg in parsed.get("history", [])]
+ context.detected_intent = parsed.get("detected_intent")
+ context.user_role = parsed.get("user_role") or "founder"
+ context.entities = parsed.get("entities", {})
+ return context
+
+
+async def get_or_create_conversation(
+ conversation_id: str | None,
+ redis=None,
+) -> tuple[str, ConversationContext]:
+ """Load an existing conversation or create a fresh one.
+
+ Uses Redis when ``USE_REDIS_CONVERSATIONS`` is enabled and a client is
+ available; otherwise falls back to the in-memory store.
+ """
+ if _USE_REDIS and redis is not None:
+ if conversation_id:
+ data = await redis.get(f"chat:{conversation_id}")
+ if data:
+ return conversation_id, _deserialize_context(data)
+ new_id = conversation_id or str(uuid4())
+ context = ConversationContext()
+ await redis.setex(
+ f"chat:{new_id}",
+ timedelta(hours=_CONVERSATION_TTL_HOURS),
+ _serialize_context(context),
+ )
+ return new_id, context
+
+ now = utc_now()
+ expired = [
+ key
+ for key, (_, seen) in _conversations.items()
+ if now - seen > timedelta(hours=_CONVERSATION_TTL_HOURS)
+ ]
+ for key in expired:
+ del _conversations[key]
+
+ if conversation_id and conversation_id in _conversations:
+ context, _ = _conversations[conversation_id]
+ _conversations[conversation_id] = (context, now)
+ return conversation_id, context
+
+ new_id = conversation_id or str(uuid4())
+ context = ConversationContext()
+ _conversations[new_id] = (context, now)
+ return new_id, context
+
+
+def format_bot_results(bot_results: dict | None) -> str:
+ if not bot_results:
+ return ""
+ return f"\nBot analysis results:\n{bot_results}\n"
+
+
+async def run_relevant_bot(intent: str, message: str, db: DbDep, llm) -> dict | None:
+ """Run a bot for the detected intent.
+
+ Placeholder: real startup-id extraction and bot orchestration land with the
+ bot-run route slice. Returns ``None`` until then.
+ """
+ return None
+
+
+async def generate_intelligent_response(
+ message: str,
+ intent: str,
+ context: ConversationContext,
+ llm,
+ db: DbDep,
+ role: str,
+ system_prompt: str,
+) -> str:
+ """Build a prompt from history + intent and return the LLM completion."""
+ bot_results = None
+ if intent in {
+ "runway_analysis",
+ "obituary_analysis",
+ "pmf_analysis",
+ "pivot_analysis",
+ "acqui_analysis",
+ }:
+ bot_results = await run_relevant_bot(intent, message, db, llm)
+
+ recent = context.get_recent_context(3)
+ history_text = "\n".join(f"{m.role}: {m.content}" for m in recent[:-1])
+
+ prompt = (
+ f"{system_prompt}\n\n"
+ f"Conversation history:\n{history_text}\n\n"
+ f"User message: {message}\n\n"
+ f"Detected intent: {intent}\n"
+ f"User role: {role}\n\n"
+ f"{format_bot_results(bot_results)}\n\n"
+ "Provide a helpful, natural response. Be conversational but informative. "
+ "If this is a follow-up question, reference previous context. Suggest "
+ "relevant next steps.\n\nResponse:"
+ )
+
+ try:
+ return await llm.complete(prompt, temperature=0.7, max_tokens=1024)
+ except Exception:
+ readable = intent.replace("_", " ")
+ return (
+ "I'm here to help with your startup intelligence needs. I noticed "
+ f"you're asking about {readable}. Could you tell me which startup "
+ "you'd like me to analyze?"
+ )
+
+
+def generate_suggested_actions(intent: str, context: ConversationContext) -> list[dict]:
+ actions = {
+ "runway_analysis": [
+ {"label": "View detailed runway metrics", "action": "run_bot", "bot": "runway"},
+ {"label": "Compare with similar startups", "action": "compare"},
+ {"label": "Get funding recommendations", "action": "advice"},
+ ],
+ "obituary_analysis": [
+ {"label": "View failure pattern analysis", "action": "run_bot", "bot": "obituary"},
+ {"label": "See risk mitigation strategies", "action": "advice"},
+ {"label": "Compare with failed startups", "action": "compare"},
+ ],
+ "pmf_analysis": [
+ {"label": "View PMF metrics", "action": "run_bot", "bot": "pmf"},
+ {"label": "See growth recommendations", "action": "advice"},
+ {"label": "Analyze user feedback", "action": "analyze"},
+ ],
+ "pivot_analysis": [
+ {"label": "View pivot history", "action": "run_bot", "bot": "pivot"},
+ {"label": "Explore alternative strategies", "action": "explore"},
+ {"label": "Get strategic advice", "action": "advice"},
+ ],
+ "acqui_analysis": [
+ {"label": "View acqui-hire probability", "action": "run_bot", "bot": "acqui"},
+ {"label": "See likely acquirers", "action": "analyze"},
+ {"label": "Get exit strategy advice", "action": "advice"},
+ ],
+ "startup_search": [
+ {"label": "View startup details", "action": "view"},
+ {"label": "Run full analysis", "action": "analyze_all"},
+ {"label": "Add to watchlist", "action": "watch"},
+ ],
+ "general": [
+ {"label": "Search startups", "action": "search"},
+ {"label": "View dashboard", "action": "dashboard"},
+ {"label": "Get help", "action": "help"},
+ ],
+ }
+ return actions.get(intent, actions["general"])
+
+
+async def generate_related_insights(intent: str, db: DbDep) -> list[dict]:
+ insights: list[dict] = []
+ try:
+ result = await db.execute(select(Startup).limit(3))
+ for startup in result.scalars().all():
+ insights.append(
+ {
+ "type": "startup",
+ "title": startup.name,
+ "description": (
+ f"{startup.industry or 'Tech'} startup at "
+ f"{startup.stage or 'early'} stage"
+ ),
+ "relevance_score": 0.85,
+ }
+ )
+ except Exception:
+ logger.debug("related insights lookup failed", exc_info=True)
+ return insights
+
+
+@router.post(
+ "/send",
+ response_model=ChatResponse,
+ dependencies=[Depends(rate_limit_dependency)],
+)
+async def send_message(
+ request: ChatRequest,
+ db: DbDep,
+ redis=Depends(get_redis_client),
+) -> ChatResponse:
+ """Send a message and get a full (non-streaming) assistant response."""
+ sanitized = sanitize_input(request.message)
+ if len(sanitized) > _MAX_MESSAGE_LENGTH:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail=f"Message too long. Maximum {_MAX_MESSAGE_LENGTH} characters allowed.",
+ )
+ if not sanitized:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Message cannot be empty.",
+ )
+
+ conversation_id, context = await get_or_create_conversation(
+ request.conversation_id, redis
+ )
+ context.user_role = request.role
+ context.add_message(ChatMessage(role="user", content=sanitized))
+
+ intent = context.detect_intent(sanitized)
+ context.detected_intent = intent
+
+ llm = get_llm_client()
+ response_content = await generate_intelligent_response(
+ message=sanitized,
+ intent=intent,
+ context=context,
+ llm=llm,
+ db=db,
+ role=request.role,
+ system_prompt=_SYSTEM_PROMPTS.get(request.role, _SYSTEM_PROMPTS["founder"]),
+ )
+
+ assistant_message = ChatMessage(
+ role="assistant",
+ content=response_content,
+ metadata={"intent": intent, "role": request.role},
+ )
+ context.add_message(assistant_message)
+
+ if _USE_REDIS and redis is not None:
+ await redis.setex(
+ f"chat:{conversation_id}",
+ timedelta(hours=_CONVERSATION_TTL_HOURS),
+ _serialize_context(context),
+ )
+
+ return ChatResponse(
+ message=assistant_message,
+ conversation_id=conversation_id,
+ suggested_actions=generate_suggested_actions(intent, context),
+ related_insights=await generate_related_insights(intent, db),
+ )
+
+
+@router.post("/stream")
+async def stream_message(
+ request: ChatRequest,
+ db: DbDep,
+ redis=Depends(get_redis_client),
+) -> StreamingResponse:
+ """Stream an assistant response as server-sent events."""
+ sanitized = sanitize_input(request.message)
+ if not sanitized:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Message cannot be empty.",
+ )
+
+ conversation_id, context = await get_or_create_conversation(
+ request.conversation_id, redis
+ )
+ context.user_role = request.role
+ context.add_message(ChatMessage(role="user", content=sanitized))
+ intent = context.detect_intent(sanitized)
+ context.detected_intent = intent
+
+ async def generate_stream() -> AsyncGenerator[str, None]:
+ yield f"data: {json.dumps({'type': 'conversation_id', 'id': conversation_id})}\n\n"
+ for chunk in (
+ "I'm analyzing your request",
+ f" about {intent.replace('_', ' ')}...",
+ "\n\n",
+ ):
+ yield f"data: {json.dumps({'type': 'chunk', 'content': chunk})}\n\n"
+
+ llm = get_llm_client()
+ full_response = await generate_intelligent_response(
+ message=sanitized,
+ intent=intent,
+ context=context,
+ llm=llm,
+ db=db,
+ role=request.role,
+ system_prompt=_SYSTEM_PROMPTS.get(request.role, _SYSTEM_PROMPTS["founder"]),
+ )
+ context.add_message(ChatMessage(role="assistant", content=full_response))
+ yield f"data: {json.dumps({'type': 'complete', 'content': full_response})}\n\n"
+
+ return StreamingResponse(generate_stream(), media_type="text/event-stream")
+
+
+@router.get("/conversations/{conversation_id}/history")
+async def get_conversation_history(conversation_id: str) -> list[ChatMessage]:
+ """Return the message history for an in-memory conversation."""
+ if conversation_id not in _conversations:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Conversation not found or expired",
+ )
+ context, _ = _conversations[conversation_id]
+ return context.history
+
+
+@router.post("/conversations/{conversation_id}/clear")
+async def clear_conversation(conversation_id: str) -> dict:
+ """Clear the history of an in-memory conversation."""
+ if conversation_id not in _conversations:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Conversation not found or expired",
+ )
+ context, _ = _conversations[conversation_id]
+ context.history.clear()
+ _conversations[conversation_id] = (context, utc_now())
+ return {"message": "Conversation cleared", "conversation_id": conversation_id}
+
+
+@router.get("/intents")
+async def get_available_intents() -> dict:
+ """List supported intents and their descriptions."""
+ return {
+ "runway_analysis": "Analyze financial runway and stress indicators",
+ "obituary_analysis": "Detect failure patterns and risks",
+ "pmf_analysis": "Evaluate product-market fit",
+ "pivot_analysis": "Detect strategic pivots and changes",
+ "acqui_analysis": "Predict acquisition probability",
+ "investor_analysis": "Analyze investor network and value-add",
+ "accelerator_analysis": "Compare accelerator programs",
+ "term_analysis": "Analyze term sheets and clauses",
+ "startup_search": "Find and explore startups",
+ "compare": "Compare multiple startups",
+ "general": "General questions and help",
+ }
diff --git a/tests/test_api/test_chat_route.py b/tests/test_api/test_chat_route.py
new file mode 100644
index 0000000..4065385
--- /dev/null
+++ b/tests/test_api/test_chat_route.py
@@ -0,0 +1,139 @@
+"""Tests for the chat routes and the shared rate-limit dependency."""
+
+from __future__ import annotations
+
+import json
+
+import pytest
+from fastapi.testclient import TestClient
+
+from startupintel.api import dependencies as deps
+from startupintel.api.dependencies import RateLimiter
+from startupintel.api.main import app
+from startupintel.api.routes import chat as chat_mod
+
+
+class FakeLLM:
+ async def complete(self, prompt: str, **kwargs) -> str:
+ return "Here is a concise, data-driven answer."
+
+
+class FailingLLM:
+ async def complete(self, prompt: str, **kwargs) -> str:
+ raise RuntimeError("llm down")
+
+
+class _EmptyResult:
+ def scalars(self):
+ return self
+
+ def all(self):
+ return []
+
+
+class FakeSession:
+ async def execute(self, *args, **kwargs):
+ return _EmptyResult()
+
+
+@pytest.fixture
+def client(monkeypatch):
+ monkeypatch.setattr(chat_mod, "get_llm_client", lambda: FakeLLM())
+
+ async def _no_redis():
+ return None
+
+ async def _fake_db():
+ yield FakeSession()
+
+ app.dependency_overrides[deps.get_redis_client] = _no_redis
+ app.dependency_overrides[deps.get_db] = _fake_db
+ chat_mod._conversations.clear()
+ deps._rate_limit_store.clear()
+ yield TestClient(app)
+ app.dependency_overrides.clear()
+
+
+def test_send_returns_response_and_conversation_id(client: TestClient):
+ resp = client.post("/chat/send", json={"message": "How is my runway?"})
+ assert resp.status_code == 200
+ body = resp.json()
+ assert body["conversation_id"]
+ assert body["message"]["role"] == "assistant"
+ assert body["message"]["metadata"]["intent"] == "runway_analysis"
+ assert body["suggested_actions"]
+
+
+def test_send_sanitizes_html(client: TestClient):
+ resp = client.post("/chat/send", json={"message": " hello"})
+ assert resp.status_code == 200
+ cid = resp.json()["conversation_id"]
+ history = client.get(f"/chat/conversations/{cid}/history").json()
+ assert "