diff --git a/changelog.d/history-detail-performance.md b/changelog.d/history-detail-performance.md new file mode 100644 index 000000000..13ec3ca23 --- /dev/null +++ b/changelog.d/history-detail-performance.md @@ -0,0 +1,9 @@ +--- +category: Features +pr: 795 +--- + +**History detail performance**: bound memory and make the conversation-history detail view fast for arbitrarily large sessions (fixes admin-dashboard slowness/OOM on large sessions). + - List reads precomputed `session_summaries` (no full-payload scan); one-time preview backfill. + - Detail + markdown/JSONL exports stream bounded-memory; O(turns) single-anchor request reconstruction (the largest cumulative request array is parsed once and per-turn deltas are sliced from it via a raw→parsed prefix map), with a fast path plus a correctness fallback for request-modified / non-monotonic sessions. Output is byte-identical to the per-turn build. + - Conversation-detail turn pagination (`offset`/`limit`; omit `offset` ⇒ newest page): only the requested window's payloads are read, so each request is bounded. Frontend loads the newest page first and loads older pages on scroll-up; whole-session stats stay invariant across pages; rendered conversation unchanged. This is the conversation-page lazy-loading deferred by #752. diff --git a/src/luthien_proxy/history/models.py b/src/luthien_proxy/history/models.py index ddd95901f..9092b9d70 100644 --- a/src/luthien_proxy/history/models.py +++ b/src/luthien_proxy/history/models.py @@ -53,6 +53,7 @@ class ConversationTurn(BaseModel): model: str | None = None # Messages in this turn (from final request/response) request_messages: list[ConversationMessage] + request_messages_full: list[ConversationMessage] | None = None response_messages: list[ConversationMessage] # Policy annotations for this turn annotations: list[PolicyAnnotation] @@ -167,6 +168,10 @@ class SessionDetail(BaseModel): turns: list[ConversationTurn] total_policy_interventions: int models_used: list[str] + total_turns: int = 0 + offset: int = 0 + limit: int = 50 + has_more: bool = False __all__ = [ diff --git a/src/luthien_proxy/history/routes.py b/src/luthien_proxy/history/routes.py index 8dcf5ec10..2a51f48d1 100644 --- a/src/luthien_proxy/history/routes.py +++ b/src/luthien_proxy/history/routes.py @@ -14,7 +14,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.responses import FileResponse, StreamingResponse from pydantic import BaseModel, Field from luthien_proxy.auth import check_auth_or_redirect, verify_admin_token @@ -27,7 +27,7 @@ from . import user_labels as user_labels_service from .models import SessionDetail, SessionListResponse, SessionSearchParams -from .service import export_session_jsonl, export_session_markdown, fetch_session_detail, fetch_session_list +from .service import fetch_session_list, stream_session_detail_json, stream_session_jsonl, stream_session_markdown class UserLabelRequest(BaseModel): @@ -197,48 +197,62 @@ async def delete_user_label( return {"deleted": True} -@api_router.get("/sessions/{session_id}", response_model=SessionDetail) +@api_router.get("/sessions/{session_id}", responses={200: {"model": SessionDetail}}) async def get_session( session_id: str, + offset: int | None = Query(default=None, ge=0), + limit: int = Query(default=50, ge=1, le=200), _: str = Depends(verify_admin_token), db_pool: DatabasePool = Depends(get_db_pool), -) -> SessionDetail: - """Get full session detail with conversation turns. +) -> StreamingResponse: + """Get a window of session turns in chronological display order. - Returns the complete conversation history for a session, - including all messages, tool calls, and policy annotations. + ``offset`` is a zero-based chronological turn offset. When omitted, the + newest page is returned. ``limit`` defaults to 50 and is capped at 200. """ try: - return await fetch_session_detail(session_id, db_pool) + stream = stream_session_detail_json(session_id, db_pool, offset=offset, limit=limit) + first_chunk = await anext(stream) except ValueError as e: logger.warning(f"Session not found: {repr(e)}") raise HTTPException(status_code=404, detail="Session not found.") from None + async def body(): + yield first_chunk + async for chunk in stream: + yield chunk + + return StreamingResponse(body(), media_type="application/json") + @api_router.get("/sessions/{session_id}/export") async def export_session( session_id: str, _: str = Depends(verify_admin_token), db_pool: DatabasePool = Depends(get_db_pool), -) -> PlainTextResponse: +) -> StreamingResponse: """Export session as markdown. Returns the conversation history formatted as a markdown document, suitable for saving or sharing. """ try: - session = await fetch_session_detail(session_id, db_pool) + stream = stream_session_markdown(session_id, db_pool) + first_chunk = await anext(stream) except ValueError as e: logger.warning(f"Session not found for export: {repr(e)}") raise HTTPException(status_code=404, detail="Session not found.") from None - markdown = export_session_markdown(session) - # Sanitize session_id for filename safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) - return PlainTextResponse( - content=markdown, + async def body(): + yield first_chunk + async for chunk in stream: + yield chunk + + return StreamingResponse( + body(), media_type="text/markdown", headers={"Content-Disposition": f'attachment; filename="conversation_{safe_id}.md"'}, ) @@ -249,23 +263,27 @@ async def export_session_jsonl_endpoint( session_id: str, _: str = Depends(verify_admin_token), db_pool: DatabasePool = Depends(get_db_pool), -) -> PlainTextResponse: +) -> StreamingResponse: """Export session as JSONL (one JSON line per turn). Returns the conversation history as JSONL, suitable for programmatic analysis and log ingestion. """ try: - session = await fetch_session_detail(session_id, db_pool) + stream = stream_session_jsonl(session_id, db_pool) + first_chunk = await anext(stream) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None - jsonl = export_session_jsonl(session) - safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in session_id) - return PlainTextResponse( - content=jsonl, + async def body(): + yield first_chunk + async for chunk in stream: + yield chunk + + return StreamingResponse( + body(), media_type="application/x-ndjson", headers={"Content-Disposition": f'attachment; filename="conversation_{safe_id}.jsonl"'}, ) diff --git a/src/luthien_proxy/history/service.py b/src/luthien_proxy/history/service.py index 5134282f8..62908a645 100644 --- a/src/luthien_proxy/history/service.py +++ b/src/luthien_proxy/history/service.py @@ -10,10 +10,13 @@ import json import logging -import re +import weakref +from collections.abc import AsyncIterator, Mapping, Sequence +from dataclasses import dataclass from datetime import datetime from typing import Any, TypedDict, cast +from luthien_proxy.observability.session_summary import extract_preview from luthien_proxy.utils.db import DatabasePool, parse_db_ts from luthien_proxy.utils.search import session_fts_filter_sql @@ -40,7 +43,63 @@ class StoredEvent(TypedDict): created_at: datetime +@dataclass(frozen=True, slots=True) +class CallEventRange: + """Chronological call id and timestamp bounds for payload-scoped streaming.""" + + call_id: str + first_ts: datetime + last_ts: datetime + + +@dataclass(frozen=True, slots=True) +class RequestDeltaState: + """Running message-count state for server-side transcript delta emission.""" + + prev_real_msg_count: int = 0 + + +@dataclass(frozen=True, slots=True) +class RequestProjection: + call_id: str + first_ts: datetime + request_ts: datetime + final_model: str | None + request_params: dict[str, Any] + raw_msg_count: int + request_was_modified: bool + + +@dataclass(frozen=True, slots=True) +class FastPathPlan: + anchor: RequestProjection + preflights: list[RequestProjection] + + +@dataclass(frozen=True, slots=True) +class SessionTurnWindow: + ranges: list[CallEventRange] + total_turns: int + offset: int + limit: int + first_timestamp: datetime + last_timestamp: datetime + initial_prev_real_msg_count: int + + +@dataclass(frozen=True, slots=True) +class SessionDetailStats: + total_policy_interventions: int + models_used: list[str] + + logger = logging.getLogger(__name__) +_SUMMARY_BACKFILLED_POOLS: weakref.WeakSet[DatabasePool] = weakref.WeakSet() +_SUMMARY_PREVIEWS_BACKFILLED_POOLS: weakref.WeakSet[DatabasePool] = weakref.WeakSet() +_NO_PREVIEW_SENTINEL = "" +_SESSION_DETAIL_BATCH_SIZE = 25 +_SESSION_DETAIL_DEFAULT_LIMIT = 50 +_SESSION_DETAIL_MAX_LIMIT = 200 # User-friendly descriptions for common policy event types. # Note: every current emitter writes a non-empty `summary` into the event @@ -173,70 +232,50 @@ def _parse_request_messages(request: dict[str, Any]) -> list[ConversationMessage raw_messages = request.get("messages", []) for msg in raw_messages: - role = msg.get("role", "") - msg_type = _ROLE_TO_MESSAGE_TYPE.get(role, MessageType.UNKNOWN) - if msg_type == MessageType.UNKNOWN: - raise ValueError(f"Unrecognized message role: '{role}'") - - content = extract_text_content(msg.get("content")) - - # For OpenAI-style tool results, include the tool_call_id - tool_call_id = msg.get("tool_call_id") if msg_type == MessageType.TOOL_RESULT else None - - # For assistant messages, extract any tool calls first - if msg_type == MessageType.ASSISTANT: - tool_call_msgs = _extract_tool_calls(msg) - if tool_call_msgs: - messages.extend(tool_call_msgs) - if content: - messages.append( - ConversationMessage( - message_type=msg_type, - content=content, - ) - ) - continue + messages.extend(_parse_raw_request_message(msg)) - # Anthropic-style tool results: user messages with tool_result content blocks. - # Split these into separate TOOL_RESULT messages with their tool_use_id - # so the frontend can pair them with tool calls. - if msg_type == MessageType.USER: - raw_content = msg.get("content") - if isinstance(raw_content, list): - has_tool_results = any(b.get("type") == "tool_result" for b in raw_content) - if has_tool_results: - for block in raw_content: - if block.get("type") == "tool_result": - result_content = block.get("content") - text = extract_text_content(result_content) if result_content is not None else "" - # False/None → None; only True propagates - is_error = block.get("is_error") or None - messages.append( - ConversationMessage( - message_type=MessageType.TOOL_RESULT, - content=text, - tool_call_id=block.get("tool_use_id"), - is_error=is_error, - ) - ) - elif block.get("type") == "text" and block.get("text", "").strip(): - messages.append( - ConversationMessage( - message_type=MessageType.USER, - content=block["text"], - ) - ) - continue + return messages - messages.append( - ConversationMessage( - message_type=msg_type, - content=content, - tool_call_id=tool_call_id, - ) - ) - return messages +def _parse_raw_request_message(msg: dict[str, Any]) -> list[ConversationMessage]: + role = msg.get("role", "") + msg_type = _ROLE_TO_MESSAGE_TYPE.get(role, MessageType.UNKNOWN) + if msg_type == MessageType.UNKNOWN: + raise ValueError(f"Unrecognized message role: '{role}'") + + content = extract_text_content(msg.get("content")) + tool_call_id = msg.get("tool_call_id") if msg_type == MessageType.TOOL_RESULT else None + if msg_type == MessageType.ASSISTANT: + tool_call_msgs = _extract_tool_calls(msg) + if tool_call_msgs: + if content: + return [*tool_call_msgs, ConversationMessage(message_type=msg_type, content=content)] + return tool_call_msgs + + if msg_type == MessageType.USER: + raw_content = msg.get("content") + if isinstance(raw_content, list): + has_tool_results = any(b.get("type") == "tool_result" for b in raw_content) + if has_tool_results: + parsed: list[ConversationMessage] = [] + for block in raw_content: + if block.get("type") == "tool_result": + result_content = block.get("content") + text = extract_text_content(result_content) if result_content is not None else "" + is_error = block.get("is_error") or None + parsed.append( + ConversationMessage( + message_type=MessageType.TOOL_RESULT, + content=text, + tool_call_id=block.get("tool_use_id"), + is_error=is_error, + ) + ) + elif block.get("type") == "text" and block.get("text", "").strip(): + parsed.append(ConversationMessage(message_type=MessageType.USER, content=block["text"])) + return parsed + + return [ConversationMessage(message_type=msg_type, content=content, tool_call_id=tool_call_id)] def _parse_response_messages(response: dict[str, Any]) -> list[ConversationMessage]: @@ -291,13 +330,6 @@ def _parse_response_messages(response: dict[str, Any]) -> list[ConversationMessa return messages -# Maximum length for first user message preview -_FIRST_MESSAGE_MAX_LENGTH = 100 - -# Pattern to strip system-reminder tags from content -_SYSTEM_REMINDER_PATTERN = re.compile(r".*?\s*", re.DOTALL) - - def _extract_preview_message(payload: dict[str, Any] | str | None) -> str | None: """Extract the first meaningful user message from a request payload for preview. @@ -307,51 +339,7 @@ def _extract_preview_message(payload: dict[str, Any] | str | None) -> str | None ``inject_policy_awareness_anthropic``). Falls back to ``final_request`` for older payloads recorded before ``original_request`` was stored. """ - if not payload: - return None - - # Handle JSON string (from asyncpg) - if isinstance(payload, str): - payload = _safe_parse_json(payload) - if not payload: - return None - - request = payload.get("original_request") or payload.get("final_request") or {} - - # Skip probe requests structurally: Claude Code sends internal probes - # (token counting, quota checks) with max_tokens=1. No real conversation - # uses max_tokens=1, so this catches all probes without a content blocklist. - max_tokens = request.get("max_tokens") - if max_tokens is not None: - try: - if int(max_tokens) <= 1: - return None - except (TypeError, ValueError) as e: - logger.debug(f"max_tokens conversion failed: {repr(e)}") - - messages = request.get("messages", []) - - # Find the first meaningful user message (captures session intent) - for msg in messages: - if not isinstance(msg, dict): - continue - if msg.get("role") == "user": - content = extract_text_content(msg.get("content")) - if content: - # Truncate and clean up for display - content = content.strip() - # Skip system-reminder tags (Claude Code injects these) - if content.startswith(""): - content = _SYSTEM_REMINDER_PATTERN.sub("", content).strip() - if not content: - continue - # Replace newlines with spaces for single-line preview - content = " ".join(content.split()) - if len(content) > _FIRST_MESSAGE_MAX_LENGTH: - content = content[:_FIRST_MESSAGE_MAX_LENGTH] + "..." - return content - - return None + return extract_preview(payload) # A "real" policy intervention is any policy.* event that is not a judge @@ -454,6 +442,347 @@ def _having_clause(having: list[str]) -> str: return ("HAVING " + " AND ".join(having)) if having else "" +def _models_from_summary(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return sorted(model for model in value.split(",") if model) + if isinstance(value, list | tuple): + return sorted(str(model) for model in value if model) + return [] + + +def _preview_from_summary(value: object) -> str | None: + if value is None or value == _NO_PREVIEW_SENTINEL: + return None + return str(value) + + +def _last_timestamp_from_ranges(ranges: Sequence[CallEventRange]) -> datetime: + return max(call_range.last_ts for call_range in ranges) + + +def _row_value(row: Any, primary: str, fallback: str) -> object: + try: + return row[primary] + except KeyError: + return row[fallback] + + +def _int_value(value: object) -> int: + if isinstance(value, int | str | float): + return int(value) + raise TypeError(f"Expected numeric row value, got {type(value).__name__}") + + +def _clamp_detail_limit(limit: int) -> int: + return min(max(limit, 1), _SESSION_DETAIL_MAX_LIMIT) + + +def _effective_detail_offset(offset: int | None, total_turns: int, limit: int) -> int: + if offset is not None: + return offset + return max(0, total_turns - limit) + + +async def _fetch_session_turn_window( + session_id: str, + db_pool: DatabasePool, + *, + offset: int | None, + limit: int, +) -> SessionTurnWindow: + bounded_limit = _clamp_detail_limit(limit) + is_sqlite = db_pool.is_sqlite is True + async with db_pool.connection() as conn: + summary_row = await conn.fetchrow( + """ + SELECT MIN(created_at) AS first_ts, + MAX(created_at) AS last_ts, + COUNT(DISTINCT CASE WHEN event_type = 'transaction.request_recorded' THEN call_id END) AS total_turns + FROM conversation_events + WHERE session_id = $1 + """, + session_id, + ) + if summary_row is None or summary_row["first_ts"] is None: + raise ValueError(f"No events found for session_id: {session_id}") + total_turns = _int_value(summary_row["total_turns"]) + effective_offset = _effective_detail_offset(offset, total_turns, bounded_limit) + if total_turns == 0 or effective_offset >= total_turns: + return SessionTurnWindow( + ranges=[], + total_turns=total_turns, + offset=effective_offset, + limit=bounded_limit, + first_timestamp=parse_db_ts(summary_row["first_ts"]), + last_timestamp=parse_db_ts(summary_row["last_ts"]), + initial_prev_real_msg_count=0, + ) + request_rows = await conn.fetch( + """ + SELECT call_id, created_at AS request_ts + FROM conversation_events + WHERE session_id = $1 AND event_type = 'transaction.request_recorded' + ORDER BY created_at ASC + LIMIT $2 OFFSET $3 + """, + session_id, + bounded_limit, + effective_offset, + ) + call_ids = [str(row["call_id"]) for row in request_rows] + if not call_ids: + return SessionTurnWindow( + ranges=[], + total_turns=total_turns, + offset=effective_offset, + limit=bounded_limit, + first_timestamp=parse_db_ts(summary_row["first_ts"]), + last_timestamp=parse_db_ts(summary_row["last_ts"]), + initial_prev_real_msg_count=0, + ) + first_request_ts = parse_db_ts(request_rows[0]["request_ts"]) + placeholders = ", ".join(f"${index}" for index in range(2, len(call_ids) + 2)) + if is_sqlite: + range_rows = await conn.fetch( + f""" + SELECT call_id, MIN(created_at) AS first_ts, MAX(created_at) AS last_ts + FROM conversation_events + WHERE session_id = $1 AND call_id IN ({placeholders}) + GROUP BY call_id + """, + session_id, + *call_ids, + ) + else: + range_rows = await conn.fetch( + """ + SELECT call_id, MIN(created_at) AS first_ts, MAX(created_at) AS last_ts + FROM conversation_events + WHERE session_id = $1 AND call_id = ANY($2) + GROUP BY call_id + """, + session_id, + call_ids, + ) + ranges_by_call_id = { + str(row["call_id"]): CallEventRange( + call_id=str(row["call_id"]), + first_ts=parse_db_ts(row["first_ts"]), + last_ts=parse_db_ts(row["last_ts"]), + ) + for row in range_rows + } + previous_count = await _fetch_previous_real_raw_msg_count( + conn, + session_id, + first_request_ts, + is_sqlite=is_sqlite, + ) + return SessionTurnWindow( + ranges=[ranges_by_call_id[call_id] for call_id in call_ids if call_id in ranges_by_call_id], + total_turns=total_turns, + offset=effective_offset, + limit=bounded_limit, + first_timestamp=parse_db_ts(summary_row["first_ts"]), + last_timestamp=parse_db_ts(summary_row["last_ts"]), + initial_prev_real_msg_count=previous_count, + ) + + +async def _fetch_session_detail_stats(session_id: str, db_pool: DatabasePool) -> SessionDetailStats: + is_sqlite = db_pool.is_sqlite is True + async with db_pool.connection() as conn: + summary_row = await conn.fetchrow( + """ + SELECT policy_event_count, models_used + FROM session_summaries + WHERE session_id = $1 + """, + session_id, + ) + if summary_row is not None and summary_row["models_used"] is not None: + return SessionDetailStats( + total_policy_interventions=_int_value(summary_row["policy_event_count"]), + models_used=_models_from_summary(summary_row["models_used"]), + ) + if is_sqlite: + stats_row = await conn.fetchrow( + f""" + SELECT + {_intervention_count_expr(False)} AS policy_interventions, + GROUP_CONCAT(DISTINCT json_extract(ce.payload, '$.final_model')) AS models_used + FROM conversation_events ce + WHERE ce.session_id = $1 + AND ( + ce.event_type <> 'transaction.request_recorded' + OR json_extract(ce.payload, '$.final_model') IS NOT NULL + ) + """, + session_id, + ) + else: + stats_row = await conn.fetchrow( + f""" + SELECT + {_intervention_count_expr(True)} AS policy_interventions, + string_agg(DISTINCT ce.payload->>'final_model', ',') AS models_used + FROM conversation_events ce + WHERE ce.session_id = $1 + AND ( + ce.event_type <> 'transaction.request_recorded' + OR ce.payload->>'final_model' IS NOT NULL + ) + """, + session_id, + ) + if stats_row is None: + return SessionDetailStats(total_policy_interventions=0, models_used=[]) + return SessionDetailStats( + total_policy_interventions=_int_value(stats_row["policy_interventions"]), + models_used=_models_from_summary(stats_row["models_used"]), + ) + + +async def _backfill_missing_session_summaries(db_pool: DatabasePool) -> None: + if not isinstance(db_pool, DatabasePool): + return + if db_pool in _SUMMARY_BACKFILLED_POOLS: + return + async with db_pool.connection() as conn: + if db_pool.is_postgres: + await conn.execute( + f""" + INSERT INTO session_summaries ( + session_id, first_seen, last_seen, event_count, call_count, + policy_event_count, user_id, models_used + ) + SELECT + ce.session_id, + MIN(ce.created_at), + MAX(ce.created_at), + COUNT(*), + COUNT(*) FILTER (WHERE ce.event_type = 'transaction.request_recorded'), + {_intervention_count_expr(True)}, + (SELECT cc.user_id FROM conversation_calls cc + WHERE cc.session_id = ce.session_id AND cc.user_id IS NOT NULL + ORDER BY cc.created_at LIMIT 1), + (SELECT string_agg(DISTINCT ce2.payload->>'final_model', ',') + FROM conversation_events ce2 + WHERE ce2.session_id = ce.session_id + AND ce2.event_type = 'transaction.request_recorded' + AND ce2.payload->>'final_model' IS NOT NULL) + FROM conversation_events ce + WHERE ce.session_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM session_summaries ss WHERE ss.session_id = ce.session_id + ) + GROUP BY ce.session_id + ON CONFLICT (session_id) DO NOTHING + """ + ) + else: + await conn.execute( + f""" + INSERT OR IGNORE INTO session_summaries ( + session_id, first_seen, last_seen, event_count, call_count, + policy_event_count, user_id, models_used + ) + SELECT + ce.session_id, + MIN(ce.created_at), + MAX(ce.created_at), + COUNT(*), + SUM(CASE WHEN ce.event_type = 'transaction.request_recorded' THEN 1 ELSE 0 END), + {_intervention_count_expr(False)}, + (SELECT cc.user_id FROM conversation_calls cc + WHERE cc.session_id = ce.session_id AND cc.user_id IS NOT NULL + ORDER BY cc.created_at LIMIT 1), + (SELECT GROUP_CONCAT(DISTINCT json_extract(ce2.payload, '$.final_model')) + FROM conversation_events ce2 + WHERE ce2.session_id = ce.session_id + AND ce2.event_type = 'transaction.request_recorded' + AND json_extract(ce2.payload, '$.final_model') IS NOT NULL) + FROM conversation_events ce + WHERE ce.session_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM session_summaries ss WHERE ss.session_id = ce.session_id + ) + GROUP BY ce.session_id + """ + ) + _SUMMARY_BACKFILLED_POOLS.add(db_pool) + + +async def _backfill_session_summary_previews(db_pool: DatabasePool) -> None: + if not isinstance(db_pool, DatabasePool): + return + if db_pool in _SUMMARY_PREVIEWS_BACKFILLED_POOLS: + return + batch_size = 500 + cursor = "" + async with db_pool.connection() as conn: + while True: + session_rows = await conn.fetch( + """ + SELECT session_id + FROM session_summaries + WHERE preview_message IS NULL AND session_id > $1 + ORDER BY session_id + LIMIT $2 + """, + cursor, + batch_size, + ) + if not session_rows: + _SUMMARY_PREVIEWS_BACKFILLED_POOLS.add(db_pool) + return + for session_row in session_rows: + session_id = str(session_row["session_id"]) + cursor = session_id + if db_pool.is_postgres: + payload_row = await conn.fetchrow( + """ + SELECT payload + FROM conversation_events + WHERE session_id = $1 + AND event_type = 'transaction.request_recorded' + AND COALESCE((payload->'final_request'->>'max_tokens')::int, 2) > 1 + ORDER BY created_at ASC + LIMIT 1 + """, + session_id, + ) + else: + payload_row = await conn.fetchrow( + """ + SELECT payload + FROM conversation_events + WHERE session_id = $1 + AND event_type = 'transaction.request_recorded' + AND COALESCE( + CAST(json_extract(payload, '$.final_request.max_tokens') AS INTEGER), + 2 + ) > 1 + ORDER BY created_at ASC + LIMIT 1 + """, + session_id, + ) + preview: str | None = None + if payload_row is not None: + raw_payload = payload_row["payload"] + payload = json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload + if isinstance(payload, dict): + preview = extract_preview(payload) + await conn.execute( + "UPDATE session_summaries SET preview_message = $1 WHERE session_id = $2", + preview if preview is not None else _NO_PREVIEW_SENTINEL, + session_id, + ) + + async def fetch_session_list( limit: int, db_pool: DatabasePool, @@ -502,7 +831,72 @@ async def _fetch_session_list_pg( # touch conversation_calls in the hot CTE — user_ids come from a separate # post-query keyed on the page's session_ids (mirrors the SQLite pattern). search = search or SessionSearchParams() + if search.is_empty() and user_id is None: + await _backfill_missing_session_summaries(db_pool) + await _backfill_session_summary_previews(db_pool) async with db_pool.connection() as conn: + if search.is_empty() and user_id is None: + total_count = await conn.fetchval("SELECT COUNT(*) FROM session_summaries") + rows = await conn.fetch( + """ + SELECT + session_id, + first_seen as first_ts, + last_seen as last_ts, + event_count as total_events, + call_count as turn_count, + policy_event_count as policy_interventions, + models_used, + preview_message + FROM session_summaries + ORDER BY last_seen DESC + LIMIT $1 OFFSET $2 + """, + limit, + offset, + ) + user_ids_by_session: dict[str, list[str]] = {} + if rows: + session_ids_on_page = [str(row["session_id"]) for row in rows] + placeholders = ", ".join(f"${i + 1}" for i in range(len(session_ids_on_page))) + user_id_rows = await conn.fetch( + f""" + SELECT DISTINCT ce.session_id, cc.user_id + FROM conversation_events ce + JOIN conversation_calls cc ON ce.call_id = cc.call_id + WHERE ce.session_id IN ({placeholders}) + AND cc.user_id IS NOT NULL + """, + *session_ids_on_page, + ) + for r in user_id_rows: + sid = str(r["session_id"]) + uid = str(r["user_id"]) + bucket = user_ids_by_session.setdefault(sid, []) + if uid not in bucket: + bucket.append(uid) + sessions = [ + SessionSummary( + session_id=str(row["session_id"]), + first_timestamp=parse_db_ts(row["first_ts"]).isoformat(), + last_timestamp=parse_db_ts(row["last_ts"]).isoformat(), + turn_count=_int_value(row["turn_count"]), + total_events=_int_value(row["total_events"]), + policy_interventions=_int_value(row["policy_interventions"]), + models_used=_models_from_summary(_row_value(row, "models_used", "models")), + preview_message=_preview_from_summary(row["preview_message"]), + user_ids=user_ids_by_session.get(str(row["session_id"]), []), + ) + for row in rows + ] + total = _int_value(total_count) if total_count is not None else 0 + return SessionListResponse( + sessions=sessions, + total=total, + offset=offset, + has_more=offset + len(sessions) < total, + ) + if search.is_empty(): if user_id is not None: total_count = await conn.fetchval( @@ -671,7 +1065,7 @@ async def _fetch_session_list_pg( turn_count=int(row["turn_count"]), # type: ignore[arg-type] total_events=int(row["total_events"]), # type: ignore[arg-type] policy_interventions=int(row["policy_interventions"]), # type: ignore[arg-type] - models_used=list(row["models"]) if row["models"] else [], # type: ignore[arg-type] + models_used=_models_from_summary(row["models"]), preview_message=_extract_preview_message(cast(_PreviewPayload, row["request_payload"])), user_ids=user_ids_by_session.get(str(row["session_id"]), []), ) @@ -702,7 +1096,74 @@ async def _fetch_session_list_sqlite( # parameters, never interpolated into the SQL string. user_id occupies $3 # in the page query ($1 in the filtered count); search params follow. search = search or SessionSearchParams() + if search.is_empty() and user_id is None: + await _backfill_missing_session_summaries(db_pool) + await _backfill_session_summary_previews(db_pool) async with db_pool.connection() as conn: + if search.is_empty() and user_id is None: + total_count = await conn.fetchval("SELECT COUNT(*) FROM session_summaries") + rows = await conn.fetch( + """ + SELECT + session_id, + first_seen as first_ts, + last_seen as last_ts, + event_count as total_events, + call_count as turn_count, + policy_event_count as policy_interventions, + models_used, + preview_message + FROM session_summaries + ORDER BY last_seen DESC + LIMIT $1 OFFSET $2 + """, + limit, + offset, + ) + total = _int_value(total_count) if total_count is not None else 0 + if not rows: + return SessionListResponse(sessions=[], total=total, offset=offset, has_more=False) + + session_ids = [str(row["session_id"]) for row in rows] + placeholders = ", ".join(f"${i + 1}" for i in range(len(session_ids))) + user_id_rows = await conn.fetch( + f""" + SELECT DISTINCT ce.session_id, cc.user_id + FROM conversation_events ce + JOIN conversation_calls cc ON ce.call_id = cc.call_id + WHERE ce.session_id IN ({placeholders}) + AND cc.user_id IS NOT NULL + """, + *session_ids, + ) + user_ids_by_session: dict[str, list[str]] = {} + for r in user_id_rows: + sid = str(r["session_id"]) + uid = str(r["user_id"]) + bucket = user_ids_by_session.setdefault(sid, []) + if uid not in bucket: + bucket.append(uid) + sessions = [ + SessionSummary( + session_id=str(row["session_id"]), + first_timestamp=parse_db_ts(row["first_ts"]).isoformat(), + last_timestamp=parse_db_ts(row["last_ts"]).isoformat(), + turn_count=_int_value(row["turn_count"]), + total_events=_int_value(row["total_events"]), + policy_interventions=_int_value(row["policy_interventions"]), + models_used=_models_from_summary(_row_value(row, "models_used", "models")), + preview_message=_preview_from_summary(row["preview_message"]), + user_ids=user_ids_by_session.get(str(row["session_id"]), []), + ) + for row in rows + ] + return SessionListResponse( + sessions=sessions, + total=total, + offset=offset, + has_more=offset + len(sessions) < total, + ) + if search.is_empty(): if user_id is not None: total_count = await conn.fetchval( @@ -895,7 +1356,7 @@ async def _fetch_session_list_sqlite( turn_count=int(row["turn_count"]), # type: ignore[arg-type] total_events=int(row["total_events"]), # type: ignore[arg-type] policy_interventions=int(row["policy_interventions"]), # type: ignore[arg-type] - models_used=models_by_session.get(str(row["session_id"]), []), + models_used=sorted(models_by_session.get(str(row["session_id"]), [])), preview_message=preview_by_session.get(str(row["session_id"])), user_ids=user_ids_by_session.get(str(row["session_id"]), []), ) @@ -906,12 +1367,20 @@ async def _fetch_session_list_sqlite( return SessionListResponse(sessions=sessions, total=total, offset=offset, has_more=has_more) -async def fetch_session_detail(session_id: str, db_pool: DatabasePool) -> SessionDetail: +async def fetch_session_detail( + session_id: str, + db_pool: DatabasePool, + *, + offset: int | None = None, + limit: int = _SESSION_DETAIL_DEFAULT_LIMIT, +) -> SessionDetail: """Fetch full session detail with conversation turns. Args: session_id: Session identifier db_pool: Database connection pool + offset: Chronological turn offset. None selects the newest page. + limit: Maximum number of turns to return, capped by the service. Returns: Full session detail with all conversation turns @@ -919,85 +1388,847 @@ async def fetch_session_detail(session_id: str, db_pool: DatabasePool) -> Sessio Raises: ValueError: If no events found for session_id """ - async with db_pool.connection() as conn: + window = await _fetch_session_turn_window(session_id, db_pool, offset=offset, limit=limit) + stats = await _fetch_session_detail_stats(session_id, db_pool) + turns: list[ConversationTurn] = [] + async for turn in iter_session_turns( + session_id, + db_pool, + window.ranges, + initial_prev_real_msg_count=window.initial_prev_real_msg_count, + projection_offset=window.offset, + projection_limit=window.limit, + ): + turns.append(turn) + + return SessionDetail( + session_id=session_id, + first_timestamp=window.first_timestamp.isoformat(), + last_timestamp=window.last_timestamp.isoformat(), + turns=turns, + total_policy_interventions=stats.total_policy_interventions, + models_used=stats.models_used, + total_turns=window.total_turns, + offset=window.offset, + limit=window.limit, + has_more=window.offset > 0, + ) + + +def _stored_events_from_rows(rows: Sequence[Mapping[str, object]]) -> list[StoredEvent]: + events: list[StoredEvent] = [] + for row in rows: + raw_payload = row["payload"] + if isinstance(raw_payload, dict): + payload: dict[str, Any] = dict(raw_payload) + elif isinstance(raw_payload, str): + payload = json.loads(raw_payload) + else: + raise TypeError(f"Unexpected payload type: {type(raw_payload).__name__}") + events.append( + StoredEvent( + event_type=str(row["event_type"]), + payload=payload, + created_at=parse_db_ts(row["created_at"]), + ) + ) + return events + + +def _is_preflight_turn(turn: ConversationTurn) -> bool: + params = turn.request_params or {} + max_tokens = params.get("max_tokens") + if max_tokens == 1: + return True + output_config = params.get("output_config") + if not isinstance(output_config, dict): + return False + output_format = output_config.get("format") + if not isinstance(output_format, dict): + return False + if output_format.get("type") != "json_schema": + return False + return isinstance(max_tokens, int) and max_tokens <= 256 + + +def _is_preflight_projection(projection: RequestProjection) -> bool: + max_tokens = projection.request_params.get("max_tokens") + if max_tokens == 1: + return True + output_config = projection.request_params.get("output_config") + if not isinstance(output_config, dict): + return False + output_format = output_config.get("format") + if not isinstance(output_format, dict): + return False + if output_format.get("type") != "json_schema": + return False + return isinstance(max_tokens, int) and max_tokens <= 256 + + +def _select_fast_path_anchor(projections: Sequence[RequestProjection]) -> FastPathPlan | None: + real_projections: list[RequestProjection] = [] + preflights: list[RequestProjection] = [] + previous_count = 0 + for projection in projections: + if projection.request_was_modified: + return None + if _is_preflight_projection(projection): + preflights.append(projection) + continue + if projection.raw_msg_count < previous_count: + return None + previous_count = projection.raw_msg_count + real_projections.append(projection) + if not real_projections: + return None + anchor = max(real_projections, key=lambda projection: projection.raw_msg_count) + return FastPathPlan(anchor=anchor, preflights=preflights) + + +def _parsed_prefixes_by_raw_count(raw_messages: Sequence[dict[str, Any]]) -> dict[int, list[ConversationMessage]]: + parsed_messages: list[ConversationMessage] = [] + prefixes: dict[int, list[ConversationMessage]] = {0: []} + for index, raw_message in enumerate(raw_messages, start=1): + parsed_messages.extend(_parse_raw_request_message(raw_message)) + prefixes[index] = list(parsed_messages) + return prefixes + + +def _turn_from_projection( + projection: RequestProjection, + events: list[StoredEvent], + request_messages: list[ConversationMessage], +) -> ConversationTurn: + response_messages: list[ConversationMessage] = [] + original_response_messages: list[ConversationMessage] | None = None + annotations: list[PolicyAnnotation] = [] + response_was_modified = False + for event in events: + event_type = event["event_type"] + payload = event["payload"] + if event_type in ( + "transaction.streaming_response_recorded", + "transaction.non_streaming_response_recorded", + ): + final_resp = payload.get("final_response") + if final_resp is None: + raise KeyError(f"{event_type} missing 'final_response'") + response_messages = _parse_response_messages(final_resp) + original_resp = payload.get("original_response") + if original_resp is not None and original_resp != final_resp: + response_was_modified = True + original_response_messages = _parse_response_messages(original_resp) + elif event_type.startswith("policy."): + if "evaluation" in event_type: + continue + annotations.append( + PolicyAnnotation( + policy_name=_extract_policy_name(event_type), + event_type=event_type, + summary=_get_event_summary(event_type, payload), + details=payload if payload else None, + ) + ) + return ConversationTurn( + call_id=projection.call_id, + timestamp=projection.first_ts.isoformat(), + model=projection.final_model, + request_messages=request_messages, + response_messages=response_messages, + annotations=annotations, + had_policy_intervention=response_was_modified or bool(annotations), + request_was_modified=False, + response_was_modified=response_was_modified, + original_response_messages=original_response_messages, + request_params=projection.request_params, + ) + + +async def _iter_session_turns_fast( + projections: Sequence[RequestProjection], + events_by_call_id: Mapping[str, list[StoredEvent]], + messages_by_call_id: Mapping[str, list[dict[str, Any]]], + *, + initial_prev_real_msg_count: int = 0, +) -> AsyncIterator[ConversationTurn]: + plan = _select_fast_path_anchor(projections) + if plan is None: + return + anchor_messages = messages_by_call_id[plan.anchor.call_id] + prefixes = _parsed_prefixes_by_raw_count(anchor_messages) + previous_real_count = initial_prev_real_msg_count + for projection in projections: + if _is_preflight_projection(projection): + request_messages = _parse_request_messages({"messages": messages_by_call_id[projection.call_id]}) + else: + current_prefix = prefixes[projection.raw_msg_count] + previous_prefix = prefixes[previous_real_count] + request_messages = current_prefix[len(previous_prefix) :] + previous_real_count = projection.raw_msg_count + yield _turn_from_projection(projection, events_by_call_id.get(projection.call_id, []), request_messages) + + +def _apply_request_delta( + turn: ConversationTurn, state: RequestDeltaState +) -> tuple[ConversationTurn, RequestDeltaState]: + full_request_messages = turn.request_messages + request_messages_full = full_request_messages if turn.request_was_modified else None + if _is_preflight_turn(turn): + return ( + turn.model_copy( + update={ + "request_messages": full_request_messages, + "request_messages_full": request_messages_full, + } + ), + state, + ) + + request_delta = full_request_messages[state.prev_real_msg_count :] + return ( + turn.model_copy( + update={ + "request_messages": request_delta, + "request_messages_full": request_messages_full, + } + ), + RequestDeltaState(prev_real_msg_count=len(full_request_messages)), + ) + + +def _range_batches(ranges: Sequence[CallEventRange]) -> list[Sequence[CallEventRange]]: + return [ + ranges[index : index + _SESSION_DETAIL_BATCH_SIZE] + for index in range(0, len(ranges), _SESSION_DETAIL_BATCH_SIZE) + ] + + +async def _fetch_turn_batch_rows( + conn: Any, session_id: str, batch: Sequence[CallEventRange], *, is_sqlite: bool +) -> dict[str, list[Mapping[str, object]]]: + if is_sqlite: + placeholders = ", ".join(f"${index}" for index in range(2, len(batch) + 2)) + rows = await conn.fetch( + f""" + SELECT call_id, event_type, payload, created_at + FROM conversation_events + WHERE session_id = $1 AND call_id IN ({placeholders}) + ORDER BY call_id, created_at ASC + """, + session_id, + *(call_range.call_id for call_range in batch), + ) + else: rows = await conn.fetch( """ SELECT call_id, event_type, payload, created_at FROM conversation_events + WHERE session_id = $1 AND call_id = ANY($2) + ORDER BY call_id, created_at ASC + """, + session_id, + [call_range.call_id for call_range in batch], + ) + + range_by_call_id = {call_range.call_id: call_range for call_range in batch} + grouped: dict[str, list[Mapping[str, object]]] = {call_range.call_id: [] for call_range in batch} + for row in rows: + call_id = str(row["call_id"]) + call_range = range_by_call_id.get(call_id) + if call_range is not None and parse_db_ts(row["created_at"]) <= call_range.last_ts: + grouped[call_id].append(row) + return grouped + + +async def _fetch_non_request_event_rows( + conn: Any, session_id: str, ranges: Sequence[CallEventRange], *, is_sqlite: bool +) -> dict[str, list[Mapping[str, object]]]: + if not ranges: + return {} + if is_sqlite: + placeholders = ", ".join(f"${index}" for index in range(2, len(ranges) + 2)) + rows = await conn.fetch( + f""" + SELECT call_id, event_type, payload, created_at + FROM conversation_events + WHERE session_id = $1 AND call_id IN ({placeholders}) AND event_type <> 'transaction.request_recorded' + ORDER BY call_id, created_at ASC + """, + session_id, + *(call_range.call_id for call_range in ranges), + ) + else: + rows = await conn.fetch( + """ + SELECT call_id, event_type, payload, created_at + FROM conversation_events + WHERE session_id = $1 AND call_id = ANY($2) AND event_type <> 'transaction.request_recorded' + ORDER BY call_id, created_at ASC + """, + session_id, + [call_range.call_id for call_range in ranges], + ) + range_by_call_id = {call_range.call_id: call_range for call_range in ranges} + grouped: dict[str, list[Mapping[str, object]]] = {call_range.call_id: [] for call_range in ranges} + for row in rows: + call_id = str(row["call_id"]) + call_range = range_by_call_id.get(call_id) + if call_range is not None and parse_db_ts(row["created_at"]) <= call_range.last_ts: + grouped[call_id].append(row) + return grouped + + +def _json_obj(raw_value: object) -> dict[str, Any] | None: + if isinstance(raw_value, dict): + return dict(raw_value) + if isinstance(raw_value, str): + parsed = json.loads(raw_value) + return parsed if isinstance(parsed, dict) else None + return None + + +def _json_list(raw_value: object) -> list[dict[str, Any]]: + parsed: object = json.loads(raw_value) if isinstance(raw_value, str) else raw_value + if not isinstance(parsed, list): + raise TypeError(f"Expected request messages list, got {type(parsed).__name__}") + return [dict(item) for item in parsed if isinstance(item, dict)] + + +@dataclass(frozen=True, slots=True) +class _OmittedRequestParam: + pass + + +_OMITTED_REQUEST_PARAM = _OmittedRequestParam() + + +def _request_param_is_present(row: Mapping[str, object], key: str) -> bool: + present_value = row.get(f"{key}_present") + if isinstance(present_value, bool): + return present_value + if isinstance(present_value, int): + return bool(present_value) + return row.get(key) is not None + + +def _raw_request_param(raw_value: Any) -> Any | _OmittedRequestParam: + return raw_value + + +def _bool_request_param(raw_value: Any) -> Any | _OmittedRequestParam: + if isinstance(raw_value, bool): + return raw_value + if type(raw_value) is int and raw_value in (0, 1): + return bool(raw_value) + return raw_value + + +def _json_list_request_param(raw_value: Any) -> Any | _OmittedRequestParam: + if isinstance(raw_value, str): + try: + parsed_value = json.loads(raw_value) + except json.JSONDecodeError: + return raw_value + return parsed_value if isinstance(parsed_value, list) else raw_value + return raw_value + + +def _output_config_request_param(raw_value: Any) -> Any | _OmittedRequestParam: + if raw_value is None: + return None + output_config = _json_obj(raw_value) + if output_config is None: + return _OMITTED_REQUEST_PARAM + output_format = output_config.get("format") + if not isinstance(output_format, dict): + return _OMITTED_REQUEST_PARAM + return {"format": {"type": output_format.get("type")}} + + +def _projection_params_from_row(row: Mapping[str, object]) -> dict[str, Any]: + params: dict[str, Any] = {} + for key in _REQUEST_PARAM_ALLOWLIST: + if not _request_param_is_present(row, key): + continue + normalized_value = _REQUEST_PARAM_NORMALIZERS[key](row.get(key)) + if normalized_value is not _OMITTED_REQUEST_PARAM: + params[key] = normalized_value + tools_count = row.get("tools_count") + if isinstance(tools_count, int): + params["tools_count"] = tools_count + return params + + +def _projection_from_row(row: Mapping[str, object]) -> RequestProjection: + raw_msg_count = row["raw_msg_count"] + if not isinstance(raw_msg_count, int): + raise TypeError(f"Expected raw_msg_count int, got {type(raw_msg_count).__name__}") + return RequestProjection( + call_id=str(row["call_id"]), + first_ts=parse_db_ts(row["first_ts"]), + request_ts=parse_db_ts(row["request_ts"]), + final_model=str(row["final_model"]) if row.get("final_model") is not None else None, + request_params=_projection_params_from_row(row), + raw_msg_count=raw_msg_count, + request_was_modified=bool(row["request_was_modified"]), + ) + + +async def _fetch_previous_real_raw_msg_count( + conn: Any, session_id: str, before_ts: datetime, *, is_sqlite: bool +) -> int: + if is_sqlite: + row = await conn.fetchrow( + """ + SELECT json_array_length(json_extract(payload, '$.final_request.messages')) AS raw_msg_count + FROM conversation_events WHERE session_id = $1 - ORDER BY created_at ASC + AND event_type = 'transaction.request_recorded' + AND created_at < $2 + AND NOT ( + COALESCE(json_extract(payload, '$.final_request.max_tokens') = 1, 0) + OR COALESCE( + json_extract(payload, '$.final_request.output_config.format.type') = 'json_schema' + AND json_extract(payload, '$.final_request.max_tokens') <= 256, + 0 + ) + ) + ORDER BY created_at DESC + LIMIT 1 """, session_id, + before_ts.isoformat(), ) + else: + row = await conn.fetchrow( + """ + SELECT jsonb_array_length(payload->'final_request'->'messages') AS raw_msg_count + FROM conversation_events + WHERE session_id = $1 + AND event_type = 'transaction.request_recorded' + AND created_at < $2 + AND NOT ( + COALESCE((payload->'final_request'->>'max_tokens')::integer = 1, false) + OR COALESCE( + payload->'final_request'->'output_config'->'format'->>'type' = 'json_schema' + AND (payload->'final_request'->>'max_tokens')::integer <= 256, + false + ) + ) + ORDER BY created_at DESC + LIMIT 1 + """, + session_id, + before_ts, + ) + if row is None or row["raw_msg_count"] is None: + return 0 + return _int_value(row["raw_msg_count"]) - if not rows: - raise ValueError(f"No events found for session_id: {session_id}") - # Group events by call_id - calls: dict[str, list[StoredEvent]] = {} +async def _fetch_request_projections( + conn: Any, + session_id: str, + ranges: Sequence[CallEventRange], + *, + is_sqlite: bool, + offset: int | None = None, + limit: int | None = None, +) -> list[RequestProjection]: + if not ranges: + return [] + if is_sqlite: + if offset is None or limit is None: + where_clause = f"session_id = $1 AND call_id IN ({', '.join(f'${index}' for index in range(2, len(ranges) + 2))}) AND event_type = 'transaction.request_recorded'" + query_args = (session_id, *(call_range.call_id for call_range in ranges)) + source_clause = f"conversation_events WHERE {where_clause}" + else: + query_args = (session_id, limit, offset) + source_clause = """ + ( + SELECT call_id, created_at, payload + FROM conversation_events + WHERE session_id = $1 AND event_type = 'transaction.request_recorded' + ORDER BY created_at ASC + LIMIT $2 OFFSET $3 + ) AS window_events + """ + rows = await conn.fetch( + f""" + SELECT call_id, + MIN(created_at) OVER (PARTITION BY call_id) AS first_ts, + created_at AS request_ts, + json_extract(payload, '$.final_model') AS final_model, + json_extract(payload, '$.final_request.model') AS model, + json_extract(payload, '$.final_request.max_tokens') AS max_tokens, + json_extract(payload, '$.final_request.stream') AS stream, + json_extract(payload, '$.final_request.temperature') AS temperature, + json_extract(payload, '$.final_request.top_p') AS top_p, + json_extract(payload, '$.final_request.top_k') AS top_k, + json_extract(payload, '$.final_request.stop_sequences') AS stop_sequences, + json_extract(payload, '$.final_request.output_config') AS output_config, + json_type(payload, '$.final_request.model') IS NOT NULL AS model_present, + json_type(payload, '$.final_request.max_tokens') IS NOT NULL AS max_tokens_present, + json_type(payload, '$.final_request.stream') IS NOT NULL AS stream_present, + json_type(payload, '$.final_request.temperature') IS NOT NULL AS temperature_present, + json_type(payload, '$.final_request.top_p') IS NOT NULL AS top_p_present, + json_type(payload, '$.final_request.top_k') IS NOT NULL AS top_k_present, + json_type(payload, '$.final_request.stop_sequences') IS NOT NULL AS stop_sequences_present, + json_type(payload, '$.final_request.output_config') IS NOT NULL AS output_config_present, + json_array_length(json_extract(payload, '$.final_request.tools')) AS tools_count, + json_array_length(json_extract(payload, '$.final_request.messages')) AS raw_msg_count, + CASE + WHEN json_type(payload, '$.original_request') IS NULL THEN 0 + WHEN json_type(payload, '$.final_request') IS NULL THEN 1 + ELSE json_extract(payload, '$.original_request') <> json_extract(payload, '$.final_request') + END AS request_was_modified + FROM {source_clause} + ORDER BY call_id, created_at ASC + """, + *query_args, + ) + else: + if offset is None or limit is None: + where_clause = "session_id = $1 AND call_id = ANY($2) AND event_type = 'transaction.request_recorded'" + query_args = (session_id, [call_range.call_id for call_range in ranges]) + source_clause = f"conversation_events WHERE {where_clause}" + else: + query_args = (session_id, limit, offset) + source_clause = """ + ( + SELECT call_id, created_at, payload + FROM conversation_events + WHERE session_id = $1 AND event_type = 'transaction.request_recorded' + ORDER BY created_at ASC + LIMIT $2 OFFSET $3 + ) AS window_events + """ + rows = await conn.fetch( + f""" + SELECT call_id, + MIN(created_at) OVER (PARTITION BY call_id) AS first_ts, + created_at AS request_ts, + payload->>'final_model' AS final_model, + payload->'final_request'->>'model' AS model, + (payload->'final_request'->>'max_tokens')::integer AS max_tokens, + (payload->'final_request'->>'stream')::boolean AS stream, + (payload->'final_request'->>'temperature')::double precision AS temperature, + (payload->'final_request'->>'top_p')::double precision AS top_p, + (payload->'final_request'->>'top_k')::integer AS top_k, + payload->'final_request'->'stop_sequences' AS stop_sequences, + payload->'final_request'->'output_config' AS output_config, + payload->'final_request' ? 'model' AS model_present, + payload->'final_request' ? 'max_tokens' AS max_tokens_present, + payload->'final_request' ? 'stream' AS stream_present, + payload->'final_request' ? 'temperature' AS temperature_present, + payload->'final_request' ? 'top_p' AS top_p_present, + payload->'final_request' ? 'top_k' AS top_k_present, + payload->'final_request' ? 'stop_sequences' AS stop_sequences_present, + payload->'final_request' ? 'output_config' AS output_config_present, + jsonb_array_length(COALESCE(payload->'final_request'->'tools', '[]'::jsonb)) AS tools_count, + jsonb_array_length(payload->'final_request'->'messages') AS raw_msg_count, + (payload->'original_request') IS NOT NULL + AND (payload->'original_request') <> (payload->'final_request') AS request_was_modified + FROM {source_clause} + ORDER BY call_id, created_at ASC + """, + *query_args, + ) + + range_by_call_id = {call_range.call_id: call_range for call_range in ranges} + projection_by_call_id: dict[str, RequestProjection] = {} for row in rows: call_id = str(row["call_id"]) - if call_id not in calls: - calls[call_id] = [] + call_range = range_by_call_id.get(call_id) + if call_range is None or parse_db_ts(row["request_ts"]) > call_range.last_ts: + continue + row_values = dict(row) + row_values["first_ts"] = call_range.first_ts + projection_by_call_id.setdefault(call_id, _projection_from_row(row_values)) + return [ + projection_by_call_id[call_range.call_id] + for call_range in ranges + if call_range.call_id in projection_by_call_id + ] - raw_payload = row["payload"] - if isinstance(raw_payload, dict): - payload: dict[str, object] = dict(raw_payload) - elif isinstance(raw_payload, str): - payload = json.loads(raw_payload) - else: - raise TypeError(f"Unexpected payload type: {type(raw_payload).__name__}") - raw_created_at = parse_db_ts(row["created_at"]) +async def _fetch_request_messages_by_call_id( + conn: Any, session_id: str, call_ids: Sequence[str], *, is_sqlite: bool +) -> dict[str, list[dict[str, Any]]]: + if not call_ids: + return {} + if is_sqlite: + placeholders = ", ".join(f"${index}" for index in range(2, len(call_ids) + 2)) + rows = await conn.fetch( + f""" + SELECT call_id, json_extract(payload, '$.final_request.messages') AS messages + FROM conversation_events + WHERE session_id = $1 AND call_id IN ({placeholders}) AND event_type = 'transaction.request_recorded' + ORDER BY call_id, created_at ASC + """, + session_id, + *call_ids, + ) + else: + rows = await conn.fetch( + """ + SELECT call_id, payload->'final_request'->'messages' AS messages + FROM conversation_events + WHERE session_id = $1 AND call_id = ANY($2) AND event_type = 'transaction.request_recorded' + ORDER BY call_id, created_at ASC + """, + session_id, + list(call_ids), + ) + messages_by_call_id: dict[str, list[dict[str, Any]]] = {} + for row in rows: + call_id = str(row["call_id"]) + messages_by_call_id.setdefault(call_id, _json_list(row["messages"])) + return messages_by_call_id + - calls[call_id].append( - StoredEvent( - event_type=str(row["event_type"]), - payload=payload, - created_at=raw_created_at, - ) +async def _fetch_call_ranges(session_id: str, db_pool: DatabasePool) -> list[CallEventRange]: + async with db_pool.connection() as conn: + rows = await conn.fetch( + """ + SELECT call_id, MIN(created_at) as first_ts, MAX(created_at) as last_ts + FROM conversation_events + WHERE session_id = $1 + GROUP BY call_id + ORDER BY MIN(created_at) ASC + """, + session_id, ) + if not rows: + raise ValueError(f"No events found for session_id: {session_id}") + return [ + CallEventRange( + call_id=str(row["call_id"]), + first_ts=parse_db_ts(row["first_ts"]), + last_ts=parse_db_ts(row["last_ts"]), + ) + for row in rows + ] - # Build conversation turns, sorted by first event timestamp - turns = [] - all_models = set() - total_interventions = 0 - # Sort call_ids by their first event timestamp to ensure chronological order - sorted_call_ids = sorted(calls.keys(), key=lambda cid: calls[cid][0]["created_at"]) - for call_id in sorted_call_ids: - turn = _build_turn(call_id, calls[call_id]) - turns.append(turn) +async def iter_session_turns( + session_id: str, + db_pool: DatabasePool, + ranges: list[CallEventRange] | None = None, + *, + initial_prev_real_msg_count: int = 0, + projection_offset: int | None = None, + projection_limit: int | None = None, +) -> AsyncIterator[ConversationTurn]: + """Yield conversation turns with request messages reduced to transcript deltas.""" + if ranges is None: + ranges = await _fetch_call_ranges(session_id, db_pool) + is_sqlite = db_pool.is_sqlite is True + async with db_pool.connection() as conn: + async with conn.transaction(): + projections = await _fetch_request_projections( + conn, + session_id, + ranges, + is_sqlite=is_sqlite, + offset=projection_offset, + limit=projection_limit, + ) + plan = _select_fast_path_anchor(projections) + if plan is not None and len(projections) == len(ranges): + messages_by_call_id = await _fetch_request_messages_by_call_id( + conn, + session_id, + [plan.anchor.call_id, *(projection.call_id for projection in plan.preflights)], + is_sqlite=is_sqlite, + ) + event_rows_by_call_id = await _fetch_non_request_event_rows( + conn, + session_id, + ranges, + is_sqlite=is_sqlite, + ) + events_by_call_id = { + call_id: _stored_events_from_rows(rows) for call_id, rows in event_rows_by_call_id.items() + } + async for turn in _iter_session_turns_fast( + projections, + events_by_call_id, + messages_by_call_id, + initial_prev_real_msg_count=initial_prev_real_msg_count, + ): + yield turn + return + + async for turn in _iter_session_turns_slow( + session_id, + db_pool, + ranges, + initial_prev_real_msg_count=initial_prev_real_msg_count, + ): + yield turn + + +async def _iter_session_turns_slow( + session_id: str, + db_pool: DatabasePool, + ranges: list[CallEventRange], + *, + initial_prev_real_msg_count: int = 0, +) -> AsyncIterator[ConversationTurn]: + delta_state = RequestDeltaState(prev_real_msg_count=initial_prev_real_msg_count) + async with db_pool.connection() as conn: + async with conn.transaction(): + for batch in _range_batches(ranges): + rows_by_call_id = await _fetch_turn_batch_rows( + conn, + session_id, + batch, + is_sqlite=db_pool.is_sqlite is True, + ) + for call_range in batch: + full_turn = _build_turn( + call_range.call_id, + _stored_events_from_rows(rows_by_call_id[call_range.call_id]), + ) + turn, delta_state = _apply_request_delta(full_turn, delta_state) + yield turn + + +async def _streaming_detail_stats( + session_id: str, db_pool: DatabasePool +) -> tuple[list[CallEventRange], int, list[str]]: + ranges = await _fetch_call_ranges(session_id, db_pool) + total_interventions = 0 + models: set[str] = set() + async for turn in iter_session_turns(session_id, db_pool, ranges): if turn.model: - all_models.add(turn.model) + models.add(turn.model) if turn.had_policy_intervention: total_interventions += len(turn.annotations) + return ranges, total_interventions, sorted(models) - first_ts_str = parse_db_ts(rows[0]["created_at"]).isoformat() - last_ts_str = parse_db_ts(rows[-1]["created_at"]).isoformat() - return SessionDetail( - session_id=session_id, - first_timestamp=first_ts_str, - last_timestamp=last_ts_str, - turns=turns, - total_policy_interventions=total_interventions, - models_used=sorted(all_models), - ) - - -_REQUEST_PARAM_ALLOWLIST = frozenset( - { - "model", - "max_tokens", - "stream", - "temperature", - "top_p", - "top_k", - "stop_sequences", - "output_config", - } +async def stream_session_detail_json( + session_id: str, + db_pool: DatabasePool, + *, + offset: int | None = None, + limit: int = _SESSION_DETAIL_DEFAULT_LIMIT, +) -> AsyncIterator[bytes]: + """Stream the session detail JSON response without materializing all turns.""" + window = await _fetch_session_turn_window(session_id, db_pool, offset=offset, limit=limit) + stats = await _fetch_session_detail_stats(session_id, db_pool) + # Detail/JSONL may truncate after a committed 200 if a per-call read fails; + # markdown computes header stats before yielding, so the same failure is pre-response. + yield b'{"session_id":"' + json.dumps(session_id).encode()[1:-1] + b'",' + yield b'"first_timestamp":"' + window.first_timestamp.isoformat().encode() + b'",' + yield b'"last_timestamp":"' + window.last_timestamp.isoformat().encode() + b'",' + yield b'"turns":[' + first_turn = True + async for turn in iter_session_turns( + session_id, + db_pool, + window.ranges, + initial_prev_real_msg_count=window.initial_prev_real_msg_count, + projection_offset=window.offset, + projection_limit=window.limit, + ): + if not first_turn: + yield b"," + first_turn = False + yield turn.model_dump_json().encode() + yield b'],"total_policy_interventions":' + str(stats.total_policy_interventions).encode() + b"," + yield b'"models_used":' + json.dumps(stats.models_used).encode() + b"," + yield b'"total_turns":' + str(window.total_turns).encode() + b"," + yield b'"offset":' + str(window.offset).encode() + b"," + yield b'"limit":' + str(window.limit).encode() + b"," + yield b'"has_more":' + (b"true" if window.offset > 0 else b"false") + b"}" + + +async def stream_session_markdown(session_id: str, db_pool: DatabasePool) -> AsyncIterator[str]: + """Stream the markdown export without materializing all turns.""" + ranges, total_interventions, models = await _streaming_detail_stats(session_id, db_pool) + yield f"# Conversation History: {session_id}\n" + yield "\n" + yield f"**Started:** {ranges[0].first_ts.isoformat()}\n" + yield f"**Ended:** {_last_timestamp_from_ranges(ranges).isoformat()}\n" + yield f"**Turns:** {len(ranges)}\n" + if models: + yield f"**Models:** {', '.join(models)}\n" + if total_interventions > 0: + yield f"**Policy Interventions:** {total_interventions}\n" + yield "\n---\n" + yield "\n" + turn_number = 1 + async for turn in iter_session_turns(session_id, db_pool, ranges): + yield f"## Turn {turn_number}\n" + if turn.model: + yield f"*Model: {turn.model}*\n" + yield "\n" + for msg in turn.request_messages: + yield _format_message_markdown(msg) + yield "\n\n" + for msg in turn.response_messages: + yield _format_message_markdown(msg) + yield "\n\n" + if turn.annotations: + yield "### Policy Annotations\n" + for ann in turn.annotations: + yield f"- **{ann.policy_name}**: {ann.summary}\n" + yield "\n" + yield "---\n" + if turn_number < len(ranges): + yield "\n" + turn_number += 1 + + +async def stream_session_jsonl(session_id: str, db_pool: DatabasePool) -> AsyncIterator[str]: + """Stream the JSONL export without materializing all turns.""" + ranges = await _fetch_call_ranges(session_id, db_pool) + async for turn in iter_session_turns(session_id, db_pool, ranges): + record: dict[str, object] = { + "call_id": turn.call_id, + "session_id": session_id, + "timestamp": turn.timestamp, + "model": turn.model, + "request_messages": [m.model_dump(mode="json") for m in turn.request_messages], + "response_messages": [m.model_dump(mode="json") for m in turn.response_messages], + "annotations": [a.model_dump(mode="json") for a in turn.annotations], + "had_policy_intervention": turn.had_policy_intervention, + "request_was_modified": turn.request_was_modified, + "response_was_modified": turn.response_was_modified, + } + if turn.original_request_messages is not None: + record["original_request_messages"] = [m.model_dump(mode="json") for m in turn.original_request_messages] + if turn.request_messages_full is not None: + record["request_messages_full"] = [m.model_dump(mode="json") for m in turn.request_messages_full] + if turn.original_response_messages is not None: + record["original_response_messages"] = [m.model_dump(mode="json") for m in turn.original_response_messages] + yield json.dumps(record, default=str) + "\n" + + +_REQUEST_PARAM_ALLOWLIST = ( + "model", + "max_tokens", + "stream", + "temperature", + "top_p", + "top_k", + "stop_sequences", + "output_config", ) +_REQUEST_PARAM_NORMALIZERS = { + "model": _raw_request_param, + "max_tokens": _raw_request_param, + "stream": _bool_request_param, + "temperature": _raw_request_param, + "top_p": _raw_request_param, + "top_k": _raw_request_param, + "stop_sequences": _json_list_request_param, + "output_config": _output_config_request_param, +} def _build_turn(call_id: str, events: list[StoredEvent]) -> ConversationTurn: @@ -1092,6 +2323,7 @@ def _build_turn(call_id: str, events: list[StoredEvent]) -> ConversationTurn: timestamp=timestamp, model=model, request_messages=request_messages, + request_messages_full=request_messages if request_was_modified else None, response_messages=response_messages, annotations=annotations, had_policy_intervention=had_intervention, @@ -1188,6 +2420,8 @@ def export_session_jsonl(session: SessionDetail) -> str: } if turn.original_request_messages is not None: record["original_request_messages"] = [m.model_dump(mode="json") for m in turn.original_request_messages] + if turn.request_messages_full is not None: + record["request_messages_full"] = [m.model_dump(mode="json") for m in turn.request_messages_full] if turn.original_response_messages is not None: record["original_response_messages"] = [m.model_dump(mode="json") for m in turn.original_response_messages] lines.append(json.dumps(record, default=str)) @@ -1229,6 +2463,10 @@ def _format_message_markdown(msg: ConversationMessage) -> str: "extract_text_content", "fetch_session_list", "fetch_session_detail", + "iter_session_turns", "export_session_markdown", "export_session_jsonl", + "stream_session_detail_json", + "stream_session_markdown", + "stream_session_jsonl", ] diff --git a/src/luthien_proxy/observability/session_summary.py b/src/luthien_proxy/observability/session_summary.py index d44a164b0..dab5c1bf1 100644 --- a/src/luthien_proxy/observability/session_summary.py +++ b/src/luthien_proxy/observability/session_summary.py @@ -18,6 +18,7 @@ from __future__ import annotations +import json import re from datetime import datetime from typing import Any @@ -25,8 +26,13 @@ from luthien_proxy.utils.db import ConnectionProtocol # Preview is the first user-message text from the request that opened the -# session. Trimmed to keep the row small and the list page snappy. -PREVIEW_MAX_LENGTH = 200 +# session. This is the SINGLE source of truth for the history list's +# ``preview_message`` (see ``history.service._extract_preview_message``, which +# aliases ``extract_preview``), so the value precomputed here and stored on +# ``session_summaries`` is byte-for-byte what the list endpoint would have +# derived from the raw payload. Truncated to keep the row small and the list +# page snappy; matches the list's historical 100-char preview length. +PREVIEW_MAX_LENGTH = 100 # Claude Code injects ... blocks into the # first user turn; strip them so the preview shows the actual user text. @@ -36,10 +42,16 @@ def _is_policy_event(event_type: str) -> bool: """True for policy-intervention events, excluding judge evaluations. - Mirrors the backfill predicate in migration 021 so the incremental counter - and the backfilled counter agree. + Mirrors ``history.service._INTERVENTION_PREDICATE`` exactly + (``event_type LIKE 'policy.%' AND event_type NOT LIKE 'policy.%judge.evaluation%'``) + so the incrementally-maintained ``policy_event_count`` equals the count the + list endpoint computes for ``policy_interventions``. The SQL ``%`` between + ``policy.`` and ``judge.evaluation`` matches any prefix, so production judge + events (``policy.anthropic_judge.evaluation_*``) are excluded -- a literal + ``startswith('policy.judge.evaluation')`` would NOT exclude them and would + over-count every policy-active session. """ - return event_type.startswith("policy.") and not event_type.startswith("policy.judge.evaluation") + return event_type.startswith("policy.") and "judge.evaluation" not in event_type def extract_model(data: dict[str, Any]) -> str | None: @@ -48,17 +60,76 @@ def extract_model(data: dict[str, Any]) -> str | None: return model if isinstance(model, str) and model else None -def extract_preview(data: dict[str, Any]) -> str | None: +def _safe_parse_json(s: str) -> dict[str, Any] | None: + """Parse a JSON string into a dict, returning None on failure.""" + try: + result = json.loads(s) + except (json.JSONDecodeError, TypeError): + return None + return result if isinstance(result, dict) else None + + +def _preview_text(content: object) -> str: + r"""Extract display text from a message ``content`` field, robustly. + + Mirrors ``history.service.extract_text_content`` for the realistic shapes a + first user message takes (a plain string, or a list of ``text`` / + ``tool_result`` blocks) but never raises on a malformed block -- preview + extraction runs on the event-write path, so a weird payload must not abort + the write. The block separator (``\n``) is irrelevant downstream because the + caller whitespace-collapses the result. + """ + if content is None: + return "" + if isinstance(content, str): + return content + if not isinstance(content, list): + return "" + parts: list[str] = [] + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "text": + text = block.get("text") + if isinstance(text, str): + parts.append(text) + elif btype == "tool_result": + result_content = block.get("content") + if result_content is not None: + parts.append(_preview_text(result_content)) + return "\n".join(parts) + + +def extract_preview(data: dict[str, Any] | str | None) -> str | None: """Extract a short preview from the first user message of a request payload. - Returns None for probe requests (``max_tokens <= 1``) and when no usable - user text is present. The text is whitespace-collapsed and has - ```` blocks stripped. When longer than - ``PREVIEW_MAX_LENGTH`` it is cut at that many characters and a literal - ``"..."`` ellipsis is appended (so the stored value can be up to - ``PREVIEW_MAX_LENGTH + 3`` characters). + SINGLE source of truth for the history list's ``preview_message``: the + incremental ``session_summaries`` write path, the one-time preview backfill, + and ``history.service._extract_preview_message`` (the live aggregation / + filtered-list path, which aliases this) all use it. The value stored on + ``session_summaries`` is therefore byte-for-byte what the list endpoint + would have derived from the raw payload. + + Reads from ``original_request`` FIRST so the preview reflects what the user + typed, not gateway-injected content (e.g. ```` from + ``inject_policy_awareness_anthropic``); falls back to ``final_request`` for + older payloads recorded before ``original_request`` was stored. Accepts a + dict (event payload) or a JSON string (asyncpg) or None. Returns None for + probe requests (``max_tokens <= 1``) and when no usable user text is + present. ```` blocks are stripped before the text is + whitespace-collapsed and, when longer than ``PREVIEW_MAX_LENGTH``, cut at + that many characters with a literal ``"..."`` appended. """ - request = data.get("final_request") or data.get("original_request") + if not data: + return None + if isinstance(data, str): + parsed = _safe_parse_json(data) + if not parsed: + return None + data = parsed + + request = data.get("original_request") or data.get("final_request") if not isinstance(request, dict): return None @@ -77,23 +148,16 @@ def extract_preview(data: dict[str, Any]) -> str | None: for msg in messages: if not isinstance(msg, dict) or msg.get("role") != "user": continue - content = msg.get("content") - if isinstance(content, list): - texts = [ - b["text"] - for b in content - if isinstance(b, dict) and b.get("type") == "text" and isinstance(b.get("text"), str) and b["text"] - ] - content = " ".join(texts) - if not isinstance(content, str): + content = _preview_text(msg.get("content")) + if not content: continue - text = _SYSTEM_REMINDER_RE.sub("", content).strip() - if not text: + content = _SYSTEM_REMINDER_RE.sub("", content).strip() + if not content: continue - text = " ".join(text.split()) - if len(text) > PREVIEW_MAX_LENGTH: - text = text[:PREVIEW_MAX_LENGTH] + "..." - return text + content = " ".join(content.split()) + if len(content) > PREVIEW_MAX_LENGTH: + content = content[:PREVIEW_MAX_LENGTH] + "..." + return content return None diff --git a/src/luthien_proxy/static/conversation_live.js b/src/luthien_proxy/static/conversation_live.js index 855e1e18e..b51df3346 100644 --- a/src/luthien_proxy/static/conversation_live.js +++ b/src/luthien_proxy/static/conversation_live.js @@ -31,6 +31,12 @@ function conversationViewer() { renderedCallIds: new Set(), turnFingerprints: {}, _rawTurns: [], + pageLimit: 50, + totalTurns: 0, + loadedOffset: 0, + loadedEnd: 0, + loadingOlder: false, + initialLoadComplete: false, init() { const pathParts = window.location.pathname.split('/'); @@ -99,14 +105,27 @@ function conversationViewer() { return; } }); + + window.addEventListener('scroll', () => { + if (this.initialLoadComplete && window.scrollY < 80) { + this.loadOlderTurns(); + } + }); + }, + + async fetchPage(offset = null, limit = this.pageLimit) { + const params = new URLSearchParams({ limit: String(limit) }); + if (offset !== null && offset !== undefined) params.set('offset', String(offset)); + const resp = await fetch( + `/api/history/sessions/${encodeURIComponent(this.conversationId)}?${params.toString()}`, + { headers: { 'Accept': 'application/json' } } + ); + return resp; }, async loadInitial() { try { - const resp = await fetch( - `/api/history/sessions/${encodeURIComponent(this.conversationId)}`, - { headers: { 'Accept': 'application/json' } } - ); + const resp = await this.fetchPage(null, this.pageLimit); if (!resp.ok) { if (resp.status === 403) { @@ -119,11 +138,18 @@ function conversationViewer() { } const data = await resp.json(); + this.pageLimit = data.limit || this.pageLimit; this.processTurns(data); this.updateStats(data); this.updateTimestamp(); this.renderTurns(); - this.$nextTick(() => this.autoScrollToBottom()); + this.$nextTick(() => { + // Instant jump to the newest turn. A smooth scroll animates + // scrollY up from 0 and the early frames (scrollY < 80) would + // spuriously fire the load-older listener even with the guard set. + window.scrollTo(0, document.documentElement.scrollHeight); + this.initialLoadComplete = true; + }); } catch (err) { this.showError(`Failed to load: ${err.message}`); } @@ -201,10 +227,7 @@ function conversationViewer() { async refreshTurns() { try { - const resp = await fetch( - `/api/history/sessions/${encodeURIComponent(this.conversationId)}`, - { headers: { 'Accept': 'application/json' } } - ); + const resp = await this.fetchPage(null, this.pageLimit); if (!resp.ok) return; const data = await resp.json(); const rawTurns = data.turns || []; @@ -212,8 +235,8 @@ function conversationViewer() { if (rawTurns.length !== newTurns.length) { console.error('presentTurns must map 1:1 with rawTurns'); } - this._rawTurns = rawTurns; - this.turns = newTurns; + this.totalTurns = data.total_turns || rawTurns.length; + this.pageLimit = data.limit || this.pageLimit; this.updateStats(data); this.updateTimestamp(); @@ -234,19 +257,28 @@ function conversationViewer() { for (let i = 0; i < newTurns.length; i++) { const turn = newTurns[i]; const fp = JSON.stringify(rawTurns[i]); + const globalIndex = (data.offset || 0) + i; + const localIndex = globalIndex - this.loadedOffset; if (this.renderedCallIds.has(turn.call_id)) { + if (localIndex >= 0 && localIndex < this.turns.length) { + this.turns[localIndex] = turn; + this._rawTurns[localIndex] = rawTurns[i]; + } if (fp !== this.turnFingerprints[turn.call_id]) { const existing = container.querySelector(`[data-call-id="${CSS.escape(turn.call_id)}"]`); if (existing) { - existing.outerHTML = this.renderTurn(turn, i + 1); + existing.outerHTML = this.renderTurn(turn, globalIndex + 1); } this.turnFingerprints[turn.call_id] = fp; } } else { - // New turn — append + if (globalIndex < this.loadedOffset) continue; this.renderedCallIds.add(turn.call_id); this.turnFingerprints[turn.call_id] = fp; - const html = this.renderTurn(turn, i + 1); + this._rawTurns.push(rawTurns[i]); + this.turns.push(turn); + this.loadedEnd = Math.max(this.loadedEnd, globalIndex + 1); + const html = this.renderTurn(turn, globalIndex + 1); container.insertAdjacentHTML('beforeend', html); const newEl = container.lastElementChild; if (newEl) newEl.classList.add('new-turn'); @@ -264,45 +296,48 @@ function conversationViewer() { const rawTurns = data.turns || []; this._rawTurns = rawTurns; this.turns = this.presentTurns(rawTurns); + this.totalTurns = data.total_turns || rawTurns.length; + this.loadedOffset = data.offset || 0; + this.loadedEnd = this.loadedOffset + rawTurns.length; }, - // Presentation pipeline: classify preflight turns and compute - // display messages (dedup) entirely on the client side. - // - // The API sends the full conversation history on every request: - // Turn 1: [user₀] - // Turn 2: [user₀, assistant₁, user₂] - // Turn 3: [user₀, assistant₁, user₂, tool_call₂, tool_result₂, user₃] - // - // user₀ (the initial message with all preamble) is re-sent identically - // every turn. New content appears at the end, after the previous turn's - // messages. So for turn N, display = request_messages.slice(prevCount). - // Preflight turns are excluded from the count so they don't disrupt the - // sequence. - // - // Invariant: the API sends a stable, strictly-growing cumulative - // message array. If a policy rewrites or reorders earlier messages, - // the slicing will produce incorrect results. - presentTurns(rawTurns) { - let prevRealMsgCount = 0; + async loadOlderTurns() { + if (this.loadingOlder || this.loadedOffset <= 0) return; + this.loadingOlder = true; + const oldHeight = document.documentElement.scrollHeight; + try { + const nextOffset = Math.max(0, this.loadedOffset - this.pageLimit); + const nextLimit = this.loadedOffset - nextOffset; + const resp = await this.fetchPage(nextOffset, nextLimit); + if (!resp.ok) return; + const data = await resp.json(); + const rawTurns = data.turns || []; + const olderTurns = this.presentTurns(rawTurns); + this._rawTurns = [...rawTurns, ...this._rawTurns]; + this.turns = [...olderTurns, ...this.turns]; + this.loadedOffset = data.offset || nextOffset; + this.totalTurns = data.total_turns || this.totalTurns; + this.updateStats(data); + this.renderTurns(); + this.$nextTick(() => { + window.scrollBy(0, document.documentElement.scrollHeight - oldHeight); + }); + } catch (err) { + console.error('Failed to load older turns:', err); + } finally { + this.loadingOlder = false; + } + }, + // Presentation pipeline: classify preflight turns and use the server's + // transcript delta directly. The server applies the same running-count + // semantics this client used to apply: real turns advance the count; + // preflight turns render in full and do not advance the count. + presentTurns(rawTurns) { return rawTurns.map(turn => { const isPreflight = this.classifyPreflight(turn); const messages = turn.request_messages || []; - - let displayMessages; - if (isPreflight) { - displayMessages = messages; - } else { - displayMessages = messages.slice(prevRealMsgCount); - if (displayMessages.length === 0 && messages.length > 0) { - console.warn('Dedup produced empty messages for turn', turn.call_id, - '— cumulative array invariant may be violated'); - } - prevRealMsgCount = messages.length; - } - - return { ...turn, _isPreflight: isPreflight, _displayMessages: displayMessages }; + return { ...turn, _isPreflight: isPreflight, _displayMessages: messages }; }); }, @@ -323,8 +358,7 @@ function conversationViewer() { }, updateStats(data) { - const realTurns = this.turns.filter(t => !t._isPreflight); - this.stats.turns = realTurns.length; + this.stats.turns = data.total_turns || this.totalTurns || this.turns.filter(t => !t._isPreflight).length; this.stats.interventions = data.total_policy_interventions || 0; this.stats.models = [...new Set(data.models_used || [])]; this.stats.events = Object.values(this.rawEvents).reduce((sum, events) => sum + events.length, 0); @@ -431,7 +465,7 @@ function conversationViewer() { } const savedState = this.snapshotExpandState(); - container.innerHTML = this.turns.map((turn, i) => this.renderTurn(turn, i + 1)).join(''); + container.innerHTML = this.turns.map((turn, i) => this.renderTurn(turn, this.loadedOffset + i + 1)).join(''); this.restoreExpandState(savedState); }, @@ -730,7 +764,7 @@ function conversationViewer() { requestDiffHtml = this.renderDiffPanels( 'Request', turn.original_request_messages, - turn.request_messages + turn.request_messages_full || turn.request_messages ); } diff --git a/tests/luthien_proxy/unit_tests/history/test_routes.py b/tests/luthien_proxy/unit_tests/history/test_routes.py index 94b2902bc..779e8665d 100644 --- a/tests/luthien_proxy/unit_tests/history/test_routes.py +++ b/tests/luthien_proxy/unit_tests/history/test_routes.py @@ -6,10 +6,11 @@ - Return correct response models """ +import json from unittest.mock import AsyncMock, MagicMock, patch import pytest -from fastapi import HTTPException +from fastapi import FastAPI, HTTPException from luthien_proxy.history.models import ( ConversationMessage, @@ -21,6 +22,7 @@ SessionSummary, ) from luthien_proxy.history.routes import ( + api_router, export_session, get_session, list_sessions, @@ -29,6 +31,22 @@ AUTH_TOKEN = "test-admin-key" +async def _collect_streaming_body(response) -> bytes: + parts: list[bytes] = [] + async for chunk in response.body_iterator: + parts.append(chunk if isinstance(chunk, bytes) else chunk.encode()) + return b"".join(parts) + + +async def _single_chunk_stream(chunk: str | bytes): + yield chunk + + +async def _raising_stream(error: Exception): + raise error + yield b"" + + class TestListSessionsRoute: """Test list_sessions route handler.""" @@ -204,6 +222,15 @@ async def test_list_sessions_empty(self): class TestGetSessionRoute: """Test get_session route handler.""" + def test_openapi_documents_session_detail_schema(self): + """Session detail streaming route keeps its documented response schema.""" + app = FastAPI() + app.include_router(api_router) + + schema = app.openapi()["paths"]["/api/history/sessions/{session_id}"]["get"]["responses"]["200"] + + assert schema["content"]["application/json"]["schema"] == {"$ref": "#/components/schemas/SessionDetail"} + @pytest.mark.asyncio async def test_successful_get_session(self): """Test successful session detail returns response.""" @@ -225,19 +252,23 @@ async def test_successful_get_session(self): ], total_policy_interventions=0, models_used=["gpt-4"], + total_turns=1, + offset=0, + limit=50, + has_more=False, ) with patch( - "luthien_proxy.history.routes.fetch_session_detail", - new_callable=AsyncMock, - return_value=expected_detail, - ) as mock_fetch: - result = await get_session(session_id="test-session", _=AUTH_TOKEN, db_pool=mock_db_pool) + "luthien_proxy.history.routes.stream_session_detail_json", + return_value=_single_chunk_stream(expected_detail.model_dump_json()), + ) as mock_stream: + result = await get_session( + session_id="test-session", offset=10, limit=25, _=AUTH_TOKEN, db_pool=mock_db_pool + ) - assert isinstance(result, SessionDetail) - assert result.session_id == "test-session" - assert len(result.turns) == 1 - mock_fetch.assert_called_once_with("test-session", mock_db_pool) + body = await _collect_streaming_body(result) + assert json.loads(body) == expected_detail.model_dump(mode="json") + mock_stream.assert_called_once_with("test-session", mock_db_pool, offset=10, limit=25) @pytest.mark.asyncio async def test_get_session_not_found(self): @@ -245,12 +276,11 @@ async def test_get_session_not_found(self): mock_db_pool = MagicMock() with patch( - "luthien_proxy.history.routes.fetch_session_detail", - new_callable=AsyncMock, - side_effect=ValueError("No events found for session_id: nonexistent"), + "luthien_proxy.history.routes.stream_session_detail_json", + return_value=_raising_stream(ValueError("No events found for session_id: nonexistent")), ): with pytest.raises(HTTPException) as exc_info: - await get_session(session_id="nonexistent", _=AUTH_TOKEN, db_pool=mock_db_pool) + await get_session(session_id="nonexistent", offset=None, limit=50, _=AUTH_TOKEN, db_pool=mock_db_pool) assert exc_info.value.status_code == 404 assert exc_info.value.detail == "Session not found." @@ -263,34 +293,16 @@ class TestExportSessionRoute: async def test_successful_export(self): """Test successful export returns markdown.""" mock_db_pool = MagicMock() - session_detail = SessionDetail( - session_id="test-session", - first_timestamp="2025-01-15T10:00:00", - last_timestamp="2025-01-15T11:00:00", - turns=[ - ConversationTurn( - call_id="call-1", - timestamp="2025-01-15T10:00:00", - model="gpt-4", - request_messages=[ConversationMessage(message_type=MessageType.USER, content="Hello")], - response_messages=[ConversationMessage(message_type=MessageType.ASSISTANT, content="Hi!")], - annotations=[], - had_policy_intervention=False, - ) - ], - total_policy_interventions=0, - models_used=["gpt-4"], - ) with patch( - "luthien_proxy.history.routes.fetch_session_detail", - new_callable=AsyncMock, - return_value=session_detail, + "luthien_proxy.history.routes.stream_session_markdown", + return_value=_single_chunk_stream("# Conversation History: test-session"), ): result = await export_session(session_id="test-session", _=AUTH_TOKEN, db_pool=mock_db_pool) assert result.media_type == "text/markdown" - assert "# Conversation History: test-session" in result.body.decode() + body = await _collect_streaming_body(result) + assert "# Conversation History: test-session" in body.decode() assert "Content-Disposition" in result.headers assert 'filename="conversation_test-session.md"' in result.headers["Content-Disposition"] @@ -300,9 +312,8 @@ async def test_export_not_found(self): mock_db_pool = MagicMock() with patch( - "luthien_proxy.history.routes.fetch_session_detail", - new_callable=AsyncMock, - side_effect=ValueError("No events found for session_id: nonexistent"), + "luthien_proxy.history.routes.stream_session_markdown", + return_value=_raising_stream(ValueError("No events found for session_id: nonexistent")), ): with pytest.raises(HTTPException) as exc_info: await export_session(session_id="nonexistent", _=AUTH_TOKEN, db_pool=mock_db_pool) @@ -314,19 +325,10 @@ async def test_export_not_found(self): async def test_export_filename_sanitization(self): """Test that session IDs with special characters are sanitized in filename.""" mock_db_pool = MagicMock() - session_detail = SessionDetail( - session_id="test", - first_timestamp="2025-01-15T10:00:00", - last_timestamp="2025-01-15T11:00:00", - turns=[], - total_policy_interventions=0, - models_used=[], - ) with patch( - "luthien_proxy.history.routes.fetch_session_detail", - new_callable=AsyncMock, - return_value=session_detail, + "luthien_proxy.history.routes.stream_session_markdown", + return_value=_single_chunk_stream(""), ): result = await export_session( session_id="test", diff --git a/tests/luthien_proxy/unit_tests/history/test_service.py b/tests/luthien_proxy/unit_tests/history/test_service.py index de7d489d5..92d571af7 100644 --- a/tests/luthien_proxy/unit_tests/history/test_service.py +++ b/tests/luthien_proxy/unit_tests/history/test_service.py @@ -4,21 +4,30 @@ parsing conversation turns, and exporting to markdown. """ +from __future__ import annotations + import json -from datetime import datetime +from collections.abc import AsyncIterator +from datetime import datetime, timedelta +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from tests.constants import DEFAULT_TEST_MODEL +from luthien_proxy.history import service from luthien_proxy.history.models import ( ConversationMessage, ConversationTurn, MessageType, PolicyAnnotation, SessionDetail, + SessionSearchParams, ) from luthien_proxy.history.service import ( + CallEventRange, + RequestProjection, + StoredEvent, _build_turn, _extract_preview_message, _extract_tool_calls, @@ -31,7 +40,1486 @@ extract_text_content, fetch_session_detail, fetch_session_list, + iter_session_turns, + stream_session_detail_json, ) +from luthien_proxy.utils.db import DatabasePool, parse_db_ts +from luthien_proxy.utils.db_sqlite import SqliteConnection + + +async def _collect_bytes(chunks) -> bytes: + parts: list[bytes] = [] + async for chunk in chunks: + if isinstance(chunk, str): + parts.append(chunk.encode()) + else: + parts.append(chunk) + return b"".join(parts) + + +def _enable_mock_transaction(mock_conn: AsyncMock) -> None: + mock_conn.transaction = MagicMock() + mock_conn.transaction.return_value.__aenter__ = AsyncMock(return_value=None) + mock_conn.transaction.return_value.__aexit__ = AsyncMock(return_value=None) + + +@pytest.fixture +async def sqlite_pool() -> AsyncIterator[DatabasePool]: + pool = DatabasePool("sqlite://:memory:") + migrations_dir = Path(__file__).parent.parent.parent.parent.parent / "migrations" / "sqlite" + + async with pool.connection() as conn: + assert isinstance(conn, SqliteConnection) + for migration_file in sorted(migrations_dir.glob("*.sql")): + await conn.executescript(migration_file.read_text()) + + yield pool + + await pool.close() + + +def _equivalence_rows() -> list[dict[str, object]]: + return [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": { + "final_model": "gpt-4", + "original_request": {"messages": [{"role": "user", "content": "Hi"}]}, + "final_request": {"messages": [{"role": "user", "content": "Hi"}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 0, 0), + }, + { + "call_id": "call-1", + "event_type": "transaction.streaming_response_recorded", + "payload": { + "original_response": {"choices": [{"message": {"content": "Hello!"}}]}, + "final_response": {"choices": [{"message": {"content": "Hello!"}}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 0, 1), + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_model": "claude-3-sonnet", + "original_request": {"messages": [{"role": "user", "content": "Hi"}]}, + "final_request": { + "messages": [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "Use the tool"}, + ] + }, + }, + "created_at": datetime(2025, 1, 15, 10, 2, 0), + }, + { + "call_id": "call-2", + "event_type": "policy.anthropic_judge.tool_call_blocked", + "payload": {"summary": "Dangerous operation blocked", "rule": "deny"}, + "created_at": datetime(2025, 1, 15, 10, 2, 1), + }, + { + "call_id": "call-2", + "event_type": "transaction.non_streaming_response_recorded", + "payload": { + "original_response": {"choices": [{"message": {"content": "Done"}}]}, + "final_response": {"choices": [{"message": {"content": "Blocked"}}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 2, 2), + }, + ] + + +def _projection( + call_id: str, + raw_msg_count: int, + created_at: datetime, + *, + max_tokens: int | None = None, + output_config: dict[str, object] | None = None, + request_was_modified: bool = False, + tools_count: int | None = None, +) -> RequestProjection: + return service.RequestProjection( + call_id=call_id, + first_ts=created_at, + request_ts=created_at, + final_model=DEFAULT_TEST_MODEL, + request_params={ + "model": DEFAULT_TEST_MODEL, + "max_tokens": max_tokens, + "output_config": output_config, + "tools_count": tools_count, + }, + raw_msg_count=raw_msg_count, + request_was_modified=request_was_modified, + ) + + +def _raw_messages(contents: list[str]) -> list[dict[str, object]]: + return [{"role": "user", "content": content} for content in contents] + + +def _request_event(call_id: str, created_at: datetime, messages: list[dict[str, object]]) -> StoredEvent: + return StoredEvent( + event_type="transaction.request_recorded", + payload={ + "final_model": DEFAULT_TEST_MODEL, + "final_request": {"model": DEFAULT_TEST_MODEL, "messages": messages}, + }, + created_at=created_at, + ) + + +def _response_event(content: str, created_at: datetime) -> StoredEvent: + return StoredEvent( + event_type="transaction.non_streaming_response_recorded", + payload={"final_response": {"choices": [{"message": {"content": content}}]}}, + created_at=created_at, + ) + + +def _window_rows(call_count: int) -> list[dict[str, object]]: + rows: list[dict[str, object]] = [] + messages: list[dict[str, object]] = [] + base_time = datetime(2025, 1, 15, 10, 0, 0) + for index in range(call_count): + call_id = f"window-call-{index + 1}" + messages = [*messages, {"role": "user", "content": f"message {index + 1}"}] + request_time = base_time + timedelta(minutes=index) + rows.append( + { + "call_id": call_id, + "event_type": "transaction.request_recorded", + "payload": { + "final_model": DEFAULT_TEST_MODEL, + "final_request": { + "model": DEFAULT_TEST_MODEL, + "messages": messages, + "stream": True, + }, + }, + "created_at": request_time, + } + ) + rows.append( + { + "call_id": call_id, + "event_type": "transaction.non_streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": f"response {index + 1}"}}]}}, + "created_at": request_time + timedelta(seconds=1), + } + ) + return rows + + +def _range_rows_from_event_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]: + grouped: dict[str, list[dict[str, object]]] = {} + for row in rows: + grouped.setdefault(str(row["call_id"]), []).append(row) + return [ + { + "call_id": call_id, + "first_ts": min(parse_db_ts(row["created_at"]) for row in call_rows), + "last_ts": max(parse_db_ts(row["created_at"]) for row in call_rows), + } + for call_id, call_rows in grouped.items() + ] + + +def _projection_rows_from_event_rows(rows: list[dict[str, object]]) -> list[dict[str, object]]: + first_ts_by_call_id: dict[str, datetime] = {} + for range_row in _range_rows_from_event_rows(rows): + first_ts_by_call_id[str(range_row["call_id"])] = parse_db_ts(range_row["first_ts"]) + projection_rows: list[dict[str, object]] = [] + for row in rows: + if row["event_type"] != "transaction.request_recorded": + continue + payload = row["payload"] + if not isinstance(payload, dict): + continue + final_request = payload.get("final_request") + original_request = payload.get("original_request") + if not isinstance(final_request, dict): + continue + projection_rows.append( + { + "call_id": row["call_id"], + "first_ts": first_ts_by_call_id[str(row["call_id"])], + "request_ts": row["created_at"], + "final_model": payload.get("final_model"), + "model": final_request.get("model"), + "max_tokens": final_request.get("max_tokens"), + "stream": final_request.get("stream"), + "temperature": final_request.get("temperature"), + "top_p": final_request.get("top_p"), + "top_k": final_request.get("top_k"), + "stop_sequences": final_request.get("stop_sequences"), + "output_config": final_request.get("output_config"), + "tools_count": len(final_request["tools"]) if isinstance(final_request.get("tools"), list) else None, + "raw_msg_count": len(final_request["messages"]) + if isinstance(final_request.get("messages"), list) + else 0, + "request_was_modified": original_request is not None and original_request != final_request, + } + ) + return projection_rows + + +def _message_rows_from_event_rows(rows: list[dict[str, object]], call_ids: list[str]) -> list[dict[str, object]]: + output: list[dict[str, object]] = [] + for row in rows: + if row["event_type"] != "transaction.request_recorded" or row["call_id"] not in call_ids: + continue + payload = row["payload"] + if not isinstance(payload, dict): + continue + final_request = payload.get("final_request") + if isinstance(final_request, dict): + output.append({"call_id": row["call_id"], "messages": final_request.get("messages", [])}) + return output + + +def _summary_row_from_event_rows(rows: list[dict[str, object]]) -> dict[str, object] | None: + if not rows: + return None + return { + "first_ts": min(parse_db_ts(row["created_at"]) for row in rows), + "last_ts": max(parse_db_ts(row["created_at"]) for row in rows), + "total_turns": len( + {str(row["call_id"]) for row in rows if row["event_type"] == "transaction.request_recorded"} + ), + } + + +def _session_stats_row_from_event_rows(rows: list[dict[str, object]]) -> dict[str, object]: + models: set[str] = set() + policy_interventions = 0 + for row in rows: + event_type = str(row["event_type"]) + if event_type.startswith("policy.") and "judge.evaluation" not in event_type: + policy_interventions += 1 + if event_type != "transaction.request_recorded": + continue + payload = row["payload"] + if not isinstance(payload, dict): + continue + model = payload.get("final_model") + if model is not None: + models.add(str(model)) + return {"policy_interventions": policy_interventions, "models_used": ",".join(sorted(models))} + + +def _request_window_rows_from_event_rows( + rows: list[dict[str, object]], limit: int, offset: int +) -> list[dict[str, object]]: + request_rows = [row for row in rows if row["event_type"] == "transaction.request_recorded"] + return [ + {"call_id": row["call_id"], "request_ts": row["created_at"]} + for row in sorted(request_rows, key=lambda row: parse_db_ts(row["created_at"]))[offset : offset + limit] + ] + + +def _previous_raw_msg_count_from_event_rows( + rows: list[dict[str, object]], before_ts: object +) -> dict[str, object] | None: + previous_rows = [] + for row in rows: + if row["event_type"] != "transaction.request_recorded": + continue + payload = row["payload"] + if not isinstance(payload, dict): + continue + final_request = payload.get("final_request") + if not isinstance(final_request, dict): + continue + max_tokens = final_request.get("max_tokens") + output_config = final_request.get("output_config") + output_format = output_config.get("format") if isinstance(output_config, dict) else None + if max_tokens == 1 or ( + isinstance(output_format, dict) and output_format.get("type") == "json_schema" and max_tokens <= 256 + ): + continue + if parse_db_ts(row["created_at"]) < parse_db_ts(before_ts): + previous_rows.append(row) + if not previous_rows: + return None + previous_row = max(previous_rows, key=lambda row: parse_db_ts(row["created_at"])) + payload = previous_row["payload"] + if not isinstance(payload, dict): + return None + final_request = payload.get("final_request") + if not isinstance(final_request, dict): + return None + messages = final_request.get("messages") + if not isinstance(messages, list): + return {"raw_msg_count": 0} + return {"raw_msg_count": len(messages)} + + +def _fetchrow_from_event_rows(rows: list[dict[str, object]]): + def fetch_row(query: str, _session_id: str, *args: object): + lowered = query.lower() + if "from session_summaries" in lowered: + return None + if "count(distinct case" in lowered: + return _summary_row_from_event_rows(rows) + if "policy_interventions" in lowered and "models_used" in lowered: + return _session_stats_row_from_event_rows(rows) + if "raw_msg_count" in lowered: + return _previous_raw_msg_count_from_event_rows(rows, args[0]) + return None + + return fetch_row + + +def _fetch_from_event_rows(rows: list[dict[str, object]]): + def fetch_rows(query: str, _session_id: str, *args: object): + lowered = query.lower() + if "raw_msg_count" in lowered: + projection_rows = _projection_rows_from_event_rows(rows) + if len(args) == 2 and all(isinstance(arg, int) for arg in args): + limit = int(args[0]) + offset = int(args[1]) + return sorted(projection_rows, key=lambda row: parse_db_ts(row["request_ts"]))[offset : offset + limit] + return projection_rows + if "created_at as request_ts" in lowered: + limit = int(args[0]) + offset = int(args[1]) + return _request_window_rows_from_event_rows(rows, limit, offset) + if "group by call_id" in lowered: + return _range_rows_from_event_rows(rows) + call_ids = args[0] if len(args) == 1 and isinstance(args[0], list) else list(args) + if " as messages" in lowered: + return _message_rows_from_event_rows(rows, [str(call_id) for call_id in call_ids]) + if "event_type <> 'transaction.request_recorded'" in lowered: + return [ + row + for row in rows + if row["event_type"] != "transaction.request_recorded" and row["call_id"] in call_ids + ] + return [row for row in rows if row["call_id"] in call_ids] + + return fetch_rows + + +def _stub_event_rows(mock_conn: AsyncMock, rows: list[dict[str, object]]) -> None: + mock_conn.fetch.side_effect = _fetch_from_event_rows(rows) + mock_conn.fetchrow.side_effect = _fetchrow_from_event_rows(rows) + + +def _assert_turns_field_equal(actual: list[ConversationTurn], expected: list[ConversationTurn]) -> None: + assert len(actual) == len(expected) + for actual_turn, expected_turn in zip(actual, expected, strict=True): + assert actual_turn.model_dump_json() == expected_turn.model_dump_json() + + +def _representative_fast_path_rows() -> list[dict[str, object]]: + def request_payload( + messages: list[dict[str, object]], + *, + max_tokens: int = 1024, + output_config: dict[str, object] | None = None, + stop_sequences: list[str] | None = None, + ) -> dict[str, object]: + final_request: dict[str, object] = { + "model": DEFAULT_TEST_MODEL, + "max_tokens": max_tokens, + "stream": True, + "temperature": 0.2, + "top_p": 0.9, + "top_k": 40, + "messages": messages, + "tools": [{"name": "lookup"}, {"name": "calendar"}], + } + if output_config is not None: + final_request["output_config"] = output_config + if stop_sequences is not None: + final_request["stop_sequences"] = stop_sequences + return {"final_model": DEFAULT_TEST_MODEL, "final_request": final_request, "original_request": final_request} + + cumulative_messages = [ + {"role": "user", "content": "first"}, + { + "role": "assistant", + "content": [ + {"type": "text", "text": "need lookup"}, + {"type": "tool_use", "id": "tool-1", "name": "lookup", "input": {"city": "Oslo"}}, + ], + }, + { + "role": "user", + "content": [ + {"type": "tool_result", "tool_use_id": "tool-1", "content": "snow"}, + {"type": "text", "text": "thanks"}, + ], + }, + {"role": "user", "content": "second"}, + ] + malformed_output_config = {"format": "json_schema"} + return [ + { + "call_id": "call-1", + "event_type": "policy.string_replacement.request_modified", + "payload": {"summary": "request warning", "rule": "context"}, + "created_at": datetime(2025, 1, 15, 9, 59, 59), + }, + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": request_payload( + [cumulative_messages[0]], + output_config=malformed_output_config, + stop_sequences=["END"], + ), + "created_at": datetime(2025, 1, 15, 10, 0, 0), + }, + { + "call_id": "call-1", + "event_type": "transaction.non_streaming_response_recorded", + "payload": { + "original_response": {"choices": [{"message": {"content": "blocked"}}]}, + "final_response": {"choices": [{"message": {"content": "allowed"}}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 0, 1), + }, + { + "call_id": "preflight-mid", + "event_type": "transaction.request_recorded", + "payload": request_payload([{"role": "user", "content": "quota"}], max_tokens=1), + "created_at": datetime(2025, 1, 15, 10, 0, 2), + }, + { + "call_id": "preflight-mid", + "event_type": "transaction.non_streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "ok"}}]}}, + "created_at": datetime(2025, 1, 15, 10, 0, 3), + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": request_payload(cumulative_messages), + "created_at": datetime(2025, 1, 15, 10, 0, 4), + }, + { + "call_id": "call-2", + "event_type": "policy.judge.tool_call_blocked", + "payload": {"summary": "blocked tool", "tool": "lookup"}, + "created_at": datetime(2025, 1, 15, 10, 0, 5), + }, + { + "call_id": "call-2", + "event_type": "transaction.streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "done"}}]}}, + "created_at": datetime(2025, 1, 15, 10, 0, 6), + }, + { + "call_id": "preflight-last", + "event_type": "transaction.request_recorded", + "payload": request_payload( + [{"role": "user", "content": "schema probe"}], + max_tokens=128, + output_config={"format": {"type": "json_schema", "schema": {"private": True}}}, + ), + "created_at": datetime(2025, 1, 15, 10, 0, 7), + }, + ] + + +async def _insert_sqlite_call(conn: SqliteConnection, call_id: str, session_id: str, created_at: str) -> None: + await conn.execute( + "INSERT INTO conversation_calls (call_id, model_name, provider, status, session_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + call_id, + DEFAULT_TEST_MODEL, + "openai", + "completed", + session_id, + created_at, + ) + + +async def _insert_sqlite_event_rows(conn: SqliteConnection, session_id: str, rows: list[dict[str, object]]) -> None: + inserted_call_ids: set[str] = set() + for index, row in enumerate(rows): + call_id = str(row["call_id"]) + created_at = parse_db_ts(row["created_at"]).isoformat() + if call_id not in inserted_call_ids: + await _insert_sqlite_call(conn, call_id, session_id, created_at) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"{session_id}-{index}-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + session_id, + created_at, + ) + + +class TestReadFinalOnceFastPath: + @pytest.mark.asyncio + async def test_fast_path_reconstructs_real_turn_deltas_from_one_anchor_and_preflights(self): + created = [datetime(2025, 1, 15, 10, index, 0) for index in range(4)] + projections = [ + _projection("call-1", 1, created[0]), + _projection("preflight", 1, created[1], max_tokens=1), + _projection("call-2", 3, created[2]), + _projection("call-3", 5, created[3]), + ] + events_by_call_id = { + "call-1": [_response_event("r1", created[0])], + "preflight": [_response_event("probe", created[1])], + "call-2": [_response_event("r2", created[2])], + "call-3": [_response_event("r3", created[3])], + } + messages_by_call_id = { + "call-3": _raw_messages(["one", "assistant one", "two", "assistant two", "three"]), + "preflight": _raw_messages(["quota probe"]), + } + + turns = [ + turn + async for turn in service._iter_session_turns_fast( + projections, + events_by_call_id, + messages_by_call_id, + ) + ] + + assert [[message.content for message in turn.request_messages] for turn in turns] == [ + ["one"], + ["quota probe"], + ["assistant one", "two"], + ["assistant two", "three"], + ] + assert [turn.request_messages_full for turn in turns] == [None, None, None, None] + assert [turn.response_messages[0].content for turn in turns] == ["r1", "probe", "r2", "r3"] + + def test_fast_anchor_uses_largest_real_turn_not_last_preflight(self): + created = [datetime(2025, 1, 15, 10, index, 0) for index in range(3)] + projections = [ + _projection("call-1", 1, created[0]), + _projection("call-2", 5, created[1]), + _projection("title-gen", 2, created[2], max_tokens=128, output_config={"format": {"type": "json_schema"}}), + ] + + plan = service._select_fast_path_anchor(projections) + + assert plan is not None + assert plan.anchor.call_id == "call-2" + assert [projection.call_id for projection in plan.preflights] == ["title-gen"] + + def test_fast_anchor_rejects_request_modified_real_turn(self): + created = datetime(2025, 1, 15, 10, 0, 0) + projections = [ + _projection("call-1", 1, created), + _projection("call-2", 3, created.replace(minute=1), request_was_modified=True), + ] + + assert service._select_fast_path_anchor(projections) is None + + def test_fast_anchor_rejects_non_monotonic_real_message_counts(self): + created = datetime(2025, 1, 15, 10, 0, 0) + projections = [ + _projection("call-1", 5, created), + _projection("call-2", 3, created.replace(minute=1)), + ] + + assert service._select_fast_path_anchor(projections) is None + + @pytest.mark.asyncio + async def test_fast_path_uses_raw_to_parsed_prefix_boundaries_for_tool_expansion(self): + created = [datetime(2025, 1, 15, 10, index, 0) for index in range(2)] + projections = [_projection("call-1", 1, created[0]), _projection("call-2", 3, created[1])] + events_by_call_id = { + "call-1": [_response_event("r1", created[0])], + "call-2": [_response_event("r2", created[1])], + } + anchor_messages: list[dict[str, object]] = [ + {"role": "user", "content": "weather"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "tool-1", + "type": "function", + "function": {"name": "get_weather", "arguments": '{"city":"Oslo"}'}, + } + ], + }, + {"role": "tool", "tool_call_id": "tool-1", "content": "snow"}, + ] + + turns = [ + turn + async for turn in service._iter_session_turns_fast( + projections, + events_by_call_id, + {"call-2": anchor_messages}, + ) + ] + + assert [message.content for message in turns[0].request_messages] == ["weather"] + assert [message.message_type for message in turns[1].request_messages] == [ + MessageType.TOOL_CALL, + MessageType.TOOL_RESULT, + ] + assert [message.content for message in turns[1].request_messages] == ['{"city":"Oslo"}', "snow"] + + @pytest.mark.asyncio + async def test_fast_path_preserves_build_turn_request_params_shape(self): + created = datetime(2025, 1, 15, 10, 0, 0) + projection = _projection( + "call-1", + 1, + created, + max_tokens=1024, + output_config={"format": {"type": "json_schema"}}, + tools_count=2, + ) + + turns = [ + turn + async for turn in service._iter_session_turns_fast( + [projection], + {"call-1": [_response_event("ok", created)]}, + {"call-1": _raw_messages(["one"])}, + ) + ] + + assert turns[0].request_params == { + "model": DEFAULT_TEST_MODEL, + "max_tokens": 1024, + "output_config": {"format": {"type": "json_schema"}}, + "tools_count": 2, + } + + @pytest.mark.asyncio + async def test_iter_session_turns_fast_path_fetches_only_one_large_messages_array(self, monkeypatch): + created = [datetime(2025, 1, 15, 10, index, 0) for index in range(3)] + ranges = [CallEventRange(f"call-{index + 1}", created[index], created[index]) for index in range(3)] + projection_rows = [ + { + "call_id": "call-1", + "first_ts": created[0], + "request_ts": created[0], + "final_model": DEFAULT_TEST_MODEL, + "model": DEFAULT_TEST_MODEL, + "max_tokens": None, + "stream": None, + "temperature": None, + "top_p": None, + "top_k": None, + "stop_sequences": None, + "output_config": None, + "tools_count": None, + "raw_msg_count": 1, + "request_was_modified": False, + }, + { + "call_id": "call-2", + "first_ts": created[1], + "request_ts": created[1], + "final_model": DEFAULT_TEST_MODEL, + "model": DEFAULT_TEST_MODEL, + "max_tokens": None, + "stream": None, + "temperature": None, + "top_p": None, + "top_k": None, + "stop_sequences": None, + "output_config": None, + "tools_count": None, + "raw_msg_count": 3, + "request_was_modified": False, + }, + { + "call_id": "call-3", + "first_ts": created[2], + "request_ts": created[2], + "final_model": DEFAULT_TEST_MODEL, + "model": DEFAULT_TEST_MODEL, + "max_tokens": None, + "stream": None, + "temperature": None, + "top_p": None, + "top_k": None, + "stop_sequences": None, + "output_config": None, + "tools_count": None, + "raw_msg_count": 5, + "request_was_modified": False, + }, + ] + payload_rows = { + "call-1": [ + { + "call_id": "call-1", + "event_type": "transaction.non_streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "r1"}}]}}, + "created_at": created[0], + } + ], + "call-2": [ + { + "call_id": "call-2", + "event_type": "transaction.non_streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "r2"}}]}}, + "created_at": created[1], + } + ], + "call-3": [ + { + "call_id": "call-3", + "event_type": "transaction.non_streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "r3"}}]}}, + "created_at": created[2], + } + ], + } + messages_rows = [ + { + "call_id": "call-3", + "messages": json.dumps(_raw_messages(["one", "two", "three", "four", "five"])), + } + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + + def fetch_side_effect(query: str, _session_id: str, *args: object): + lowered = query.lower() + if "raw_msg_count" in lowered: + return projection_rows + if " as messages" in lowered: + return messages_rows + call_ids = args[0] if len(args) == 1 and isinstance(args[0], list) else list(args) + return [row for call_id in call_ids for row in payload_rows[str(call_id)]] + + mock_conn.fetch.side_effect = fetch_side_effect + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + raw_parse_count = 0 + request_parse_count = 0 + original_parse_raw_request_message = service._parse_raw_request_message + original_parse_request_messages = service._parse_request_messages + + def count_raw_parse(raw_message: dict[str, object]) -> list[ConversationMessage]: + nonlocal raw_parse_count + raw_parse_count += 1 + return original_parse_raw_request_message(raw_message) + + def count_request_parse(request: dict[str, object]) -> list[ConversationMessage]: + nonlocal request_parse_count + raw_messages = request.get("messages", []) + if isinstance(raw_messages, list): + request_parse_count += len(raw_messages) + return original_parse_request_messages(request) + + monkeypatch.setattr(service, "_parse_raw_request_message", count_raw_parse) + monkeypatch.setattr(service, "_parse_request_messages", count_request_parse) + + turns = [turn async for turn in iter_session_turns("session-1", mock_pool, ranges)] + + messages_fetches = [ + call.args[0] for call in mock_conn.fetch.call_args_list if " as messages" in call.args[0].lower() + ] + full_payload_fetches = [ + call.args[0] + for call in mock_conn.fetch.call_args_list + if "select call_id, event_type, payload" in call.args[0].lower() + ] + assert [[message.content for message in turn.request_messages] for turn in turns] == [ + ["one"], + ["two", "three"], + ["four", "five"], + ] + assert len(messages_fetches) == 1 + assert len(full_payload_fetches) == 1 + assert raw_parse_count + request_parse_count <= 5 + + @pytest.mark.asyncio + async def test_sqlite_projection_and_message_fetches_use_json_projection(self, sqlite_pool: DatabasePool): + async with sqlite_pool.connection() as conn: + await conn.execute( + """ + INSERT INTO conversation_calls (call_id, model_name, provider, status, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "call-1", + DEFAULT_TEST_MODEL, + "openai", + "completed", + "session-fast", + "2025-01-15T10:00:00", + ) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "event-1", + "call-1", + "transaction.request_recorded", + json.dumps( + { + "final_model": DEFAULT_TEST_MODEL, + "final_request": { + "model": DEFAULT_TEST_MODEL, + "max_tokens": 512, + "stream": True, + "output_config": {"format": {"type": "json_schema", "schema": {"secret": True}}}, + "tools": [{"name": "a"}, {"name": "b"}], + "messages": [{"role": "user", "content": "hello"}], + }, + } + ), + "session-fast", + "2025-01-15T10:00:00", + ) + + projections = await service._fetch_request_projections( + conn, + "session-fast", + [CallEventRange("call-1", datetime(2025, 1, 15, 10, 0, 0), datetime(2025, 1, 15, 10, 0, 0))], + is_sqlite=True, + ) + messages_by_call = await service._fetch_request_messages_by_call_id( + conn, + "session-fast", + ["call-1"], + is_sqlite=True, + ) + + assert projections == [ + service.RequestProjection( + call_id="call-1", + first_ts=datetime(2025, 1, 15, 10, 0, 0), + request_ts=datetime(2025, 1, 15, 10, 0, 0), + final_model=DEFAULT_TEST_MODEL, + request_params={ + "model": DEFAULT_TEST_MODEL, + "max_tokens": 512, + "stream": True, + "output_config": {"format": {"type": "json_schema"}}, + "tools_count": 2, + }, + raw_msg_count=1, + request_was_modified=False, + ) + ] + assert messages_by_call == {"call-1": [{"role": "user", "content": "hello"}]} + + @pytest.mark.asyncio + async def test_fast_turns_match_slow_turns_for_representative_pg_rows(self): + rows = _representative_fast_path_rows() + ranges = [ + CallEventRange( + call_id=str(row["call_id"]), + first_ts=parse_db_ts(row["first_ts"]), + last_ts=parse_db_ts(row["last_ts"]), + ) + for row in _range_rows_from_event_rows(rows) + ] + fast_conn = AsyncMock() + fast_conn.fetch.side_effect = _fetch_from_event_rows(rows) + projections = await service._fetch_request_projections(fast_conn, "session-parity", ranges, is_sqlite=False) + plan = service._select_fast_path_anchor(projections) + assert plan is not None + messages_by_call = await service._fetch_request_messages_by_call_id( + fast_conn, + "session-parity", + [plan.anchor.call_id, *(projection.call_id for projection in plan.preflights)], + is_sqlite=False, + ) + event_rows_by_call_id = await service._fetch_non_request_event_rows( + fast_conn, + "session-parity", + ranges, + is_sqlite=False, + ) + events_by_call_id = { + call_id: service._stored_events_from_rows(event_rows) + for call_id, event_rows in event_rows_by_call_id.items() + } + + slow_conn = AsyncMock() + _enable_mock_transaction(slow_conn) + slow_conn.fetch.side_effect = _fetch_from_event_rows(rows) + slow_pool = MagicMock() + slow_pool.is_sqlite = False + slow_pool.connection.return_value.__aenter__.return_value = slow_conn + + fast_turns = [ + turn async for turn in service._iter_session_turns_fast(projections, events_by_call_id, messages_by_call) + ] + slow_turns = [turn async for turn in service._iter_session_turns_slow("session-parity", slow_pool, ranges)] + + _assert_turns_field_equal(fast_turns, slow_turns) + + @pytest.mark.asyncio + async def test_fast_turns_match_slow_turns_for_representative_sqlite_rows(self, sqlite_pool: DatabasePool): + rows = _representative_fast_path_rows() + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for index, row in enumerate(rows, start=1): + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-sqlite-parity", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"parity-event-{index}", + call_id, + row["event_type"], + json.dumps(row["payload"]), + "session-sqlite-parity", + parse_db_ts(row["created_at"]).isoformat(), + ) + ranges = await service._fetch_call_ranges("session-sqlite-parity", sqlite_pool) + async with sqlite_pool.connection() as conn: + projections = await service._fetch_request_projections( + conn, + "session-sqlite-parity", + ranges, + is_sqlite=True, + ) + plan = service._select_fast_path_anchor(projections) + assert plan is not None + messages_by_call = await service._fetch_request_messages_by_call_id( + conn, + "session-sqlite-parity", + [plan.anchor.call_id, *(projection.call_id for projection in plan.preflights)], + is_sqlite=True, + ) + event_rows_by_call_id = await service._fetch_non_request_event_rows( + conn, + "session-sqlite-parity", + ranges, + is_sqlite=True, + ) + events_by_call_id = { + call_id: service._stored_events_from_rows(event_rows) + for call_id, event_rows in event_rows_by_call_id.items() + } + fast_turns = [ + turn async for turn in service._iter_session_turns_fast(projections, events_by_call_id, messages_by_call) + ] + slow_turns = [ + turn async for turn in service._iter_session_turns_slow("session-sqlite-parity", sqlite_pool, ranges) + ] + + _assert_turns_field_equal(fast_turns, slow_turns) + + @pytest.mark.asyncio + async def test_fast_path_rejects_modified_preflight_projection(self): + created = datetime(2025, 1, 15, 10, 0, 0) + projections = [ + _projection("call-1", 1, created), + _projection("preflight", 1, created, max_tokens=1, request_was_modified=True), + _projection("call-2", 2, created), + ] + + assert service._select_fast_path_anchor(projections) is None + + @pytest.mark.asyncio + async def test_sqlite_projection_detects_request_modification_without_false_negative( + self, sqlite_pool: DatabasePool + ): + async with sqlite_pool.connection() as conn: + await _insert_sqlite_call(conn, "modified-call", "session-modified-sqlite", "2025-01-15T10:00:00") + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "modified-request-event", + "modified-call", + "transaction.request_recorded", + json.dumps( + { + "final_model": DEFAULT_TEST_MODEL, + "original_request": {"messages": [{"role": "user", "content": "before"}]}, + "final_request": {"messages": [{"role": "user", "content": "after"}]}, + } + ), + "session-modified-sqlite", + "2025-01-15T10:00:00", + ) + projections = await service._fetch_request_projections( + conn, + "session-modified-sqlite", + [CallEventRange("modified-call", datetime(2025, 1, 15, 10, 0, 0), datetime(2025, 1, 15, 10, 0, 0))], + is_sqlite=True, + ) + + assert projections[0].request_was_modified is True + assert service._select_fast_path_anchor(projections) is None + + @pytest.mark.asyncio + async def test_sqlite_projection_request_param_shape_matches_slow_path(self, sqlite_pool: DatabasePool): + payload = { + "final_model": DEFAULT_TEST_MODEL, + "final_request": { + "model": DEFAULT_TEST_MODEL, + "max_tokens": 512, + "stream": True, + "temperature": 0.2, + "top_p": 0.9, + "top_k": 40, + "stop_sequences": ["END", "STOP"], + "output_config": {"format": {"type": "json_schema", "schema": {"private": True}}}, + "tools": [{"name": "lookup"}], + "messages": [{"role": "user", "content": "hello"}], + }, + } + async with sqlite_pool.connection() as conn: + await _insert_sqlite_call(conn, "param-call", "session-param-sqlite", "2025-01-15T10:00:00") + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "param-shape-event", + "param-call", + "transaction.request_recorded", + json.dumps(payload), + "session-param-sqlite", + "2025-01-15T10:00:00", + ) + projections = await service._fetch_request_projections( + conn, + "session-param-sqlite", + [CallEventRange("param-call", datetime(2025, 1, 15, 10, 0, 0), datetime(2025, 1, 15, 10, 0, 0))], + is_sqlite=True, + ) + + slow_turn = service._build_turn( + "param-call", + [ + StoredEvent( + event_type="transaction.request_recorded", + payload=payload, + created_at=datetime(2025, 1, 15, 10, 0, 0), + ) + ], + ) + assert type(projections[0].request_params["stream"]) is bool + assert type(projections[0].request_params["stop_sequences"]) is list + assert json.dumps(projections[0].request_params, sort_keys=True) == json.dumps( + slow_turn.request_params, + sort_keys=True, + ) + + @pytest.mark.asyncio + async def test_windowed_detail_defaults_to_newest_page_with_metadata(self, sqlite_pool: DatabasePool): + rows = _window_rows(6) + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for row in rows: + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-window-default", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"default-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + "session-window-default", + parse_db_ts(row["created_at"]).isoformat(), + ) + + detail = await fetch_session_detail("session-window-default", sqlite_pool, offset=None, limit=2) + + assert detail.total_turns == 6 + assert detail.offset == 4 + assert detail.limit == 2 + assert detail.has_more is True + assert [turn.call_id for turn in detail.turns] == ["window-call-5", "window-call-6"] + assert [turn.request_messages[0].content for turn in detail.turns] == ["message 5", "message 6"] + + @pytest.mark.asyncio + async def test_windowed_turns_match_full_slice_for_sqlite_middle_window(self, sqlite_pool: DatabasePool): + rows = _window_rows(5) + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for row in rows: + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-window-slice", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"slice-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + "session-window-slice", + parse_db_ts(row["created_at"]).isoformat(), + ) + + full_ranges = await service._fetch_call_ranges("session-window-slice", sqlite_pool) + full_turns = [turn async for turn in iter_session_turns("session-window-slice", sqlite_pool, full_ranges)] + window = await fetch_session_detail("session-window-slice", sqlite_pool, offset=2, limit=2) + + assert [turn.model_dump_json() for turn in window.turns] == [turn.model_dump_json() for turn in full_turns[2:4]] + assert window.offset == 2 + assert window.limit == 2 + assert window.has_more is True + assert window.total_turns == 5 + + @pytest.mark.asyncio + async def test_windowed_projection_query_limits_before_json_projection(self): + rows = _window_rows(3) + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + await fetch_session_detail("session-bounded-query", mock_pool, offset=1, limit=1) + + projection_query = next( + call.args[0].lower() for call in mock_conn.fetch.call_args_list if "raw_msg_count" in call.args[0].lower() + ) + source_index = projection_query.index("from \n (") + limit_index = projection_query.index("limit $2 offset $3") + projection_index = projection_query.index("order by call_id, created_at asc") + assert source_index < limit_index < projection_index + + @pytest.mark.asyncio + async def test_windowed_detail_stats_match_list_and_stay_invariant_for_sqlite(self, sqlite_pool: DatabasePool): + rows = _window_rows(6) + for index, row in enumerate(rows): + if row["event_type"] != "transaction.request_recorded": + continue + payload = row["payload"] + assert isinstance(payload, dict) + final_request = payload["final_request"] + assert isinstance(final_request, dict) + model = "claude-opus-4-6" if index % 4 == 0 else "gpt-4" + payload["final_model"] = model + final_request["model"] = model + rows.extend( + [ + { + "call_id": "window-call-2", + "event_type": "policy.string_replacement.request_modified", + "payload": {"summary": "request changed"}, + "created_at": datetime(2025, 1, 15, 10, 1, 30), + }, + { + "call_id": "window-call-5", + "event_type": "policy.judge.tool_call_blocked", + "payload": {"summary": "tool blocked"}, + "created_at": datetime(2025, 1, 15, 10, 4, 30), + }, + { + "call_id": "window-call-6", + "event_type": "policy.anthropic_judge.evaluation_complete", + "payload": {"summary": "judge bookkeeping"}, + "created_at": datetime(2025, 1, 15, 10, 5, 30), + }, + ] + ) + async with sqlite_pool.connection() as conn: + await _insert_sqlite_event_rows(conn, "session-window-stats", rows) + + summary = (await fetch_session_list(limit=10, db_pool=sqlite_pool)).sessions[0] + first_page = await fetch_session_detail("session-window-stats", sqlite_pool, offset=0, limit=2) + middle_page = await fetch_session_detail("session-window-stats", sqlite_pool, offset=2, limit=2) + last_page = await fetch_session_detail("session-window-stats", sqlite_pool, offset=4, limit=2) + streamed_last_page = json.loads( + ( + await _collect_bytes(stream_session_detail_json("session-window-stats", sqlite_pool, offset=4, limit=2)) + ).decode() + ) + + assert summary.policy_interventions == 2 + assert summary.models_used == ["claude-opus-4-6", "gpt-4"] + for detail in [first_page, middle_page, last_page]: + assert detail.total_policy_interventions == summary.policy_interventions + assert detail.models_used == summary.models_used + assert streamed_last_page["total_policy_interventions"] == summary.policy_interventions + assert streamed_last_page["models_used"] == summary.models_used + + @pytest.mark.asyncio + async def test_windowed_detail_stats_stay_invariant_for_pg_queries(self): + rows = _window_rows(5) + for index, row in enumerate(rows): + if row["event_type"] != "transaction.request_recorded": + continue + payload = row["payload"] + assert isinstance(payload, dict) + final_request = payload["final_request"] + assert isinstance(final_request, dict) + model = "claude-3-opus" if index == 0 else "gpt-4" + payload["final_model"] = model + final_request["model"] = model + rows.append( + { + "call_id": "window-call-1", + "event_type": "policy.string_replacement.response_modified", + "payload": {"summary": "response changed"}, + "created_at": datetime(2025, 1, 15, 10, 0, 30), + } + ) + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + first_page = await fetch_session_detail("session-pg-window-stats", mock_pool, offset=0, limit=2) + last_page = await fetch_session_detail("session-pg-window-stats", mock_pool, offset=3, limit=2) + + assert first_page.total_policy_interventions == 1 + assert last_page.total_policy_interventions == 1 + assert first_page.models_used == ["claude-3-opus", "gpt-4"] + assert last_page.models_used == ["claude-3-opus", "gpt-4"] + + @pytest.mark.asyncio + async def test_windowed_detail_large_request_payload_parses_are_bounded_by_window( + self, monkeypatch: pytest.MonkeyPatch + ): + original_json_list = service._json_list + + async def large_parse_count(turn_count: int) -> int: + parse_count = 0 + + def counting_json_list(value): + nonlocal parse_count + if isinstance(value, list) and len(value) > 20: + parse_count += 1 + return original_json_list(value) + + monkeypatch.setattr(service, "_json_list", counting_json_list) + rows = _window_rows(turn_count) + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + await fetch_session_detail("session-bounded-parse", mock_pool, offset=100, limit=10) + return parse_count + + assert await large_parse_count(200) == 1 + assert await large_parse_count(400) == 1 + + @pytest.mark.asyncio + async def test_windowed_detail_preserves_modified_request_slow_fallback(self, sqlite_pool: DatabasePool): + rows = _window_rows(3) + modified_payload = rows[2]["payload"] + assert isinstance(modified_payload, dict) + final_request = modified_payload["final_request"] + assert isinstance(final_request, dict) + modified_payload["original_request"] = {"messages": [{"role": "user", "content": "original"}]} + final_request["messages"] = [*final_request["messages"], {"role": "user", "content": "modified"}] + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for row in rows: + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-window-modified", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"modified-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + "session-window-modified", + parse_db_ts(row["created_at"]).isoformat(), + ) + + window = await fetch_session_detail("session-window-modified", sqlite_pool, offset=1, limit=1) + + assert len(window.turns) == 1 + assert window.turns[0].request_was_modified is True + assert window.turns[0].request_messages_full is not None + assert window.turns[0].original_request_messages is not None + + @pytest.mark.asyncio + async def test_stream_session_detail_json_matches_fetch_window(self, sqlite_pool: DatabasePool): + rows = _window_rows(4) + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for row in rows: + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-window-stream", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"stream-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + "session-window-stream", + parse_db_ts(row["created_at"]).isoformat(), + ) + + fetched = await fetch_session_detail("session-window-stream", sqlite_pool, offset=1, limit=2) + streamed = json.loads( + ( + await _collect_bytes( + stream_session_detail_json("session-window-stream", sqlite_pool, offset=1, limit=2) + ) + ).decode() + ) + + assert streamed == fetched.model_dump(mode="json") + + @pytest.mark.asyncio + async def test_window_boundary_ignores_prior_preflight_raw_message_count(self): + rows = [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": "first"}]}}, + "created_at": datetime(2025, 1, 15, 10, 0, 0), + }, + { + "call_id": "preflight", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "max_tokens": 1, + "messages": [{"role": "user", "content": f"probe {index}"} for index in range(10)], + } + }, + "created_at": datetime(2025, 1, 15, 10, 1, 0), + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "assistant", "content": "ack"}, + {"role": "user", "content": "second"}, + ] + } + }, + "created_at": datetime(2025, 1, 15, 10, 2, 0), + }, + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + detail = await fetch_session_detail("session-preflight-boundary", mock_pool, offset=2, limit=1) + + assert [message.content for message in detail.turns[0].request_messages] == ["ack", "second"] + + @pytest.mark.asyncio + async def test_exports_remain_full_session_when_detail_default_is_windowed(self): + rows = _window_rows(60) + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + detail_body = json.loads(await _collect_bytes(stream_session_detail_json("session-export-full", mock_pool))) + jsonl = (await _collect_bytes(service.stream_session_jsonl("session-export-full", mock_pool))).decode() + markdown = (await _collect_bytes(service.stream_session_markdown("session-export-full", mock_pool))).decode() + + assert len(detail_body["turns"]) == 50 + assert len(jsonl.strip().splitlines()) == 60 + assert "**Turns:** 60" in markdown + + @pytest.mark.asyncio + async def test_windowed_detail_offset_beyond_end_returns_empty_metadata(self, sqlite_pool: DatabasePool): + rows = _window_rows(2) + async with sqlite_pool.connection() as conn: + inserted_call_ids: set[str] = set() + for row in rows: + call_id = str(row["call_id"]) + if call_id not in inserted_call_ids: + await _insert_sqlite_call( + conn, + call_id, + "session-window-empty", + parse_db_ts(row["created_at"]).isoformat(), + ) + inserted_call_ids.add(call_id) + await conn.execute( + """ + INSERT INTO conversation_events (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"empty-{row['call_id']}-{row['event_type']}", + row["call_id"], + row["event_type"], + json.dumps(row["payload"]), + "session-window-empty", + parse_db_ts(row["created_at"]).isoformat(), + ) + + detail = await fetch_session_detail("session-window-empty", sqlite_pool, offset=99, limit=10) + + assert detail.turns == [] + assert detail.total_turns == 2 + assert detail.offset == 99 + assert detail.limit == 10 + assert detail.has_more is True class TestGetEventSummary: @@ -119,6 +1607,7 @@ def test_truncates_long_messages(self): long_message = "x" * 150 payload = {"final_request": {"messages": [{"role": "user", "content": long_message}]}} result = _extract_preview_message(payload) + assert result is not None assert len(result) == 103 # 100 chars + "..." assert result.endswith("...") @@ -127,6 +1616,16 @@ def test_normalizes_whitespace(self): payload = {"final_request": {"messages": [{"role": "user", "content": "Hello\n\nworld\n test"}]}} assert _extract_preview_message(payload) == "Hello world test" + def test_inline_system_reminder_matches_summary_preview(self): + """Inline system reminders are stripped by the shared preview extractor.""" + payload = { + "final_request": { + "messages": [{"role": "user", "content": "real question noise"}] + } + } + + assert _extract_preview_message(payload) == "real question" + def test_none_payload(self): """Test handling of None payload.""" assert _extract_preview_message(None) is None @@ -775,7 +2274,7 @@ class TestBuildTurn: def test_simple_turn(self): """Test building a simple request/response turn.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -805,7 +2304,7 @@ def test_simple_turn(self): def test_request_params_allowlist(self): """Test that request_params only includes allowlisted fields.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -848,7 +2347,7 @@ def test_request_params_allowlist(self): def test_turn_with_policy_intervention(self): """Test turn with policy modification.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -876,7 +2375,7 @@ def test_turn_with_policy_intervention(self): def test_missing_final_request_raises_error(self): """Test that missing final_request raises KeyError.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -893,7 +2392,7 @@ def test_missing_final_request_raises_error(self): def test_missing_final_response_raises_error(self): """Test that missing final_response raises KeyError.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -918,7 +2417,7 @@ def test_missing_final_response_raises_error(self): def test_anthropic_turn_with_text_response(self): """Test building a turn from Anthropic-format request and response events.""" - events = [ + events: list[StoredEvent] = [ { "event_type": "transaction.request_recorded", "payload": { @@ -986,8 +2485,8 @@ async def test_successful_fetch(self): "total_events": 10, "turn_count": 3, "policy_interventions": 1, - "models": ["gpt-4", "claude-3"], - "request_payload": {"final_request": {"messages": [{"role": "user", "content": "Hello world"}]}}, + "models_used": "gpt-4,claude-3", + "preview_message": "Hello world", }, ] @@ -1024,8 +2523,8 @@ async def test_fetch_with_offset(self): "total_events": 5, "turn_count": 2, "policy_interventions": 0, - "models": ["gpt-4"], - "request_payload": None, # Test with no first message + "models_used": "gpt-4", + "preview_message": None, }, ] @@ -1062,10 +2561,356 @@ async def test_empty_result(self): assert result.has_more is False assert result.sessions == [] + @pytest.mark.asyncio + async def test_unfiltered_pg_list_uses_session_summaries_without_payload(self): + """Unfiltered list hot path reads session_summaries and never selects full payloads.""" + summary_rows = [ + { + "session_id": "session-1", + "first_ts": datetime(2025, 1, 15, 10, 0, 0), + "last_ts": datetime(2025, 1, 15, 11, 0, 0), + "total_events": 10, + "turn_count": 3, + "policy_interventions": 1, + "models_used": "gpt-4,claude-3", + "preview_message": "Hello world", + }, + ] + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetch.side_effect = [summary_rows, []] + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + result = await fetch_session_list(limit=10, db_pool=mock_pool) + + queries = [call.args[0].lower() for call in mock_conn.fetch.call_args_list] + assert result.sessions[0].preview_message == "Hello world" + assert result.sessions[0].models_used == ["claude-3", "gpt-4"] + assert "from session_summaries" in queries[0] + assert "payload" not in queries[0] + assert "request_payload" not in queries[0] + + @pytest.mark.asyncio + async def test_pg_summary_null_preview_without_payload_returns_none(self): + """PG summary rows with NULL preview return None without payload fallback.""" + summary_rows = [ + { + "session_id": "session-null-preview", + "first_ts": datetime(2025, 1, 15, 10, 0, 0), + "last_ts": datetime(2025, 1, 15, 11, 0, 0), + "total_events": 1, + "turn_count": 1, + "policy_interventions": 0, + "models_used": "gpt-4", + "preview_message": None, + }, + ] + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetch.side_effect = [summary_rows, []] + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + result = await fetch_session_list(limit=10, db_pool=mock_pool) + + assert result.sessions[0].preview_message is None + + @pytest.mark.asyncio + async def test_pg_summary_models_are_sorted_like_aggregation(self): + """Summary models are sorted to match existing list output.""" + summary_rows = [ + { + "session_id": "session-model-order", + "first_ts": datetime(2025, 1, 15, 10, 0, 0), + "last_ts": datetime(2025, 1, 15, 11, 0, 0), + "total_events": 2, + "turn_count": 2, + "policy_interventions": 0, + "models_used": "z-model,a-model", + "preview_message": "Hello", + }, + ] + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 1 + mock_conn.fetch.side_effect = [summary_rows, []] + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + result = await fetch_session_list(limit=10, db_pool=mock_pool) + + assert result.sessions[0].models_used == ["a-model", "z-model"] + + @pytest.mark.asyncio + async def test_pg_search_path_still_uses_existing_aggregation(self): + """Full-text search remains on conversation_events aggregation.""" + mock_conn = AsyncMock() + mock_conn.fetchval.return_value = 0 + mock_conn.fetch.return_value = [] + mock_pool = MagicMock() + mock_pool.is_sqlite = False + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + await fetch_session_list(limit=10, db_pool=mock_pool, search=SessionSearchParams(model="gpt-4")) + + query = mock_conn.fetch.call_args.args[0].lower() + assert "from conversation_events" in query + assert "request_payload" in query + class TestFetchSessionDetail: """Test fetching session detail from database.""" + @pytest.mark.asyncio + async def test_iter_session_turns_emits_request_message_deltas_with_preflight_excluded_from_count(self): + """Detail turns carry display-equivalent request deltas instead of cumulative history.""" + ranges = [ + CallEventRange( + call_id="call-1", + first_ts=datetime(2025, 1, 15, 10, 0, 0), + last_ts=datetime(2025, 1, 15, 10, 0, 0), + ), + CallEventRange( + call_id="preflight", + first_ts=datetime(2025, 1, 15, 10, 0, 30), + last_ts=datetime(2025, 1, 15, 10, 0, 30), + ), + CallEventRange( + call_id="call-2", + first_ts=datetime(2025, 1, 15, 10, 1, 0), + last_ts=datetime(2025, 1, 15, 10, 1, 0), + ), + ] + rows_by_call = { + "call-1": [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": "Hi"}]}}, + "created_at": ranges[0].first_ts, + } + ], + "preflight": [ + { + "call_id": "preflight", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota probe"}], + } + }, + "created_at": ranges[1].first_ts, + } + ], + "call-2": [ + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "original_request": { + "messages": [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "Use forbidden tool"}, + ] + }, + "final_request": { + "messages": [ + {"role": "user", "content": "Hi"}, + {"role": "assistant", "content": "Hello!"}, + {"role": "user", "content": "Use safe tool"}, + ] + }, + }, + "created_at": ranges[2].first_ts, + } + ], + } + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + + _stub_event_rows(mock_conn, [row for call_rows in rows_by_call.values() for row in call_rows]) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + turns = [turn async for turn in iter_session_turns("session-1", mock_pool, ranges)] + + request_contents = [[message.content for message in turn.request_messages] for turn in turns] + original_contents = [message.content for message in turns[2].original_request_messages or []] + final_full_contents = [message.content for message in turns[2].request_messages_full or []] + assert request_contents == [["Hi"], ["quota probe"], ["Hello!", "Use safe tool"]] + assert original_contents == ["Hi", "Hello!", "Use forbidden tool"] + assert final_full_contents == ["Hi", "Hello!", "Use safe tool"] + assert request_contents[0] + request_contents[2] == final_full_contents + + @pytest.mark.asyncio + async def test_iter_session_turns_fetches_payloads_in_batches(self): + """Payload fetch query count grows by batch count, not by turn count.""" + ranges = [ + CallEventRange( + call_id=f"call-{index}", + first_ts=datetime(2025, 1, 15, 10, index, 0), + last_ts=datetime(2025, 1, 15, 10, index, 1), + ) + for index in range(51) + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + + rows = [ + { + "call_id": call_id, + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": call_id}]}}, + "created_at": ranges[int(call_id.split("-")[1])].first_ts, + } + for call_range in ranges + for call_id in [call_range.call_id] + ] + + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + turns = [turn async for turn in iter_session_turns("session-1", mock_pool, ranges)] + + assert len(turns) == 51 + assert mock_conn.fetch.await_count == 3 + assert all("call_id = any($2)" in call.args[0].lower() for call in mock_conn.fetch.call_args_list) + + @pytest.mark.asyncio + async def test_iter_session_turns_fetches_sqlite_payload_batches_with_expanded_in_clause(self): + ranges = [ + CallEventRange( + call_id="call-1", + first_ts=datetime(2025, 1, 15, 10, 0, 0), + last_ts=datetime(2025, 1, 15, 10, 0, 10), + ), + CallEventRange( + call_id="call-2", + first_ts=datetime(2025, 1, 15, 10, 1, 0), + last_ts=datetime(2025, 1, 15, 10, 1, 10), + ), + CallEventRange( + call_id="call-3", + first_ts=datetime(2025, 1, 15, 10, 2, 0), + last_ts=datetime(2025, 1, 15, 10, 2, 10), + ), + ] + rows_by_call = { + "call-1": [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": "first"}]}}, + "created_at": ranges[0].first_ts, + } + ], + "call-2": [ + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + ] + } + }, + "created_at": ranges[1].first_ts, + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "late"}, + ] + } + }, + "created_at": datetime(2025, 1, 15, 10, 1, 30), + }, + ], + "call-3": [ + { + "call_id": "call-3", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "first"}, + {"role": "user", "content": "second"}, + {"role": "user", "content": "third"}, + ] + } + }, + "created_at": ranges[2].first_ts, + } + ], + } + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + + _stub_event_rows(mock_conn, [row for call_rows in rows_by_call.values() for row in call_rows]) + mock_pool = MagicMock() + mock_pool.is_sqlite = True + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + turns = [turn async for turn in iter_session_turns("session-1", mock_pool, ranges)] + + query, session_id, *call_ids = mock_conn.fetch.await_args.args + assert [turn.call_id for turn in turns] == ["call-1", "call-2", "call-3"] + assert [[message.content for message in turn.request_messages] for turn in turns] == [ + ["first"], + ["second"], + ["third"], + ] + assert "call_id IN ($2, $3, $4)" in query + assert session_id == "session-1" + assert call_ids == ["call-1", "call-2", "call-3"] + + @pytest.mark.asyncio + async def test_iter_session_turns_bounds_reads_to_snapshot_last_timestamp(self): + """Per-call streaming reads are bounded by the enumerated snapshot.""" + first_ts = datetime(2025, 1, 15, 10, 0, 0) + snapshot_last = datetime(2025, 1, 15, 10, 1, 0) + ranges = [CallEventRange(call_id="call-1", first_ts=first_ts, last_ts=snapshot_last)] + rows = [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": "first"}]}}, + "created_at": first_ts, + }, + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": {"final_request": {"messages": [{"role": "user", "content": "late"}]}}, + "created_at": datetime(2025, 1, 15, 10, 2, 0), + }, + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + turns = [turn async for turn in iter_session_turns("session-1", mock_pool, ranges)] + + query, session_id, call_ids = mock_conn.fetch.await_args.args + assert len(turns) == 1 + assert [message.content for message in turns[0].request_messages] == ["first"] + assert "call_id = ANY($2)" in query + assert session_id == "session-1" + assert call_ids == ["call-1"] + @pytest.mark.asyncio async def test_successful_fetch(self): """Test successful session detail fetching.""" @@ -1092,7 +2937,8 @@ async def test_successful_fetch(self): ] mock_conn = AsyncMock() - mock_conn.fetch.return_value = mock_rows + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, mock_rows) mock_pool = MagicMock() mock_pool.connection.return_value.__aenter__.return_value = mock_conn @@ -1103,11 +2949,80 @@ async def test_successful_fetch(self): assert len(result.turns) == 1 assert result.turns[0].model == "gpt-4" + @pytest.mark.asyncio + async def test_fetch_session_detail_emits_multi_turn_request_message_deltas(self): + rows = [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": {"messages": [{"role": "user", "content": "Plan trip"}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 0, 0), + }, + { + "call_id": "preflight", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "max_tokens": 1, + "messages": [{"role": "user", "content": "quota probe"}], + } + }, + "created_at": datetime(2025, 1, 15, 10, 0, 30), + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "Plan trip"}, + {"role": "assistant", "content": "Where to?"}, + {"role": "user", "content": "Lisbon"}, + ] + } + }, + "created_at": datetime(2025, 1, 15, 10, 1, 0), + }, + { + "call_id": "call-3", + "event_type": "transaction.request_recorded", + "payload": { + "final_request": { + "messages": [ + {"role": "user", "content": "Plan trip"}, + {"role": "assistant", "content": "Where to?"}, + {"role": "user", "content": "Lisbon"}, + {"role": "assistant", "content": "Which dates?"}, + {"role": "user", "content": "May"}, + ] + } + }, + "created_at": datetime(2025, 1, 15, 10, 2, 0), + }, + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + result = await fetch_session_detail("session-1", mock_pool) + + assert [turn.call_id for turn in result.turns] == ["call-1", "preflight", "call-2", "call-3"] + assert [[message.content for message in turn.request_messages] for turn in result.turns] == [ + ["Plan trip"], + ["quota probe"], + ["Where to?", "Lisbon"], + ["Which dates?", "May"], + ] + @pytest.mark.asyncio async def test_no_events_found(self): """Test error when no events found.""" mock_conn = AsyncMock() - mock_conn.fetch.return_value = [] + _stub_event_rows(mock_conn, []) mock_pool = MagicMock() mock_pool.connection.return_value.__aenter__.return_value = mock_conn @@ -1128,7 +3043,8 @@ async def test_unexpected_payload_type_raises_error(self): ] mock_conn = AsyncMock() - mock_conn.fetch.return_value = mock_rows + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, mock_rows) mock_pool = MagicMock() mock_pool.connection.return_value.__aenter__.return_value = mock_conn @@ -1149,7 +3065,8 @@ async def test_string_created_at_is_parsed(self): ] mock_conn = AsyncMock() - mock_conn.fetch.return_value = mock_rows + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, mock_rows) mock_pool = MagicMock() mock_pool.connection.return_value.__aenter__.return_value = mock_conn @@ -1170,7 +3087,8 @@ async def test_unexpected_created_at_type_raises_error(self): ] mock_conn = AsyncMock() - mock_conn.fetch.return_value = mock_rows + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, mock_rows) mock_pool = MagicMock() mock_pool.connection.return_value.__aenter__.return_value = mock_conn @@ -1178,6 +3096,119 @@ async def test_unexpected_created_at_type_raises_error(self): with pytest.raises(TypeError, match="got int"): await fetch_session_detail("session-1", mock_pool) + @pytest.mark.asyncio + async def test_streamed_session_detail_json_matches_existing_fetch_output(self): + """Streaming detail JSON is semantically identical to existing SessionDetail.""" + from luthien_proxy.history import service + + rows = _equivalence_rows() + request_payload = rows[2]["payload"] + assert isinstance(request_payload, dict) + request_payload["original_request"] = request_payload["final_request"] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + expected = await fetch_session_detail("session-1", mock_pool) + + body = await _collect_bytes(service.stream_session_detail_json("session-1", mock_pool)) + + assert json.loads(body) == expected.model_dump(mode="json") + + @pytest.mark.asyncio + async def test_streamed_detail_payload_fetches_are_scoped_to_one_call(self): + """Streaming detail enumerates call ids without payload and fetches payloads per call.""" + from luthien_proxy.history import service + + rows = _equivalence_rows() + request_payload = rows[2]["payload"] + assert isinstance(request_payload, dict) + request_payload["original_request"] = request_payload["final_request"] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + + await _collect_bytes(service.stream_session_detail_json("session-1", mock_pool)) + + queries = [call.args[0].lower() for call in mock_conn.fetch.call_args_list] + assert "payload" not in queries[0] + message_fetch_index = next(index for index, query in enumerate(queries) if " as messages" in query) + payload_call = mock_conn.fetch.call_args_list[message_fetch_index] + assert payload_call.args[1] == "session-1" + assert payload_call.args[2] == ["call-2"] + + @pytest.mark.asyncio + async def test_streamed_exports_match_existing_export_output(self): + """Streaming markdown and JSONL exports equal existing exporters.""" + from luthien_proxy.history import service + + rows = _equivalence_rows() + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + expected = await fetch_session_detail("session-1", mock_pool) + + markdown = (await _collect_bytes(service.stream_session_markdown("session-1", mock_pool))).decode() + jsonl = (await _collect_bytes(service.stream_session_jsonl("session-1", mock_pool))).decode() + + assert markdown == export_session_markdown(expected) + assert jsonl == export_session_jsonl(expected) + + @pytest.mark.asyncio + async def test_streamed_detail_uses_global_last_timestamp_for_interleaved_calls(self): + """Streaming detail last timestamp matches global max event timestamp.""" + from luthien_proxy.history import service + + rows = [ + { + "call_id": "call-1", + "event_type": "transaction.request_recorded", + "payload": { + "final_model": "gpt-4", + "final_request": {"messages": [{"role": "user", "content": "first"}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 0, 0), + }, + { + "call_id": "call-2", + "event_type": "transaction.request_recorded", + "payload": { + "final_model": "claude-3", + "final_request": {"messages": [{"role": "user", "content": "second"}]}, + }, + "created_at": datetime(2025, 1, 15, 10, 1, 0), + }, + { + "call_id": "call-2", + "event_type": "transaction.streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "second done"}}]}}, + "created_at": datetime(2025, 1, 15, 10, 2, 0), + }, + { + "call_id": "call-1", + "event_type": "transaction.streaming_response_recorded", + "payload": {"final_response": {"choices": [{"message": {"content": "first done"}}]}}, + "created_at": datetime(2025, 1, 15, 10, 3, 0), + }, + ] + mock_conn = AsyncMock() + _enable_mock_transaction(mock_conn) + _stub_event_rows(mock_conn, rows) + mock_pool = MagicMock() + mock_pool.connection.return_value.__aenter__.return_value = mock_conn + expected = await fetch_session_detail("session-1", mock_pool) + + detail = json.loads(await _collect_bytes(service.stream_session_detail_json("session-1", mock_pool))) + markdown = (await _collect_bytes(service.stream_session_markdown("session-1", mock_pool))).decode() + + assert detail["last_timestamp"] == expected.last_timestamp == "2025-01-15T10:03:00" + assert "**Ended:** 2025-01-15T10:03:00" in markdown + class TestExportSessionMarkdown: """Test markdown export functionality.""" diff --git a/tests/luthien_proxy/unit_tests/history/test_service_sqlite.py b/tests/luthien_proxy/unit_tests/history/test_service_sqlite.py index 021369c7e..5e02356ef 100644 --- a/tests/luthien_proxy/unit_tests/history/test_service_sqlite.py +++ b/tests/luthien_proxy/unit_tests/history/test_service_sqlite.py @@ -7,17 +7,19 @@ from __future__ import annotations import json +from collections.abc import AsyncIterator from pathlib import Path import pytest +from luthien_proxy.history.models import SessionSearchParams from luthien_proxy.history.service import fetch_session_list from luthien_proxy.utils.db import DatabasePool from luthien_proxy.utils.db_sqlite import SqliteConnection @pytest.fixture -async def sqlite_pool() -> DatabasePool: +async def sqlite_pool() -> AsyncIterator[DatabasePool]: """Create an in-memory SQLite pool with schema applied.""" pool = DatabasePool("sqlite://:memory:") @@ -426,6 +428,127 @@ async def test_session_with_no_preview_message(self, sqlite_pool: DatabasePool): assert len(result.sessions) == 1 assert result.sessions[0].preview_message is None + @pytest.mark.asyncio + async def test_null_preview_is_sentinel_backfilled_and_not_rescanned(self, sqlite_pool: DatabasePool): + """Probe-only sessions keep None output while storing a no-preview sentinel.""" + async with sqlite_pool.connection() as conn: + await conn.execute( + """ + INSERT INTO conversation_calls + (call_id, model_name, provider, status, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "call-probe-only", + "gpt-4", + "openai", + "completed", + "session-probe-only", + "2025-01-15T17:00:00", + ) + await conn.execute( + """ + INSERT INTO conversation_events + (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "event-probe-only", + "call-probe-only", + "transaction.request_recorded", + json.dumps( + { + "final_model": "gpt-4", + "final_request": {"max_tokens": 1, "messages": [{"role": "user", "content": "probe"}]}, + } + ), + "session-probe-only", + "2025-01-15T17:00:00", + ) + + result = await fetch_session_list(limit=10, db_pool=sqlite_pool) + + async with sqlite_pool.connection() as conn: + stored_preview = await conn.fetchval( + "SELECT preview_message FROM session_summaries WHERE session_id = $1", + "session-probe-only", + ) + remaining_nulls = await conn.fetchval( + "SELECT COUNT(*) FROM session_summaries WHERE preview_message IS NULL" + ) + + assert result.sessions[0].preview_message is None + assert stored_preview == "" + assert remaining_nulls == 0 + + second_result = await fetch_session_list(limit=10, db_pool=sqlite_pool) + assert second_result.sessions[0].preview_message is None + + @pytest.mark.asyncio + async def test_summary_list_path_matches_old_aggregation_path(self, sqlite_pool: DatabasePool): + """Summary hot path matches old aggregation fields field-for-field.""" + async with sqlite_pool.connection() as conn: + for idx, model in enumerate(["z-model", "a-model"]): + await conn.execute( + """ + INSERT INTO conversation_calls + (call_id, model_name, provider, status, session_id, created_at, user_id) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + f"call-equiv-{idx}", + model, + "openai", + "completed", + "session-equiv", + f"2025-01-15T18:0{idx}:00", + "user-equiv", + ) + await conn.execute( + """ + INSERT INTO conversation_events + (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + f"event-equiv-req-{idx}", + f"call-equiv-{idx}", + "transaction.request_recorded", + json.dumps( + { + "final_model": model, + "final_request": {"messages": [{"role": "user", "content": f"Question {idx}"}]}, + } + ), + "session-equiv", + f"2025-01-15T18:0{idx}:00", + ) + await conn.execute( + """ + INSERT INTO conversation_events + (id, call_id, event_type, payload, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + "event-equiv-policy", + "call-equiv-0", + "policy.anthropic_judge.tool_call_blocked", + json.dumps({"summary": "blocked"}), + "session-equiv", + "2025-01-15T18:02:00", + ) + + summary_result = await fetch_session_list(limit=10, db_pool=sqlite_pool) + old_result = await fetch_session_list( + limit=10, + db_pool=sqlite_pool, + user_id="user-equiv", + search=SessionSearchParams(), + ) + summary = summary_result.sessions[0] + old = old_result.sessions[0] + + assert summary_result.total == old_result.total == 1 + assert summary.turn_count == old.turn_count + assert summary.models_used == old.models_used + assert summary.policy_interventions == old.policy_interventions + assert summary.preview_message == old.preview_message + @pytest.mark.asyncio async def test_multiple_models_in_single_session(self, sqlite_pool: DatabasePool): """Test session using multiple distinct models."""