Created
April 14, 2026 03:55
-
-
Save jaidhyani/ebb2b26232d886b9e61037091916575a to your computer and use it in GitHub Desktop.
Bug demonstration tests for LuthienResearch/luthien-proxy#578 — written by Claude Code (claude-opus-4-6), not the repo maintainer
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Tests demonstrating bugs in the session search implementation. | |
| These tests hit real SQLite to exercise the actual SQL query construction, | |
| unlike test_search.py which mocks the DB and can't catch SQL-level bugs. | |
| Bug 1: from_time/to_time filter applied to raw event rows, not aggregated session timestamps. | |
| A session spanning Jan-Mar queried with from_time=Feb returns mutilated stats. | |
| Bug 2: SQLite q parameter missing LIKE wildcard escaping. | |
| q=% or q=_ matches everything; diverges from Postgres plainto_tsquery behavior. | |
| Bug 3: SQLite q search matches raw JSON structure, not just content. | |
| q=role or q=type matches every event because of JSON keys. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| 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 | |
| @pytest.fixture | |
| async def sqlite_pool() -> DatabasePool: | |
| """Create an in-memory SQLite pool with schema applied.""" | |
| pool = DatabasePool("sqlite://:memory:") | |
| migrations_dir = Path(__file__).parent.parent.parent.parent.parent / "migrations" / "sqlite" | |
| async with pool.connection() as conn: | |
| for migration_file in sorted(migrations_dir.glob("*.sql")): | |
| sql = migration_file.read_text() | |
| for statement in sql.split(";"): | |
| statement = statement.strip() | |
| if statement and not all( | |
| line.strip().startswith("--") or not line.strip() for line in statement.split("\n") | |
| ): | |
| await conn.execute(statement) | |
| yield pool | |
| await pool.close() | |
| async def _insert_event( | |
| pool: DatabasePool, | |
| *, | |
| event_id: str, | |
| call_id: str, | |
| session_id: str, | |
| event_type: str = "transaction.request_recorded", | |
| payload: dict | None = None, | |
| created_at: str, | |
| ) -> None: | |
| """Insert a conversation event (and its call if needed).""" | |
| if payload is None: | |
| payload = { | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "Hello"}]}, | |
| } | |
| async with pool.connection() as conn: | |
| # Upsert the call | |
| await conn.execute( | |
| """ | |
| INSERT OR IGNORE INTO conversation_calls | |
| (call_id, model_name, provider, status, session_id, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| """, | |
| call_id, | |
| "claude-opus-4-6", | |
| "anthropic", | |
| "completed", | |
| session_id, | |
| created_at, | |
| ) | |
| await conn.execute( | |
| """ | |
| INSERT INTO conversation_events | |
| (id, call_id, event_type, payload, session_id, created_at) | |
| VALUES (?, ?, ?, ?, ?, ?) | |
| """, | |
| event_id, | |
| call_id, | |
| event_type, | |
| json.dumps(payload), | |
| session_id, | |
| created_at, | |
| ) | |
| class TestBug1_TimeFilterMutilatesSessionStats: | |
| """Bug: from_time/to_time is applied to individual event rows, not session-level aggregates. | |
| The docstring says "lower bound on session last activity" but the WHERE clause | |
| filters raw events, so a session spanning Jan-Mar queried with from_time=Feb | |
| returns wrong first_ts, total_events, and turn_count (computed only from Feb+ events). | |
| """ | |
| @pytest.mark.asyncio | |
| async def test_from_time_should_not_mutilate_session_stats(self, sqlite_pool: DatabasePool): | |
| """A session with events in Jan and Mar, filtered with from_time=Feb, | |
| should return the session with its REAL stats (first_ts=Jan, total_events=2), | |
| not stats computed only from the Mar event.""" | |
| # Session A: events in January and March | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "January message"}]}, | |
| }, | |
| ) | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e2", | |
| call_id="c2", | |
| session_id="session-A", | |
| created_at="2026-03-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "March message"}]}, | |
| }, | |
| ) | |
| # Unfiltered: should show 2 events, first_ts in January | |
| unfiltered = await fetch_session_list(limit=10, db_pool=sqlite_pool) | |
| assert len(unfiltered.sessions) == 1 | |
| session = unfiltered.sessions[0] | |
| assert session.total_events == 2 | |
| assert "2026-01-15" in session.first_timestamp | |
| # Filtered: from_time=Feb should still return the session (last activity is March) | |
| # and should show the REAL stats, not just the March-onwards slice | |
| search = SessionSearchParams( | |
| from_time=__import__("datetime").datetime(2026, 2, 1, tzinfo=__import__("datetime").timezone.utc) | |
| ) | |
| filtered = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| # The session should appear (its last activity is March, after Feb) | |
| assert len(filtered.sessions) == 1, "Session with last_ts=March should match from_time=Feb" | |
| filtered_session = filtered.sessions[0] | |
| # BUG: These assertions demonstrate the bug. | |
| # The filter should not change the session's intrinsic stats. | |
| # But the current implementation computes stats only from events >= Feb, | |
| # so first_ts becomes March and total_events becomes 1. | |
| assert filtered_session.total_events == 2, ( | |
| f"BUG: total_events={filtered_session.total_events}, expected 2. " | |
| "The time filter is mutilating session stats by excluding early events from the aggregate." | |
| ) | |
| assert "2026-01-15" in filtered_session.first_timestamp, ( | |
| f"BUG: first_timestamp={filtered_session.first_timestamp}, expected 2026-01-15. " | |
| "The time filter shifted first_ts to the filter boundary." | |
| ) | |
| @pytest.mark.asyncio | |
| async def test_to_time_should_not_exclude_later_events_from_stats(self, sqlite_pool: DatabasePool): | |
| """A session with events in Jan and Mar, filtered with to_time=Feb, | |
| should return the session (if its first activity is before Feb) | |
| with its REAL stats, not stats computed only from the Jan event.""" | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| ) | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e2", | |
| call_id="c2", | |
| session_id="session-A", | |
| created_at="2026-03-15T10:00:00", | |
| ) | |
| search = SessionSearchParams( | |
| to_time=__import__("datetime").datetime(2026, 2, 1, tzinfo=__import__("datetime").timezone.utc) | |
| ) | |
| filtered = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| if len(filtered.sessions) == 1: | |
| # If the session is returned, its stats should be the real ones | |
| assert filtered.sessions[0].total_events == 2, ( | |
| f"BUG: total_events={filtered.sessions[0].total_events}, expected 2. " | |
| "The to_time filter is excluding later events from the aggregate." | |
| ) | |
| class TestBug2_SqliteLikeEscaping: | |
| """Bug: The q parameter is not escaped for LIKE metacharacters on SQLite. | |
| The user filter correctly escapes %, _, and \\ before constructing the LIKE pattern. | |
| The q filter does not, so q=% matches everything and q=_ matches everything | |
| with at least one character. This diverges from Postgres plainto_tsquery which | |
| treats input as literal text. | |
| """ | |
| @pytest.mark.asyncio | |
| async def test_q_percent_should_not_match_everything(self, sqlite_pool: DatabasePool): | |
| """q=% should be treated as a literal percent sign search, not a wildcard.""" | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "Hello world"}]}, | |
| }, | |
| ) | |
| # Search for literal "%" — should NOT match "Hello world" | |
| search = SessionSearchParams(q="%") | |
| result = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| assert len(result.sessions) == 0, ( | |
| f"BUG: q='%' matched {len(result.sessions)} sessions. " | |
| "The percent sign is being treated as a LIKE wildcard instead of literal text. " | |
| "Expected 0 matches since no event contains a literal '%'." | |
| ) | |
| @pytest.mark.asyncio | |
| async def test_q_underscore_should_not_match_everything(self, sqlite_pool: DatabasePool): | |
| """q=_ should be treated as a literal underscore search, not a single-char wildcard.""" | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "Hello world"}]}, | |
| }, | |
| ) | |
| # Search for literal "_" — should NOT match "Hello world" | |
| search = SessionSearchParams(q="_") | |
| result = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| assert len(result.sessions) == 0, ( | |
| f"BUG: q='_' matched {len(result.sessions)} sessions. " | |
| "The underscore is being treated as a LIKE single-char wildcard instead of literal text. " | |
| "Expected 0 matches since no event contains a literal '_'." | |
| ) | |
| class TestBug3_SqliteSearchesRawJson: | |
| """Bug: SQLite q search matches the entire serialized JSON payload, including | |
| structural keys like 'role', 'type', 'content', 'final_request', etc. | |
| Postgres uses _extract_event_search_text() which carefully extracts only user/assistant | |
| text content. SQLite uses `payload LIKE '%q%'` which matches JSON keys and metadata. | |
| """ | |
| @pytest.mark.asyncio | |
| async def test_q_role_should_not_match_json_keys(self, sqlite_pool: DatabasePool): | |
| """Searching for 'role' should not match just because every event has | |
| a 'role' key in its JSON structure.""" | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "What is the weather?"}]}, | |
| }, | |
| ) | |
| # "role" appears in the JSON structure but not in the user's actual message | |
| search = SessionSearchParams(q="role") | |
| result = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| assert len(result.sessions) == 0, ( | |
| f"BUG: q='role' matched {len(result.sessions)} sessions. " | |
| "SQLite is matching against JSON structural keys, not just message content. " | |
| "Postgres uses _extract_event_search_text() to search only user/assistant text." | |
| ) | |
| @pytest.mark.asyncio | |
| async def test_q_final_request_should_not_match_json_keys(self, sqlite_pool: DatabasePool): | |
| """Searching for 'final_request' should not match just because it's a JSON key.""" | |
| await _insert_event( | |
| sqlite_pool, | |
| event_id="e1", | |
| call_id="c1", | |
| session_id="session-A", | |
| created_at="2026-01-15T10:00:00", | |
| payload={ | |
| "final_model": "claude-opus-4-6", | |
| "final_request": {"messages": [{"role": "user", "content": "Tell me a joke"}]}, | |
| }, | |
| ) | |
| search = SessionSearchParams(q="final_request") | |
| result = await fetch_session_list(limit=10, db_pool=sqlite_pool, search=search) | |
| assert len(result.sessions) == 0, ( | |
| f"BUG: q='final_request' matched {len(result.sessions)} sessions. " | |
| "SQLite is matching against JSON structural keys, not just message content." | |
| ) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment