diff --git a/Sensor/README.md b/Sensor/README.md index bd81ecf..6d79f02 100644 --- a/Sensor/README.md +++ b/Sensor/README.md @@ -17,6 +17,7 @@ ADR Sensor is a Python library that collects telemetry from AI coding agents to | **Cline (Claude Dev)** | `cline` | JSON task files | macOS, Linux, Windows | | **Claude Desktop** | `claude_desktop` | JSONL audit logs | macOS, Windows | | **OpenAI Codex CLI** | `codex` | JSONL (`~/.codex/sessions/`) | macOS, Linux, Windows | +| **GitHub Copilot** | `copilot` | JSONL (`~/.copilot/session-state/`) | macOS, Linux, Windows | | **Warp Terminal** | `warp` | SQLite (`warp.sqlite`) | macOS, Windows | | **opencode** | `opencode` | SQLite (`opencode.db`) or JSON tree | macOS, Linux | @@ -110,6 +111,7 @@ adr-sensor adr-sensor --source claude adr-sensor --source cursor adr-sensor --source codex +adr-sensor --source copilot adr-sensor --source claude_desktop adr-sensor --source opencode @@ -349,6 +351,7 @@ adr-sensor/ │ │ ├── cline_parser.py │ │ ├── claude_desktop_parser.py │ │ ├── codex_parser.py +│ │ ├── copilot_parser.py │ │ ├── opencode_parser.py │ │ └── warp_parser.py │ ├── schemas/ diff --git a/Sensor/adr_sensor/observer.py b/Sensor/adr_sensor/observer.py index 97e2109..962ca10 100644 --- a/Sensor/adr_sensor/observer.py +++ b/Sensor/adr_sensor/observer.py @@ -5,10 +5,16 @@ the ingestion, display, and export of agent telemetry data. """ +import errno +import hashlib import json import os import platform +import re +import secrets +import stat import sys +import time import traceback from datetime import datetime from pathlib import Path @@ -20,6 +26,7 @@ from .parsers.claude_parser import ClaudeParser from .parsers.cline_parser import ClineParser from .parsers.codex_parser import CodexParser +from .parsers.copilot_parser import CopilotParser from .parsers.cursor_parser import CursorParser from .parsers.opencode_parser import OpencodeParser from .parsers.warp_parser import WarpParser @@ -27,6 +34,8 @@ from .schemas.system_config_schema import SystemConfiguration from .utils.timestamp_utils import format_timestamp_for_filename, normalize_timestamp, parse_timestamp_from_filename +_COLLISION_SUFFIX_PATTERN = re.compile(r"_([0-9a-f]{64})(?:_(\d+))?$") + class AgentObserver: """Main class for observing and analyzing AI agent interactions. @@ -50,6 +59,7 @@ class AgentObserver: ("cline", "Cline"), ("warp", "Warp Terminal"), ("codex", "Codex"), + ("copilot", "GitHub Copilot"), ("opencode", "opencode"), ) @@ -59,6 +69,8 @@ class AgentObserver: "claude_desktop": ("Darwin", "Windows"), } + CONTENT_AWARE_INCREMENTAL_SOURCES = frozenset({"codex", "copilot"}) + def __init__(self, output_dir: Optional[Path] = None, max_age_days: Optional[int] = None): """Initialize the AgentObserver. @@ -72,6 +84,7 @@ def __init__(self, output_dir: Optional[Path] = None, max_age_days: Optional[int ClaudeDesktopParser(max_age_days=max_age_days) if max_age_days is not None else ClaudeDesktopParser() ) self.codex_parser = CodexParser() + self.copilot_parser = CopilotParser() self.cline_parser = ClineParser() self.warp_parser = WarpParser(max_age_days=max_age_days) if max_age_days is not None else WarpParser() self.opencode_parser = ( @@ -114,7 +127,7 @@ def ingest_all( Args: source_filter: Which source to ingest. One of 'all', 'claude', 'cursor', - 'claude_desktop', 'cline', 'warp', 'codex', 'opencode'. + 'claude_desktop', 'cline', 'warp', 'codex', 'copilot', 'opencode'. Returns: Tuple of (agent_events, system_configs). @@ -279,20 +292,85 @@ def save_sessions_to_individual_files( output_dir.mkdir(parents=True, exist_ok=True) saved_files = [] + session_file_index = ( + self._build_session_file_index(output_dir) + if any(entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES for entry in entries) + else {} + ) for entry in entries: timestamp_str = format_timestamp_for_filename(entry.timestamp) - clean_session_id = self._clean_filename(entry.session_id) - filename = f"adr.{clean_session_id}.{timestamp_str}.json" + filename_session_id = ( + self._session_filename_id(entry.session_id) + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES + else self._clean_filename(entry.session_id) + ) + filename = f"adr.{filename_session_id}.{timestamp_str}.json" file_path = output_dir / filename + temp_path: Optional[Path] = None + lock_path: Optional[Path] = None + lock_fd: Optional[int] = None try: - with open(file_path, "w", encoding="utf-8") as f: - json.dump(entry.get_non_null_fields(), f, indent=2, ensure_ascii=False) + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES: + lock_path = output_dir / f".adr.{filename_session_id}.lock" + lock_fd = self._acquire_session_lock(lock_path) + existing_info = self._find_session_file(entry, session_file_index) + file_path = self._resolve_session_file_path( + output_dir, entry, filename_session_id, timestamp_str, existing_info + ) + filename = file_path.name + fresh_target = self._session_file_info(file_path) + if ( + fresh_target is not None + and fresh_target["data"].get("session_id") == entry.session_id + ): + existing_info = self._newer_session_file(existing_info, fresh_target) + if self._session_revision_regresses(entry, existing_info): + print(f"Skipped stale session: {filename}") + continue + + entry_data = entry.get_non_null_fields() + temp_path, temp_fd = self._create_session_temp(output_dir, filename, file_path) + with os.fdopen(temp_fd, mode="w", encoding="utf-8") as f: + json.dump(entry_data, f, indent=2, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + os.replace(temp_path, file_path) + temp_path = None + self._sync_session_directory(output_dir) + + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES: + removed_stale_files = self._remove_stale_session_files( + entry.session_id, + file_path, + self._session_file_candidates(entry, session_file_index), + ) + if removed_stale_files: + self._sync_session_directory(output_dir) + self._index_session_file(session_file_index, file_path, entry_data) + saved_files.append(file_path) print(f"Saved session: {filename}") except Exception as e: print(f"Error saving session {filename}: {e}") + self._emit_error( + { + "source": entry.source, + "stage": "save_session", + "error_type": e.__class__.__name__, + "message": str(e), + "session_id": entry.session_id, + } + ) + finally: + if temp_path is not None and temp_path.exists(): + try: + temp_path.unlink() + except OSError as cleanup_error: + print(f"Error removing temporary session file {temp_path.name}: {cleanup_error}") + if lock_fd is not None and lock_path is not None: + self._release_session_lock(lock_fd) print(f"\nSaved {len(saved_files)} sessions to: {output_dir}") return saved_files @@ -302,15 +380,36 @@ def filter_entries_by_existing_files( ) -> List[AgentEvent]: """Filter out entries that haven't changed since last processing.""" existing_files = self._get_existing_session_files(output_dir) + target_dir = Path(output_dir) if output_dir is not None else self._get_default_session_dir() + session_file_index = ( + self._build_session_file_index(target_dir) + if any(entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES for entry in entries) + else {} + ) filtered_entries = [] for entry in entries: - session_id = entry.session_id - if session_id not in existing_files: + filename_session_id = ( + self._session_filename_id(entry.session_id) + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES + else self._clean_filename(entry.session_id) + ) + existing_info = existing_files.get(filename_session_id) + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES: + existing_info = self._find_session_file(entry, session_file_index) + + if existing_info is None: filtered_entries.append(entry) continue - existing_info = existing_files[session_id] + if entry.source in self.CONTENT_AWARE_INCREMENTAL_SOURCES: + if self._session_content_changed(entry, existing_info): + existing_revision = self._session_file_revision(existing_info) + current_revision = self._entry_session_revision(entry) + if existing_revision is None or current_revision is None or current_revision >= existing_revision: + filtered_entries.append(entry) + continue + existing_ts = normalize_timestamp(existing_info["timestamp"]).replace(microsecond=0) entry_ts = normalize_timestamp(entry.timestamp).replace(microsecond=0) @@ -319,6 +418,401 @@ def filter_entries_by_existing_files( return filtered_entries + def _session_content_changed(self, entry: AgentEvent, existing_info: Dict[str, Any]) -> bool: + """Compare complete exported content for sources whose sessions can resume.""" + existing_path = existing_info["file_path"] + try: + existing_data = existing_info.get("data") + if existing_data is None: + with open(existing_path, encoding="utf-8") as handle: + existing_data = json.load(handle) + if not isinstance(existing_data, dict): + raise ValueError("existing session file must contain a JSON object") + current_content = self._session_export_content(entry.get_non_null_fields()) + existing_content = self._session_export_content(existing_data) + if entry.source == "codex" and "session_context" not in existing_content: + current_context = current_content.get("session_context") + if isinstance(current_context, dict) and set(current_context) == {"last_event_at"}: + current_content.pop("session_context") + return current_content != existing_content + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError) as exc: + print(f"Error comparing existing session {existing_path.name}: {exc}") + self._emit_error( + { + "source": entry.source, + "stage": "compare_session", + "error_type": exc.__class__.__name__, + "message": str(exc), + "session_id": entry.session_id, + } + ) + return True + + @staticmethod + def _session_export_content(event_data: Dict[str, Any]) -> Dict[str, Any]: + """Ignore fields that change only because a snapshot is rewritten.""" + return {key: value for key, value in event_data.items() if key not in {"timestamp", "uuid"}} + + def _find_session_file( + self, + entry: AgentEvent, + session_file_index: Dict[str, List[Dict[str, Any]]], + ) -> Optional[Dict[str, Any]]: + """Find the latest snapshot whose stored session ID matches exactly.""" + latest: Optional[Dict[str, Any]] = None + for candidate_info in self._session_file_candidates(entry, session_file_index): + candidate = candidate_info["file_path"] + if not candidate.exists(): + continue + data = candidate_info.get("data") or self._load_session_file(candidate) + if data is None or data.get("session_id") != entry.session_id: + continue + candidate_info = dict(candidate_info) + candidate_info["data"] = data + candidate_info["revision"] = self._session_file_revision(candidate_info) + latest = self._newer_session_file(latest, candidate_info) + return latest + + def _session_file_info(self, file_path: Path) -> Optional[Dict[str, Any]]: + if not file_path.exists(): + return None + file_timestamp = parse_timestamp_from_filename(file_path.name) + data = self._load_session_file(file_path) + if file_timestamp is None or data is None: + return None + info = { + "file_path": file_path, + "timestamp": file_timestamp, + "filename": file_path.name, + "data": data, + } + info["revision"] = self._session_file_revision(info) + return info + + @staticmethod + def _newer_session_file( + current: Optional[Dict[str, Any]], + candidate: Dict[str, Any], + ) -> Dict[str, Any]: + if current is None: + return candidate + candidate_revision = candidate.get("revision") + current_revision = current.get("revision") + candidate_event_count = AgentObserver._session_file_event_count(candidate) + current_event_count = AgentObserver._session_file_event_count(current) + if ( + candidate_revision is not None + and (current_revision is None or candidate_revision > current_revision) + ) or ( + candidate_revision == current_revision + and ( + candidate_event_count is not None + and (current_event_count is None or candidate_event_count > current_event_count) + ) + ) or ( + candidate_revision == current_revision + and candidate_event_count == current_event_count + and candidate["timestamp"] > current["timestamp"] + ): + return candidate + return current + + @staticmethod + def _create_session_temp(output_dir: Path, filename: str, target_path: Path) -> Tuple[Path, int]: + """Create a same-directory temporary file with compatible permissions.""" + for _ in range(10): + temp_path = output_dir / f".{filename}.{secrets.token_hex(8)}.tmp" + try: + fd = os.open(temp_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o666) + except FileExistsError: + continue + + try: + if target_path.exists(): + os.chmod(temp_path, stat.S_IMODE(target_path.stat().st_mode)) + except OSError: + os.close(fd) + temp_path.unlink(missing_ok=True) + raise + return temp_path, fd + raise FileExistsError(f"unable to allocate temporary file for {filename}") + + def _build_session_file_index(self, output_dir: Path) -> Dict[str, List[Dict[str, Any]]]: + """Index filenames once so batch incremental processing stays linear.""" + index: Dict[str, List[Dict[str, Any]]] = {} + if not output_dir.exists(): + return index + + for file_path in output_dir.glob("adr.*.json"): + file_timestamp = parse_timestamp_from_filename(file_path.name) + if file_timestamp is None: + continue + filename_session_id = file_path.name[4:-5].rsplit(".", 1)[0] + info = { + "file_path": file_path, + "timestamp": file_timestamp, + "filename": file_path.name, + } + index.setdefault(filename_session_id, []).append(info) + match = _COLLISION_SUFFIX_PATTERN.search(filename_session_id) + if match: + index.setdefault(f"#sha256:{match.group(1)}", []).append(info) + return index + + def _session_file_candidates( + self, + entry: AgentEvent, + session_file_index: Dict[str, List[Dict[str, Any]]], + ) -> List[Dict[str, Any]]: + filename_session_id = self._session_filename_id(entry.session_id) + legacy_session_id = self._clean_filename(entry.session_id) + digest = hashlib.sha256(entry.session_id.encode("utf-8")).hexdigest() + candidates: List[Dict[str, Any]] = [] + seen_paths = set() + for key in (filename_session_id, legacy_session_id, f"#sha256:{digest}"): + for info in session_file_index.get(key, []): + path = info["file_path"] + if path in seen_paths: + continue + candidate_session_id = path.name[4:-5].rsplit(".", 1)[0] + if self._filename_id_matches_session(candidate_session_id, entry.session_id): + candidates.append(info) + seen_paths.add(path) + return candidates + + def _index_session_file( + self, + session_file_index: Dict[str, List[Dict[str, Any]]], + file_path: Path, + data: Dict[str, Any], + ) -> None: + filename_session_id = file_path.name[4:-5].rsplit(".", 1)[0] + keys = [filename_session_id] + match = _COLLISION_SUFFIX_PATTERN.search(filename_session_id) + if match: + keys.append(f"#sha256:{match.group(1)}") + for key in keys: + session_file_index[key] = [ + info for info in session_file_index.get(key, []) if info["file_path"] != file_path + ] + + info = { + "file_path": file_path, + "timestamp": parse_timestamp_from_filename(file_path.name), + "filename": file_path.name, + "data": data, + } + session_file_index.setdefault(filename_session_id, []).append(info) + if match: + session_file_index.setdefault(f"#sha256:{match.group(1)}", []).append(info) + + def _resolve_session_file_path( + self, + output_dir: Path, + entry: AgentEvent, + filename_session_id: str, + timestamp_str: str, + existing_info: Optional[Dict[str, Any]], + ) -> Path: + """Resolve a stable path without overwriting a colliding session ID.""" + preferred = output_dir / f"adr.{filename_session_id}.{timestamp_str}.json" + if existing_info is not None and format_timestamp_for_filename(existing_info["timestamp"]) == timestamp_str: + existing_session_part = existing_info["file_path"].name[4:-5].rsplit(".", 1)[0] + if ( + existing_session_part == filename_session_id + or existing_session_part.startswith(f"{filename_session_id}_") + ): + return existing_info["file_path"] + + if not preferred.exists(): + return preferred + if self._session_file_has_id(preferred, entry.session_id): + return preferred + + digest = hashlib.sha256(entry.session_id.encode("utf-8")).hexdigest() + counter = 0 + while True: + suffix = digest if counter == 0 else f"{digest}_{counter}" + prefix = filename_session_id[: 200 - len(suffix) - 1] + alternate_id = f"{prefix}_{suffix}".strip("_") + alternate = output_dir / f"adr.{alternate_id}.{timestamp_str}.json" + if not alternate.exists() or self._session_file_has_id(alternate, entry.session_id): + return alternate + counter += 1 + + def _session_revision_regresses( + self, entry: AgentEvent, existing_info: Optional[Dict[str, Any]] + ) -> bool: + """Prevent an older concurrent parse from replacing a newer snapshot.""" + if existing_info is None: + return False + existing_revision = self._session_file_revision(existing_info) + current_revision = self._entry_session_revision(entry) + existing_event_count = self._session_file_event_count(existing_info) + current_event_count = self._entry_session_event_count(entry) + return ( + existing_revision is not None + and current_revision is not None + and ( + current_revision < existing_revision + or ( + current_revision == existing_revision + and existing_event_count is not None + and current_event_count is not None + and current_event_count < existing_event_count + ) + ) + ) + + @staticmethod + def _entry_session_revision(entry: AgentEvent) -> Optional[datetime]: + context = entry.session_context or {} + last_event_at = context.get("last_event_at") if isinstance(context, dict) else None + if last_event_at is None: + return None + try: + return normalize_timestamp(last_event_at) + except (TypeError, ValueError): + return None + + @staticmethod + def _entry_session_event_count(entry: AgentEvent) -> Optional[int]: + context = entry.session_context or {} + event_count = context.get("event_count") if isinstance(context, dict) else None + return event_count if isinstance(event_count, int) and event_count >= 0 else None + + @staticmethod + def _session_file_event_count(existing_info: Dict[str, Any]) -> Optional[int]: + data = existing_info.get("data") + if data is None: + data = AgentObserver._load_session_file(existing_info["file_path"]) + context = data.get("session_context") if isinstance(data, dict) else None + event_count = context.get("event_count") if isinstance(context, dict) else None + return event_count if isinstance(event_count, int) and event_count >= 0 else None + + @staticmethod + def _session_file_revision(existing_info: Dict[str, Any]) -> Optional[datetime]: + if "revision" in existing_info: + revision = existing_info["revision"] + return revision if isinstance(revision, datetime) else None + try: + data = existing_info.get("data") + if data is None: + with open(existing_info["file_path"], encoding="utf-8") as handle: + data = json.load(handle) + if not isinstance(data, dict): + return None + context = data.get("session_context") + last_event_at = context.get("last_event_at") if isinstance(context, dict) else None + return normalize_timestamp(last_event_at or data.get("timestamp") or existing_info["timestamp"]) + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return None + + @staticmethod + def _acquire_session_lock(lock_path: Path, timeout_seconds: float = 30.0) -> int: + """Acquire an OS-managed cross-process lock for one session.""" + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + if os.fstat(fd).st_size == 0: + os.write(fd, b"0") + except Exception: + os.close(fd) + raise + + deadline = time.monotonic() + timeout_seconds + while True: + try: + os.lseek(fd, 0, os.SEEK_SET) + if os.name == "nt": + import msvcrt + + msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) + else: + import fcntl + + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + return fd + except OSError as exc: + if exc.errno not in {errno.EACCES, errno.EAGAIN}: + os.close(fd) + raise + if time.monotonic() >= deadline: + os.close(fd) + raise TimeoutError(f"timed out waiting for session lock {lock_path.name}") + time.sleep(0.05) + + @staticmethod + def _release_session_lock(lock_fd: int) -> None: + try: + os.lseek(lock_fd, 0, os.SEEK_SET) + if os.name == "nt": + import msvcrt + + msvcrt.locking(lock_fd, msvcrt.LK_UNLCK, 1) + else: + import fcntl + + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) + + @staticmethod + def _sync_session_directory(output_dir: Path) -> None: + """Persist directory entries after replacing or removing snapshots.""" + if os.name == "nt": + return + directory_fd = os.open(output_dir, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + + def _remove_stale_session_files( + self, + session_id: str, + keep_path: Path, + candidates: List[Dict[str, Any]], + ) -> bool: + """Remove superseded moving-timestamp snapshots after the replacement succeeds.""" + removed_files = False + for info in candidates: + candidate = info["file_path"] + if candidate == keep_path or not candidate.exists(): + continue + # Reload immediately before removal: indexed data can be stale after a writer updates a path. + data = self._load_session_file(candidate) + if data is None or data.get("session_id") != session_id: + continue + try: + candidate.unlink() + removed_files = True + except OSError as exc: + print(f"Error removing stale session {candidate.name}: {exc}") + self._emit_error( + { + "stage": "remove_stale_session", + "error_type": exc.__class__.__name__, + "message": str(exc), + "session_id": session_id, + } + ) + return removed_files + + @staticmethod + def _session_file_has_id(file_path: Path, session_id: str) -> bool: + """Verify ownership before migrating or deleting a legacy filename.""" + data = AgentObserver._load_session_file(file_path) + return data is not None and data.get("session_id") == session_id + + @staticmethod + def _load_session_file(file_path: Path) -> Optional[Dict[str, Any]]: + try: + with open(file_path, encoding="utf-8") as handle: + data = json.load(handle) + return data if isinstance(data, dict) else None + except (OSError, UnicodeError, json.JSONDecodeError, TypeError): + return None + def _get_existing_session_files(self, output_dir: Optional[Path] = None) -> Dict[str, Dict[str, Any]]: """Get a mapping of session_id to file info for existing session files.""" if output_dir is None: @@ -370,3 +864,30 @@ def _clean_filename(self, session_id: str) -> str: clean_id = clean_id[:max_session_length] return clean_id + + def _session_filename_id(self, session_id: str) -> str: + """Preserve existing safe names while disambiguating lossy sanitization.""" + clean_id = self._clean_filename(session_id) + if clean_id == session_id: + return clean_id + + digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()[:12] + clean_id = clean_id[: 200 - len(digest) - 1] + return f"{clean_id}_{digest}".strip("_") + + def _filename_id_matches_session(self, filename_session_id: str, session_id: str) -> bool: + current_id = self._session_filename_id(session_id) + if filename_session_id in {current_id, self._clean_filename(session_id)}: + return True + + match = _COLLISION_SUFFIX_PATTERN.search(filename_session_id) + if not match: + return False + digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest() + if match.group(1) != digest: + return False + + counter = int(match.group(2)) if match.group(2) is not None else 0 + suffix = digest if counter == 0 else f"{digest}_{counter}" + prefix = current_id[: 200 - len(suffix) - 1] + return filename_session_id == f"{prefix}_{suffix}".strip("_") diff --git a/Sensor/adr_sensor/parsers/__init__.py b/Sensor/adr_sensor/parsers/__init__.py index a3b1281..c94fff0 100644 --- a/Sensor/adr_sensor/parsers/__init__.py +++ b/Sensor/adr_sensor/parsers/__init__.py @@ -8,6 +8,7 @@ from .claude_desktop_parser import ClaudeDesktopParser from .claude_parser import ClaudeParser from .cline_parser import ClineParser +from .copilot_parser import CopilotParser from .codex_parser import CodexParser from .cursor_parser import CursorParser from .opencode_parser import OpencodeParser @@ -18,6 +19,7 @@ "ClaudeDesktopParser", "ClaudeParser", "ClineParser", + "CopilotParser", "CodexParser", "CursorParser", "OpencodeParser", diff --git a/Sensor/adr_sensor/parsers/codex_parser.py b/Sensor/adr_sensor/parsers/codex_parser.py index 4987c95..7c3e490 100644 --- a/Sensor/adr_sensor/parsers/codex_parser.py +++ b/Sensor/adr_sensor/parsers/codex_parser.py @@ -48,6 +48,9 @@ def parse_jsonl_file(self, file_path: Path) -> Optional[AgentEvent]: session_data: Dict[str, Any] = { "id": None, "timestamp": None, + "first_event_timestamp": None, + "last_event_timestamp": None, + "event_count": 0, "cwd": None, "model": None, "messages": [], @@ -61,6 +64,7 @@ def parse_jsonl_file(self, file_path: Path) -> Optional[AgentEvent]: continue try: event = json.loads(line) + session_data["event_count"] += 1 self._process_event(event, session_data) except json.JSONDecodeError: continue @@ -94,13 +98,26 @@ def parse_jsonl_file(self, file_path: Path) -> Optional[AgentEvent]: ) return AgentEvent( - timestamp=session_data["timestamp"] or datetime.now(timezone.utc), + # Keep the filename identity stable; resumed content is detected by the exporter. + timestamp=( + session_data["timestamp"] + or session_data["first_event_timestamp"] + or datetime.now(timezone.utc) + ), source="codex", session_id=f"codex_{session_data['id']}", project_path=session_data["cwd"], model=session_data["model"], chat_history=chat_history, raw_log_path=str(file_path), + session_context=( + { + "last_event_at": session_data["last_event_timestamp"].isoformat(), + "event_count": session_data["event_count"], + } + if session_data["last_event_timestamp"] + else {"event_count": session_data["event_count"]} + ), ) except Exception as e: @@ -174,6 +191,19 @@ def _process_event(self, event: Dict[str, Any], session_data: Dict[str, Any]): evt_type = event.get("type") payload = event.get("payload", {}) + event_timestamp = event.get("timestamp") + if event_timestamp: + try: + normalized = normalize_timestamp(event_timestamp) + current = session_data.get("first_event_timestamp") + if current is None or normalized < current: + session_data["first_event_timestamp"] = normalized + current = session_data.get("last_event_timestamp") + if current is None or normalized > current: + session_data["last_event_timestamp"] = normalized + except Exception: + pass + if evt_type == "session_meta": session_data["id"] = payload.get("id") if payload.get("timestamp"): diff --git a/Sensor/adr_sensor/parsers/copilot_parser.py b/Sensor/adr_sensor/parsers/copilot_parser.py new file mode 100644 index 0000000..933ac4a --- /dev/null +++ b/Sensor/adr_sensor/parsers/copilot_parser.py @@ -0,0 +1,594 @@ +""" +Parser for GitHub Copilot session-state logs. + +Reads per-session event streams from ``~/.copilot/session-state//`` +and normalizes them into ADR's ``AgentEvent`` schema. + +The primary signal lives in ``events.jsonl``. When available, the parser also +enriches sessions with lightweight metadata from ``workspace.yaml`` and +``vscode.metadata.json``. +""" + +import json +import traceback +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from ..schemas.agent_event_schema import AgentEvent, ChatMessage, ToolUsage +from ..utils.string_utils import truncate_middle +from ..utils.timestamp_utils import normalize_timestamp +from .base_parser import BaseParser + +MAX_STRING_LENGTH = 1000 +EDGE_CHARS = 400 + + +class CopilotParser(BaseParser): + """Parser for GitHub Copilot session-state logs.""" + + def __init__(self, base_path: Optional[Path] = None): + self.base_path = Path(base_path) if base_path else Path.home() / ".copilot" / "session-state" + + def parse_all(self) -> List[AgentEvent]: + """Parse all available Copilot sessions.""" + entries: List[AgentEvent] = [] + + if not self.base_path.exists(): + print(f"[COPILOT] No logs found at {self.base_path}") + return entries + + session_dirs = [path for path in self.base_path.iterdir() if path.is_dir() and (path / "events.jsonl").exists()] + session_dirs.sort(key=lambda path: path.stat().st_mtime, reverse=True) + print(f"[COPILOT] Found {len(session_dirs)} session directories") + + for session_dir in session_dirs: + try: + entry = self.parse_session_dir(session_dir) + if entry and entry.has_meaningful_content(): + entries.append(entry) + except Exception as exc: + print(f"[COPILOT] Error parsing {session_dir}: {exc}") + + return entries + + def parse_session_dir(self, session_dir: Path) -> Optional[AgentEvent]: + """Parse one Copilot session directory.""" + events_path = session_dir / "events.jsonl" + if not events_path.exists(): + return None + + workspace_meta = self._load_workspace_yaml(session_dir / "workspace.yaml") + vscode_meta = self._load_json_file(session_dir / "vscode.metadata.json") + + session_data: Dict[str, Any] = { + "id": session_dir.name, + "timestamp": None, + "cwd": workspace_meta.get("cwd"), + "model": None, + "messages": [], + "pending_tool_calls": {}, + "first_event_at": None, + "last_event_at": None, + "event_counts": Counter(), + "workspace_metadata": workspace_meta, + "vscode_metadata": self._sanitize_for_context(vscode_meta) if isinstance(vscode_meta, dict) else {}, + "model_changes": [], + "usage_checkpoints": [], + "permissions": [], + "skills_invoked": [], + "subagents": [], + "session_info": [], + "mode_changes": [], + "hooks": [], + "system_messages": [], + "system_notifications": [], + "workspace_file_changes": [], + "plan_changes": [], + "transformed_user_messages": [], + } + + try: + with open(events_path, encoding="utf-8") as handle: + for line in handle: + line = line.strip() + if not line: + continue + try: + event = json.loads(line) + except json.JSONDecodeError: + continue + self._process_event(event, session_data) + except Exception as exc: + print(f"[COPILOT] Error reading {events_path}: {exc}") + traceback.print_exc() + return None + + chat_history: List[ChatMessage] = [] + for index, msg_dict in enumerate(session_data["messages"]): + tools = [ + ToolUsage( + tool_name=tool_dict["tool_name"], + tool_type=tool_dict["tool_type"], + arguments=tool_dict["arguments"], + result=tool_dict.get("result"), + status=tool_dict.get("status"), + error=tool_dict.get("error"), + ) + for tool_dict in msg_dict["tools"] + ] + + chat_history.append( + ChatMessage( + role=msg_dict["role"], + content=msg_dict["content"], + tools=tools, + sequence_id=msg_dict.get("sequence_id") or f"{session_data['id']}_msg_{index}", + ) + ) + + if not chat_history: + return None + + timestamp = ( + # Updated/modified sidecars can lag resumed events and must not define file identity. + session_data["timestamp"] + or self._normalize_optional_timestamp(workspace_meta.get("created_at")) + or self._normalize_optional_timestamp(vscode_meta.get("created")) + or self._normalize_optional_timestamp(session_data["first_event_at"]) + or datetime.now(timezone.utc) + ) + + context = self._build_session_context(session_data) + + return AgentEvent( + timestamp=timestamp, + source="copilot", + session_id=f"copilot_{session_data['id']}", + project_path=session_data["cwd"], + model=session_data["model"], + chat_history=chat_history, + raw_log_path=str(events_path), + session_context=context or None, + ) + + def _process_event(self, event: Dict[str, Any], session_data: Dict[str, Any]): + """Process a single Copilot event from events.jsonl.""" + event_type = event.get("type") + if not event_type: + return + + data = event.get("data", {}) + event_time = self._normalize_optional_timestamp(event.get("timestamp")) + + session_data["event_counts"][event_type] += 1 + if event_time: + first_event_at = session_data["first_event_at"] + last_event_at = session_data["last_event_at"] + if first_event_at is None or event_time < first_event_at: + session_data["first_event_at"] = event_time + if last_event_at is None or event_time > last_event_at: + session_data["last_event_at"] = event_time + + if event_type == "session.start": + session_id = data.get("sessionId") + if session_id: + session_data["id"] = session_id + session_data["timestamp"] = self._normalize_optional_timestamp(data.get("startTime")) or event_time + session_data["model"] = data.get("selectedModel") or session_data["model"] + context = data.get("context", {}) + if isinstance(context, dict): + session_data["cwd"] = context.get("cwd") or session_data["cwd"] + return + + if event_type == "session.resume": + session_id = data.get("sessionId") + if session_id: + session_data["id"] = session_id + context = data.get("context", {}) + if isinstance(context, dict): + session_data["cwd"] = context.get("cwd") or session_data["cwd"] + return + + if event_type == "user.message": + content = (data.get("content") or "").strip() + transformed = (data.get("transformedContent") or "").strip() + if transformed and transformed != content: + session_data["transformed_user_messages"].append( + { + "sequence_id": event.get("id"), + "content": truncate_middle(transformed, max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS), + } + ) + if content: + session_data["messages"].append( + { + "role": "user", + "content": content, + "tools": [], + "sequence_id": event.get("id"), + } + ) + return + + if event_type == "assistant.message": + content = (data.get("content") or "").strip() + tools = [] + for request in data.get("toolRequests") or []: + tool_call_id = request.get("toolCallId") + tool_dict = { + "tool_name": request.get("name") or "unknown", + "tool_type": request.get("type") or "tool_request", + "arguments": self._truncate_large_arguments(request.get("arguments") or {}), + "result": None, + "status": "pending", + "error": None, + } + tools.append(tool_dict) + if tool_call_id: + session_data["pending_tool_calls"][tool_call_id] = tool_dict + + if content or tools: + session_data["messages"].append( + { + "role": "assistant", + "content": content, + "tools": tools, + "sequence_id": data.get("messageId") or event.get("id"), + } + ) + + session_data["model"] = data.get("model") or session_data["model"] + return + + if event_type == "tool.execution_start": + tool = self._get_or_create_tool( + session_data, + data.get("toolCallId"), + default_tool_name=data.get("toolName"), + ) + if tool is not None: + tool["tool_name"] = data.get("toolName") or tool["tool_name"] + tool["arguments"] = self._truncate_large_arguments(data.get("arguments") or tool["arguments"]) + tool["status"] = "running" + session_data["model"] = data.get("model") or session_data["model"] + return + + if event_type == "tool.execution_complete": + tool = self._get_or_create_tool(session_data, data.get("toolCallId"), default_tool_name=None) + if tool is not None: + success = bool(data.get("success")) + result_text = self._normalize_tool_result(data.get("result")) + error_text = self._normalize_tool_result(data.get("error")) + tool["result"] = result_text + tool["status"] = "success" if success else "error" + if not success: + tool["error"] = error_text or result_text + session_data["model"] = data.get("model") or session_data["model"] + return + + if event_type == "permission.requested": + permission_record = { + "request_id": data.get("requestId"), + "tool_call_id": self._extract_tool_call_id_from_permission(data), + "permission_kind": self._extract_permission_kind(data), + "command": self._extract_permission_command(data), + "timestamp": event.get("timestamp"), + } + session_data["permissions"].append(self._sanitize_for_context(permission_record)) + + tool = self._get_or_create_tool( + session_data, + permission_record["tool_call_id"], + default_tool_name="permission_request", + ) + if tool is not None and tool.get("status") == "pending": + tool["status"] = "permission_requested" + return + + if event_type == "permission.completed": + permission_result = data.get("result", {}) + permission_record = { + "request_id": data.get("requestId"), + "tool_call_id": data.get("toolCallId"), + "result": permission_result, + "timestamp": event.get("timestamp"), + } + session_data["permissions"].append(self._sanitize_for_context(permission_record)) + + tool = self._get_or_create_tool(session_data, data.get("toolCallId"), default_tool_name=None) + kind = permission_result.get("kind") if isinstance(permission_result, dict) else None + if tool is not None and tool.get("status") in {"pending", "permission_requested"} and kind == "approved": + tool["status"] = "approved" + return + + if event_type == "session.model_change": + session_data["model_changes"].append( + self._sanitize_for_context( + { + "previous_model": data.get("previousModel"), + "new_model": data.get("newModel"), + "previous_reasoning_effort": data.get("previousReasoningEffort"), + "reasoning_effort": data.get("reasoningEffort"), + "timestamp": event.get("timestamp"), + } + ) + ) + session_data["model"] = data.get("newModel") or session_data["model"] + return + + if event_type == "session.usage_checkpoint": + session_data["usage_checkpoints"].append(self._sanitize_for_context(data)) + return + + if event_type == "skill.invoked": + session_data["skills_invoked"].append( + self._sanitize_for_context( + { + "name": data.get("name"), + "path": data.get("path"), + "content": data.get("content"), + } + ) + ) + return + + if event_type == "subagent.started": + session_data["subagents"].append( + self._sanitize_for_context( + { + "agent_id": event.get("agentId"), + "tool_call_id": data.get("toolCallId"), + "agent_name": data.get("agentName"), + "display_name": data.get("agentDisplayName"), + "description": data.get("agentDescription"), + "timestamp": event.get("timestamp"), + } + ) + ) + return + + if event_type == "session.info": + session_data["session_info"].append(self._sanitize_for_context(data)) + return + + if event_type == "session.mode_changed": + session_data["mode_changes"].append(self._sanitize_for_context(data)) + return + + if event_type in {"hook.start", "hook.end"}: + session_data["hooks"].append( + self._sanitize_for_context( + { + "type": event_type, + "timestamp": event.get("timestamp"), + "data": data, + } + ) + ) + return + + if event_type == "system.message": + content = data.get("content") + if isinstance(content, str) and content.strip(): + session_data["system_messages"].append( + truncate_middle(content.strip(), max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS) + ) + return + + if event_type == "system.notification": + session_data["system_notifications"].append(self._sanitize_for_context(data)) + return + + if event_type == "session.workspace_file_changed": + session_data["workspace_file_changes"].append(self._sanitize_for_context(data)) + return + + if event_type == "session.plan_changed": + session_data["plan_changes"].append(self._sanitize_for_context(data)) + + def _get_or_create_tool( + self, + session_data: Dict[str, Any], + tool_call_id: Optional[str], + default_tool_name: Optional[str], + ) -> Optional[Dict[str, Any]]: + """Return a mutable tool dict for a toolCallId, creating an orphan entry if needed.""" + if not tool_call_id: + return None + + existing = session_data["pending_tool_calls"].get(tool_call_id) + if existing is not None: + return existing + + tool_dict = { + "tool_name": default_tool_name or "unknown", + "tool_type": "tool_execution", + "arguments": {}, + "result": None, + "status": "pending", + "error": None, + } + session_data["pending_tool_calls"][tool_call_id] = tool_dict + session_data["messages"].append( + { + "role": "assistant", + "content": "", + "tools": [tool_dict], + "sequence_id": f"{session_data['id']}_tool_{tool_call_id}", + } + ) + return tool_dict + + def _build_session_context(self, session_data: Dict[str, Any]) -> Dict[str, Any]: + """Build session-level context from side-channel event metadata.""" + context = { + "workspace_metadata": session_data["workspace_metadata"], + "vscode_metadata": session_data["vscode_metadata"], + "first_event_at": self._format_datetime(session_data["first_event_at"]), + "last_event_at": self._format_datetime(session_data["last_event_at"]), + "event_counts": dict(session_data["event_counts"]), + "event_count": sum(session_data["event_counts"].values()), + "model_changes": session_data["model_changes"], + "usage_checkpoints": session_data["usage_checkpoints"], + "permissions": session_data["permissions"], + "skills_invoked": session_data["skills_invoked"], + "subagents": session_data["subagents"], + "session_info": session_data["session_info"], + "mode_changes": session_data["mode_changes"], + "hooks": session_data["hooks"], + "system_messages": session_data["system_messages"][:5], + "system_notifications": session_data["system_notifications"], + "workspace_file_changes": session_data["workspace_file_changes"], + "plan_changes": session_data["plan_changes"], + "transformed_user_messages": session_data["transformed_user_messages"], + } + + filtered: Dict[str, Any] = {} + for key, value in context.items(): + if isinstance(value, dict) and value: + filtered[key] = value + elif isinstance(value, list) and value: + filtered[key] = value + elif isinstance(value, str) and value: + filtered[key] = value + elif isinstance(value, int) and value >= 0: + filtered[key] = value + return filtered + + def _normalize_tool_result(self, result: Any) -> Optional[str]: + """Normalize tool result payloads to a truncated string.""" + if result is None: + return None + + if isinstance(result, str): + text = result + elif isinstance(result, dict): + text = result.get("detailedContent") or result.get("content") or json.dumps(result, ensure_ascii=False) + else: + text = str(result) + + if not text: + return text + + return truncate_middle(text, max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS) + + def _truncate_large_arguments(self, arguments: Dict[str, Any]) -> Dict[str, Any]: + """Truncate large string values within tool argument dictionaries.""" + if not isinstance(arguments, dict): + return {"raw": arguments} + + return {key: self._sanitize_for_context(value) for key, value in arguments.items()} + + def _sanitize_for_context(self, value: Any) -> Any: + """Recursively truncate long strings so session_context stays bounded.""" + if isinstance(value, str): + return truncate_middle(value, max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS) + if isinstance(value, dict): + return {str(key): self._sanitize_for_context(val) for key, val in value.items()} + if isinstance(value, list): + return [self._sanitize_for_context(item) for item in value] + return value + + def _load_json_file(self, path: Path) -> Dict[str, Any]: + """Read a JSON file if it exists, else return an empty dict.""" + if not path.exists(): + return {} + + try: + with open(path, encoding="utf-8") as handle: + value = json.load(handle) + return value if isinstance(value, dict) else {} + except Exception: + return {} + + def _load_workspace_yaml(self, path: Path) -> Dict[str, Any]: + """Parse the simple key/value workspace.yaml emitted by Copilot.""" + if not path.exists(): + return {} + + data: Dict[str, Any] = {} + try: + with open(path, encoding="utf-8") as handle: + for raw_line in handle: + line = raw_line.strip() + if not line or ":" not in line: + continue + key, value = line.split(":", 1) + data[key.strip()] = self._coerce_scalar(value.strip()) + except Exception: + return {} + return data + + def _coerce_scalar(self, value: str) -> Any: + """Coerce simple YAML scalars without pulling in a YAML dependency.""" + if value == "": + return "" + if value.lower() == "true": + return True + if value.lower() == "false": + return False + try: + return int(value) + except ValueError: + return value + + def _normalize_optional_timestamp(self, value: Any) -> Optional[datetime]: + """Normalize a timestamp when possible.""" + if value in (None, ""): + return None + try: + return normalize_timestamp(value) + except Exception: + return None + + @staticmethod + def _format_datetime(value: Optional[datetime]) -> Optional[str]: + """Format datetimes for session_context.""" + return value.isoformat() if isinstance(value, datetime) else None + + @staticmethod + def _extract_tool_call_id_from_permission(data: Dict[str, Any]) -> Optional[str]: + """Extract toolCallId from the permission event payload.""" + if data.get("toolCallId"): + return data.get("toolCallId") + + permission_request = data.get("permissionRequest", {}) + if isinstance(permission_request, dict) and permission_request.get("toolCallId"): + return permission_request.get("toolCallId") + + prompt_request = data.get("promptRequest", {}) + if isinstance(prompt_request, dict): + return prompt_request.get("toolCallId") + + return None + + @staticmethod + def _extract_permission_kind(data: Dict[str, Any]) -> Optional[str]: + """Return the requested permission kind if present.""" + permission_request = data.get("permissionRequest", {}) + if isinstance(permission_request, dict) and permission_request.get("kind"): + return permission_request.get("kind") + + prompt_request = data.get("promptRequest", {}) + if isinstance(prompt_request, dict): + return prompt_request.get("kind") + + return None + + def _extract_permission_command(self, data: Dict[str, Any]) -> Optional[str]: + """Return the shell command associated with a permission request.""" + permission_request = data.get("permissionRequest", {}) + if isinstance(permission_request, dict): + command = permission_request.get("fullCommandText") + if isinstance(command, str) and command.strip(): + return truncate_middle(command.strip(), max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS) + + prompt_request = data.get("promptRequest", {}) + if isinstance(prompt_request, dict): + command = prompt_request.get("fullCommandText") + if isinstance(command, str) and command.strip(): + return truncate_middle(command.strip(), max_length=MAX_STRING_LENGTH, edge_chars=EDGE_CHARS) + + return None diff --git a/Sensor/tests/test_observer.py b/Sensor/tests/test_observer.py index 2854b0f..b8f9e87 100644 --- a/Sensor/tests/test_observer.py +++ b/Sensor/tests/test_observer.py @@ -1,8 +1,9 @@ """Tests for the AgentObserver.""" import json +import os +import stat from datetime import datetime, timezone -from pathlib import Path from unittest.mock import MagicMock, patch import pytest @@ -16,6 +17,8 @@ def test_init_default(self, tmp_path): """Test default initialization.""" observer = AgentObserver(output_dir=tmp_path) assert observer.output_dir == tmp_path + assert hasattr(observer, "copilot_parser") + assert ("copilot", "GitHub Copilot") in observer.SOURCES def test_display_summary_empty(self, tmp_path, capsys): """Test display summary with no data.""" @@ -124,6 +127,8 @@ def test_clean_filename(self, tmp_path): assert observer._clean_filename("simple_id") == "simple_id" assert observer._clean_filename("path/with:special*chars") == "path_with_special_chars" assert observer._clean_filename(" spaces ") == "spaces" + assert observer._session_filename_id("simple_id") == "simple_id" + assert observer._session_filename_id("path/with") != observer._session_filename_id("path:with") def test_filter_entries_by_existing_files(self, tmp_path): """Test incremental filtering.""" @@ -157,6 +162,418 @@ def test_filter_entries_by_existing_files(self, tmp_path): assert len(filtered) == 1 assert filtered[0].session_id == "claude_session2" + def test_content_filter_ignores_timestamp_identity_migration(self, tmp_path): + """Changing from activity time to start time must not re-export unchanged history.""" + observer = AgentObserver(output_dir=tmp_path) + entry = AgentEvent( + timestamp=datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="unchanged")], + session_context={"last_event_at": "2025-06-16T12:30:00+00:00"}, + ) + legacy_data = entry.get_non_null_fields() + legacy_data["timestamp"] = "2025-06-16T12:30:00+00:00" + legacy_data["uuid"] = "legacy-moving-timestamp-uuid" + legacy_data.pop("session_context") + legacy_file = tmp_path / "adr.codex_session1.20250616_123000.json" + legacy_file.write_text(json.dumps(legacy_data), encoding="utf-8") + + assert observer.filter_entries_by_existing_files([entry], output_dir=tmp_path) == [] + assert legacy_file.exists() + + def test_content_filter_detects_same_second_tool_result(self, tmp_path): + """A result appended in the filename's second must still replace the snapshot.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, 100000, tzinfo=timezone.utc) + pending = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ + ChatMessage( + role="assistant", + content="", + tools=[ToolUsage(tool_name="shell", tool_type="function_call", status="pending")], + ) + ], + ) + completed = AgentEvent( + timestamp=timestamp.replace(microsecond=900000), + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ + ChatMessage( + role="assistant", + content="", + tools=[ + ToolUsage( + tool_name="shell", + tool_type="function_call", + result="done", + status="success", + ) + ], + ) + ], + ) + + first_saved = observer.save_sessions_to_individual_files([pending], output_dir=tmp_path) + assert observer.filter_entries_by_existing_files([completed], output_dir=tmp_path) == [completed] + second_saved = observer.save_sessions_to_individual_files([completed], output_dir=tmp_path) + + assert first_saved == second_saved + session_files = list(tmp_path.glob("adr.codex_session1.*.json")) + assert session_files == second_saved + saved_data = json.loads(session_files[0].read_text(encoding="utf-8")) + assert saved_data["chat_history"][0]["tools"][0]["result"] == "done" + + def test_content_update_replaces_legacy_snapshot_with_stable_file(self, tmp_path): + """A changed legacy snapshot should converge to one start-time file.""" + observer = AgentObserver(output_dir=tmp_path) + legacy_file = tmp_path / "adr.copilot_session1.20250616_123000.json" + backup_file = tmp_path / "adr.copilot_session1.backup.json" + backup_file.write_text("keep", encoding="utf-8") + legacy_file.write_text( + json.dumps( + { + "timestamp": "2025-06-16T12:30:00+00:00", + "source": "copilot", + "session_id": "copilot_session1", + "chat_history": [{"role": "user", "content": "old", "tools": []}], + "uuid": "legacy", + } + ), + encoding="utf-8", + ) + entry = AgentEvent( + timestamp=datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + source="copilot", + session_id="copilot_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="updated")], + ) + + assert observer.filter_entries_by_existing_files([entry], output_dir=tmp_path) == [entry] + saved = observer.save_sessions_to_individual_files([entry], output_dir=tmp_path) + + assert [path.name for path in saved] == ["adr.copilot_session1.20250615_100000.json"] + assert saved[0].exists() + assert not legacy_file.exists() + assert backup_file.read_text(encoding="utf-8") == "keep" + + def test_cleanup_rechecks_session_ownership_before_deleting(self, tmp_path): + """A stale in-memory index must not authorize deleting a replaced path.""" + observer = AgentObserver(output_dir=tmp_path) + candidate = tmp_path / "adr.codex_session1.20250616_123000.json" + candidate.write_text(json.dumps({"session_id": "other_session"}), encoding="utf-8") + + removed = observer._remove_stale_session_files( + "codex_session1", + tmp_path / "adr.codex_session1.20250615_100000.json", + [{"file_path": candidate, "data": {"session_id": "codex_session1"}}], + ) + + assert not removed + assert candidate.exists() + + def test_directory_sync_failure_preserves_legacy_snapshot(self, tmp_path): + """Do not remove the only prior snapshot until the replacement entry is durable.""" + observer = AgentObserver(output_dir=tmp_path) + legacy_file = tmp_path / "adr.codex_session1.20250616_123000.json" + legacy_file.write_text(json.dumps({"session_id": "codex_session1"}), encoding="utf-8") + entry = AgentEvent( + timestamp=datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="updated")], + ) + + with patch.object(observer, "_sync_session_directory", side_effect=OSError("sync failed")): + assert observer.save_sessions_to_individual_files([entry], output_dir=tmp_path) == [] + + assert legacy_file.exists() + + def test_atomic_save_failure_preserves_existing_snapshot(self, tmp_path): + """A failed replacement must leave the previous complete JSON intact.""" + observer = AgentObserver(output_dir=tmp_path) + old_entry = AgentEvent( + timestamp=datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="old")], + ) + new_entry = AgentEvent( + timestamp=old_entry.timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="new")], + ) + existing_path = observer.save_sessions_to_individual_files([old_entry], output_dir=tmp_path)[0] + original_data = existing_path.read_text(encoding="utf-8") + + with patch("adr_sensor.observer.os.replace", side_effect=OSError("replace failed")): + saved = observer.save_sessions_to_individual_files([new_entry], output_dir=tmp_path) + + assert saved == [] + assert existing_path.read_text(encoding="utf-8") == original_data + assert list(tmp_path.glob("*.tmp")) == [] + + @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not preserved on Windows") + def test_atomic_replacement_preserves_existing_permissions(self, tmp_path): + observer = AgentObserver(output_dir=tmp_path) + entry = AgentEvent( + timestamp=datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc), + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="old")], + ) + path = observer.save_sessions_to_individual_files([entry], output_dir=tmp_path)[0] + path.chmod(0o640) + updated = AgentEvent( + timestamp=entry.timestamp, + source=entry.source, + session_id=entry.session_id, + hostname=entry.hostname, + username=entry.username, + chat_history=[ChatMessage(role="user", content="updated")], + ) + + observer.save_sessions_to_individual_files([updated], output_dir=tmp_path) + + assert stat.S_IMODE(path.stat().st_mode) == 0o640 + + def test_lossy_session_ids_do_not_overwrite_or_delete_each_other(self, tmp_path): + """Sanitized filename collisions must retain both sessions.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + slash_entry = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_a/b", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="slash")], + ) + colon_entry = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_a:b", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="colon")], + ) + + saved = observer.save_sessions_to_individual_files([slash_entry, colon_entry], output_dir=tmp_path) + + assert len(saved) == 2 + assert saved[0] != saved[1] + assert all(path.exists() for path in saved) + assert observer.filter_entries_by_existing_files([slash_entry, colon_entry], output_dir=tmp_path) == [] + + colliding_legacy = tmp_path / "adr.codex_a_b.20250616_100000.json" + colliding_legacy.write_text(json.dumps(colon_entry.get_non_null_fields()), encoding="utf-8") + updated_slash = AgentEvent( + timestamp=timestamp, + source="codex", + session_id=slash_entry.session_id, + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="updated slash")], + ) + observer.save_sessions_to_individual_files([updated_slash], output_dir=tmp_path) + assert colliding_legacy.exists() + assert json.loads(colliding_legacy.read_text(encoding="utf-8"))["session_id"] == colon_entry.session_id + + def test_generated_filename_does_not_overwrite_matching_literal_session_id(self, tmp_path): + """A hash-suffixed generated name may also be another session's literal ID.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + lossy_id = "codex_a/b" + literal_id = observer._session_filename_id(lossy_id) + lossy_entry = AgentEvent( + timestamp=timestamp, + source="codex", + session_id=lossy_id, + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="lossy")], + ) + literal_entry = AgentEvent( + timestamp=timestamp, + source="codex", + session_id=literal_id, + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="literal")], + ) + + saved = observer.save_sessions_to_individual_files([lossy_entry, literal_entry], output_dir=tmp_path) + + assert len(saved) == 2 + assert saved[0] != saved[1] + assert {json.loads(path.read_text(encoding="utf-8"))["session_id"] for path in saved} == { + lossy_id, + literal_id, + } + assert observer.filter_entries_by_existing_files([lossy_entry, literal_entry], output_dir=tmp_path) == [] + + def test_older_revision_cannot_replace_newer_snapshot(self, tmp_path): + """A delayed writer must not regress an already exported session.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + newer = AgentEvent( + timestamp=timestamp, + source="copilot", + session_id="copilot_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="newer")], + session_context={"last_event_at": "2025-06-16T12:30:01.000Z"}, + ) + older = AgentEvent( + timestamp=timestamp, + source="copilot", + session_id="copilot_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="older")], + session_context={"last_event_at": "2025-06-16T12:30:00.000Z"}, + ) + + saved_path = observer.save_sessions_to_individual_files([newer], output_dir=tmp_path)[0] + assert observer.filter_entries_by_existing_files([older], output_dir=tmp_path) == [] + assert observer.save_sessions_to_individual_files([older], output_dir=tmp_path) == [] + assert json.loads(saved_path.read_text(encoding="utf-8"))["chat_history"][0]["content"] == "newer" + + def test_equal_event_timestamp_uses_event_count_to_prevent_stale_replacement(self, tmp_path): + """An older parse cannot overwrite a same-timestamp append-only snapshot.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + newer = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="newer")], + session_context={"last_event_at": "2025-06-16T12:30:00.000Z", "event_count": 3}, + ) + stale = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="stale")], + session_context={"last_event_at": "2025-06-16T12:30:00.000Z", "event_count": 2}, + ) + + saved_path = observer.save_sessions_to_individual_files([newer], output_dir=tmp_path)[0] + assert observer.save_sessions_to_individual_files([stale], output_dir=tmp_path) == [] + assert json.loads(saved_path.read_text(encoding="utf-8"))["chat_history"][0]["content"] == "newer" + + def test_leftover_legacy_file_cannot_hide_newer_stable_revision(self, tmp_path): + """Regression checks must use stored revisions, not moving filenames.""" + observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + newer = AgentEvent( + timestamp=timestamp, + source="copilot", + session_id="copilot_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="newest")], + session_context={"last_event_at": "2025-06-18T12:30:00.000Z"}, + ) + stale = AgentEvent( + timestamp=timestamp, + source="copilot", + session_id="copilot_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="stale")], + session_context={"last_event_at": "2025-06-17T12:30:00.000Z"}, + ) + stable_path = observer.save_sessions_to_individual_files([newer], output_dir=tmp_path)[0] + legacy_data = stale.get_non_null_fields() + legacy_data["timestamp"] = "2025-06-16T12:30:00+00:00" + legacy_data["session_context"]["last_event_at"] = "2025-06-16T12:30:00.000Z" + legacy_path = tmp_path / "adr.copilot_session1.20250620_123000.json" + legacy_path.write_text(json.dumps(legacy_data), encoding="utf-8") + + assert observer.filter_entries_by_existing_files([stale], output_dir=tmp_path) == [] + assert observer.save_sessions_to_individual_files([stale], output_dir=tmp_path) == [] + assert json.loads(stable_path.read_text(encoding="utf-8"))["chat_history"][0]["content"] == "newest" + assert legacy_path.exists() + + def test_session_lock_blocks_competing_writer_and_recovers(self, tmp_path): + """The OS releases a session lock cleanly for the next writer.""" + lock_path = tmp_path / ".adr.codex_session1.lock" + first_fd = AgentObserver._acquire_session_lock(lock_path) + try: + with pytest.raises(TimeoutError): + AgentObserver._acquire_session_lock(lock_path, timeout_seconds=0) + finally: + AgentObserver._release_session_lock(first_fd) + + second_fd = AgentObserver._acquire_session_lock(lock_path, timeout_seconds=0) + AgentObserver._release_session_lock(second_fd) + + def test_waiting_stale_writer_refreshes_target_after_lock(self, tmp_path): + """A writer must re-read output created after its initial directory scan.""" + observer = AgentObserver(output_dir=tmp_path) + competing_observer = AgentObserver(output_dir=tmp_path) + timestamp = datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + stale = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="stale")], + session_context={"last_event_at": "2025-06-16T12:30:00.000Z"}, + ) + newer = AgentEvent( + timestamp=timestamp, + source="codex", + session_id="codex_session1", + hostname="host", + username="user", + chat_history=[ChatMessage(role="user", content="newer")], + session_context={"last_event_at": "2025-06-16T12:30:01.000Z"}, + ) + acquire_lock = AgentObserver._acquire_session_lock + injected = False + + def inject_competing_write(lock_path): + nonlocal injected + if not injected: + injected = True + competing_observer.save_sessions_to_individual_files([newer], output_dir=tmp_path) + return acquire_lock(lock_path) + + with patch.object(observer, "_acquire_session_lock", side_effect=inject_competing_write): + assert observer.save_sessions_to_individual_files([stale], output_dir=tmp_path) == [] + + saved_path = next(tmp_path.glob("adr.codex_session1.*.json")) + assert json.loads(saved_path.read_text(encoding="utf-8"))["chat_history"][0]["content"] == "newer" + @patch("adr_sensor.observer.ClaudeParser") def test_ingest_all_handles_parser_errors(self, mock_claude_cls, tmp_path): """Test that ingest_all handles parser errors gracefully.""" diff --git a/Sensor/tests/test_parsers.py b/Sensor/tests/test_parsers.py index afea155..139b684 100644 --- a/Sensor/tests/test_parsers.py +++ b/Sensor/tests/test_parsers.py @@ -13,6 +13,7 @@ from adr_sensor.parsers.claude_parser import ClaudeParser from adr_sensor.parsers.cline_parser import ClineParser from adr_sensor.parsers.codex_parser import CodexParser +from adr_sensor.parsers.copilot_parser import CopilotParser from adr_sensor.parsers.cursor_parser import CursorParser from adr_sensor.parsers.opencode_parser import OpencodeParser from adr_sensor.parsers.warp_parser import WarpParser @@ -434,6 +435,183 @@ def test_parse_no_directory(self): entries = parser.parse_all() assert entries == [] + def test_keeps_session_start_timestamp_for_resumed_session(self, tmp_path): + """Resumed content must not change the timestamp used for file identity.""" + jsonl_file = tmp_path / "resumed-codex.jsonl" + events = [ + { + "type": "session_meta", + "timestamp": "2025-06-15T10:00:00Z", + "payload": {"id": "sess-resume", "timestamp": "2025-06-15T10:00:00Z", "cwd": "/tmp"}, + }, + { + "type": "response_item", + "timestamp": "2025-06-15T10:00:01Z", + "payload": { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "first question"}], + }, + }, + { + "type": "response_item", + "timestamp": "2025-06-16T12:30:00Z", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "later follow-up"}], + }, + }, + ] + with open(jsonl_file, "w", encoding="utf-8") as f: + for event in events: + f.write(json.dumps(event) + "\n") + + entry = CodexParser().parse_jsonl_file(jsonl_file) + assert entry is not None + assert entry.timestamp == datetime(2025, 6, 15, 10, 0, 0, tzinfo=timezone.utc) + assert entry.chat_history[-1].content == "later follow-up" + assert entry.session_context["last_event_at"] == "2025-06-16T12:30:00+00:00" + assert entry.session_context["event_count"] == len(events) + + +class TestCopilotParser: + def test_parse_session_dir(self, tmp_path): + """Parse Copilot session directories into chat history and tool usage.""" + session_dir = tmp_path / "abc-session" + session_dir.mkdir() + + (session_dir / "workspace.yaml").write_text( + "\n".join( + [ + "id: abc-session", + "cwd: C:\\repo", + "client_name: vscode", + "name: Sample session", + "created_at: 2026-08-10T10:00:00.000Z", + "updated_at: 2026-08-10T11:00:00.000Z", + ] + ), + encoding="utf-8", + ) + (session_dir / "vscode.metadata.json").write_text( + json.dumps( + { + "origin": "vscode", + "created": 1785470000000, + "modified": 1785476400000, + "firstUserMessage": "inspect the repo", + } + ), + encoding="utf-8", + ) + + events = [ + { + "type": "session.start", + "timestamp": "2026-08-10T10:00:00.000Z", + "data": { + "sessionId": "abc-session", + "startTime": "2026-08-10T10:00:00.000Z", + "selectedModel": "gpt-5.6-sol", + "context": {"cwd": "C:\\repo"}, + }, + }, + { + "type": "user.message", + "id": "user-1", + "timestamp": "2026-08-10T10:00:05.000Z", + "data": { + "content": "inspect the repo", + "transformedContent": "...\ninspect the repo", + }, + }, + { + "type": "assistant.message", + "id": "assistant-1", + "timestamp": "2026-08-10T10:00:06.000Z", + "data": { + "messageId": "assistant-message-1", + "model": "gpt-5.6-sol", + "content": "I will inspect it.", + "toolRequests": [ + { + "toolCallId": "call-1", + "name": "powershell", + "type": "function", + "arguments": {"command": "Get-ChildItem", "description": "List files"}, + } + ], + }, + }, + { + "type": "tool.execution_complete", + "timestamp": "2026-08-10T11:00:00.000Z", + "data": { + "toolCallId": "call-1", + "model": "gpt-5.6-sol", + "success": True, + "result": {"content": "file1\nfile2"}, + }, + }, + ] + with open(session_dir / "events.jsonl", "w", encoding="utf-8") as f: + for event in events: + f.write(json.dumps(event) + "\n") + + entry = CopilotParser(base_path=tmp_path).parse_session_dir(session_dir) + assert entry is not None + assert entry.source == "copilot" + assert entry.session_id == "copilot_abc-session" + assert entry.project_path == "C:\\repo" + assert entry.model == "gpt-5.6-sol" + assert entry.timestamp == datetime(2026, 8, 10, 10, 0, 0, tzinfo=timezone.utc) + assert [msg.role for msg in entry.chat_history] == ["user", "assistant"] + tool = entry.chat_history[1].tools[0] + assert tool.tool_name == "powershell" + assert tool.result == "file1\nfile2" + assert tool.status == "success" + assert entry.session_context["workspace_metadata"]["updated_at"] == "2026-08-10T11:00:00.000Z" + assert entry.session_context["last_event_at"] == "2026-08-10T11:00:00+00:00" + assert entry.session_context["event_count"] == len(events) + assert entry.session_context["transformed_user_messages"][0]["sequence_id"] == "user-1" + + def test_tool_failure_uses_error_field_when_result_is_absent(self, tmp_path): + """Copilot records some failed tools with error but no result.""" + session_dir = tmp_path / "failed-tool" + session_dir.mkdir() + events = [ + { + "type": "session.start", + "timestamp": "2026-08-10T10:00:00.000Z", + "data": {"sessionId": "failed-tool", "startTime": "2026-08-10T10:00:00.000Z"}, + }, + { + "type": "user.message", + "timestamp": "2026-08-10T10:00:01.000Z", + "data": {"content": "read missing file"}, + }, + { + "type": "tool.execution_start", + "timestamp": "2026-08-10T10:00:02.000Z", + "data": {"toolCallId": "call-1", "toolName": "read_file", "arguments": {"path": "missing"}}, + }, + { + "type": "tool.execution_complete", + "timestamp": "2026-08-10T10:00:02.001Z", + "data": {"toolCallId": "call-1", "success": False, "error": "File not found"}, + }, + ] + with open(session_dir / "events.jsonl", "w", encoding="utf-8") as handle: + for event in events: + handle.write(json.dumps(event) + "\n") + + entry = CopilotParser(base_path=tmp_path).parse_session_dir(session_dir) + tool = [tool for message in entry.chat_history for tool in message.tools][0] + assert tool.status == "error" + assert tool.result is None + assert tool.error == "File not found" + def _build_warp_db(db_path: Path, conversations: list) -> None: """Create a synthetic warp.sqlite matching the schema WarpParser queries.