|
1 | 1 | """Tests for ADR Sensor parsers.""" |
2 | 2 |
|
3 | 3 | import json |
| 4 | +import sqlite3 |
4 | 5 | import tempfile |
5 | | -from datetime import datetime, timezone |
| 6 | +from datetime import datetime, timedelta, timezone |
6 | 7 | from pathlib import Path |
7 | 8 | from unittest.mock import patch |
8 | 9 |
|
|
11 | 12 | from adr_sensor.parsers.claude_parser import ClaudeParser |
12 | 13 | from adr_sensor.parsers.cline_parser import ClineParser |
13 | 14 | from adr_sensor.parsers.codex_parser import CodexParser |
| 15 | +from adr_sensor.parsers.warp_parser import WarpParser |
14 | 16 |
|
15 | 17 |
|
16 | 18 | class TestClaudeParser: |
@@ -211,3 +213,178 @@ def test_parse_no_directory(self): |
211 | 213 | parser.base_path = Path("/nonexistent/path") |
212 | 214 | entries = parser.parse_all() |
213 | 215 | assert entries == [] |
| 216 | + |
| 217 | + |
| 218 | +def _build_warp_db(db_path: Path, conversations: list) -> None: |
| 219 | + """Create a synthetic warp.sqlite matching the schema WarpParser queries. |
| 220 | +
|
| 221 | + Each item in `conversations` is a dict with keys: |
| 222 | + conversation_id, last_modified_at, exchanges |
| 223 | + where `exchanges` is a list of (exchange_id, start_ts, input_json, llm_output_json). |
| 224 | + """ |
| 225 | + conn = sqlite3.connect(str(db_path)) |
| 226 | + cursor = conn.cursor() |
| 227 | + cursor.execute( |
| 228 | + """ |
| 229 | + CREATE TABLE agent_conversations ( |
| 230 | + conversation_id TEXT PRIMARY KEY, |
| 231 | + conversation_data TEXT, |
| 232 | + last_modified_at TEXT |
| 233 | + ) |
| 234 | + """ |
| 235 | + ) |
| 236 | + cursor.execute( |
| 237 | + """ |
| 238 | + CREATE TABLE ai_queries ( |
| 239 | + exchange_id TEXT, |
| 240 | + conversation_id TEXT, |
| 241 | + start_ts TEXT, |
| 242 | + input TEXT, |
| 243 | + working_directory TEXT, |
| 244 | + output_status TEXT, |
| 245 | + model_id TEXT |
| 246 | + ) |
| 247 | + """ |
| 248 | + ) |
| 249 | + cursor.execute("CREATE TABLE ai_blocks (exchange_id TEXT, output TEXT)") |
| 250 | + |
| 251 | + for conv in conversations: |
| 252 | + cursor.execute( |
| 253 | + "INSERT INTO agent_conversations VALUES (?, ?, ?)", |
| 254 | + (conv["conversation_id"], "unused_blob", conv["last_modified_at"]), |
| 255 | + ) |
| 256 | + for exchange_id, start_ts, input_json, llm_output_json in conv.get("exchanges", []): |
| 257 | + cursor.execute( |
| 258 | + "INSERT INTO ai_queries VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 259 | + (exchange_id, conv["conversation_id"], start_ts, input_json, "/tmp/project", "success", "claude"), |
| 260 | + ) |
| 261 | + if llm_output_json is not None: |
| 262 | + cursor.execute("INSERT INTO ai_blocks VALUES (?, ?)", (exchange_id, llm_output_json)) |
| 263 | + |
| 264 | + conn.commit() |
| 265 | + conn.close() |
| 266 | + |
| 267 | + |
| 268 | +class TestWarpParser: |
| 269 | + def _make_parser(self, tmp_path: Path, max_age_days: int = 14) -> WarpParser: |
| 270 | + parser = WarpParser(max_age_days=max_age_days) |
| 271 | + parser.base_path = tmp_path |
| 272 | + parser.db_path = tmp_path / "warp.sqlite" |
| 273 | + return parser |
| 274 | + |
| 275 | + def _query_exchange(self, exchange_id: str, timestamp: str) -> tuple: |
| 276 | + input_json = json.dumps([{"Query": {"text": f"hello from {exchange_id}"}}]) |
| 277 | + llm_output_json = json.dumps({"Received": {"output": [{"Text": {"text": f"reply for {exchange_id}"}}]}}) |
| 278 | + return (exchange_id, timestamp, input_json, llm_output_json) |
| 279 | + |
| 280 | + def test_skips_conversations_older_than_max_age(self, tmp_path): |
| 281 | + """Old conversations should be filtered out before the expensive per-conversation query runs.""" |
| 282 | + now = datetime.now(timezone.utc) |
| 283 | + recent_ts = now.isoformat() |
| 284 | + old_ts = (now - timedelta(days=30)).isoformat() |
| 285 | + |
| 286 | + conversations = [ |
| 287 | + { |
| 288 | + "conversation_id": "recent-conv", |
| 289 | + "last_modified_at": recent_ts, |
| 290 | + "exchanges": [self._query_exchange("ex-recent", recent_ts)], |
| 291 | + }, |
| 292 | + { |
| 293 | + "conversation_id": "old-conv", |
| 294 | + "last_modified_at": old_ts, |
| 295 | + "exchanges": [self._query_exchange("ex-old", old_ts)], |
| 296 | + }, |
| 297 | + ] |
| 298 | + db_path = tmp_path / "warp.sqlite" |
| 299 | + _build_warp_db(db_path, conversations) |
| 300 | + |
| 301 | + parser = self._make_parser(tmp_path, max_age_days=14) |
| 302 | + entries = parser.parse_all() |
| 303 | + |
| 304 | + session_ids = {e.session_id for e in entries} |
| 305 | + assert "warp_recent-conv" in session_ids |
| 306 | + assert "warp_old-conv" not in session_ids |
| 307 | + |
| 308 | + def test_all_history_via_large_max_age_days(self, tmp_path): |
| 309 | + """A larger max_age_days should include conversations that would otherwise be skipped.""" |
| 310 | + now = datetime.now(timezone.utc) |
| 311 | + old_ts = (now - timedelta(days=30)).isoformat() |
| 312 | + |
| 313 | + conversations = [ |
| 314 | + { |
| 315 | + "conversation_id": "old-conv", |
| 316 | + "last_modified_at": old_ts, |
| 317 | + "exchanges": [self._query_exchange("ex-old", old_ts)], |
| 318 | + } |
| 319 | + ] |
| 320 | + db_path = tmp_path / "warp.sqlite" |
| 321 | + _build_warp_db(db_path, conversations) |
| 322 | + |
| 323 | + parser = self._make_parser(tmp_path, max_age_days=10000) |
| 324 | + entries = parser.parse_all() |
| 325 | + |
| 326 | + assert {e.session_id for e in entries} == {"warp_old-conv"} |
| 327 | + |
| 328 | + def test_parses_conversation_content(self, tmp_path): |
| 329 | + """Recent conversations should still be parsed into chat history correctly.""" |
| 330 | + now = datetime.now(timezone.utc) |
| 331 | + ts1 = now.isoformat() |
| 332 | + ts2 = (now + timedelta(seconds=1)).isoformat() |
| 333 | + |
| 334 | + action_result_input = json.dumps( |
| 335 | + [ |
| 336 | + { |
| 337 | + "ActionResult": { |
| 338 | + "id": "tool-1", |
| 339 | + "result": { |
| 340 | + "RequestCommandOutput": { |
| 341 | + "result": {"Success": {"command": "ls", "output": "file.txt", "exit_code": 0}} |
| 342 | + } |
| 343 | + }, |
| 344 | + } |
| 345 | + } |
| 346 | + ] |
| 347 | + ) |
| 348 | + |
| 349 | + conversations = [ |
| 350 | + { |
| 351 | + "conversation_id": "conv-1", |
| 352 | + "last_modified_at": ts2, |
| 353 | + "exchanges": [ |
| 354 | + self._query_exchange("ex-1", ts1), |
| 355 | + ("ex-2", ts2, action_result_input, None), |
| 356 | + ], |
| 357 | + } |
| 358 | + ] |
| 359 | + db_path = tmp_path / "warp.sqlite" |
| 360 | + _build_warp_db(db_path, conversations) |
| 361 | + |
| 362 | + parser = self._make_parser(tmp_path) |
| 363 | + entries = parser.parse_all() |
| 364 | + |
| 365 | + assert len(entries) == 1 |
| 366 | + entry = entries[0] |
| 367 | + assert entry.source == "warp" |
| 368 | + assert entry.session_id == "warp_conv-1" |
| 369 | + assert len(entry.chat_history) == 2 |
| 370 | + |
| 371 | + user_msg = entry.chat_history[0] |
| 372 | + assert user_msg.role == "user" |
| 373 | + assert "hello from ex-1" in user_msg.content |
| 374 | + |
| 375 | + assistant_msg = entry.chat_history[1] |
| 376 | + assert assistant_msg.role == "assistant" |
| 377 | + assert len(assistant_msg.tools) == 1 |
| 378 | + assert assistant_msg.tools[0].tool_name == "execute_command" |
| 379 | + assert assistant_msg.tools[0].status == "success" |
| 380 | + |
| 381 | + def test_parse_all_no_database(self, tmp_path): |
| 382 | + """Test parse_all when the database file doesn't exist.""" |
| 383 | + parser = self._make_parser(tmp_path) |
| 384 | + entries = parser.parse_all() |
| 385 | + assert entries == [] |
| 386 | + |
| 387 | + def test_default_max_age_days(self): |
| 388 | + """Constructor should default to the module-level constant when unset.""" |
| 389 | + parser = WarpParser() |
| 390 | + assert parser.max_age_days == 14 |
0 commit comments