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