Garm commited on
Commit
9e53127
·
1 Parent(s): 6f3bc03

Fix tests

Browse files
headroom/learn/scanner.py CHANGED
@@ -434,9 +434,7 @@ def _greedy_path_decode(base: Path, parts: list[str]) -> Path | None:
434
  return None
435
 
436
  try:
437
- children = sorted(
438
- child for child in base.iterdir() if child.is_dir() and not child.name.startswith(".")
439
- )
440
  except OSError:
441
  return None
442
 
@@ -496,6 +494,12 @@ class CodexScanner(ConversationScanner):
496
  self.codex_dir = codex_dir or Path.home() / ".codex"
497
  self.sessions_dir = self.codex_dir / "sessions"
498
 
 
 
 
 
 
 
499
  def discover_projects(self) -> list[ProjectInfo]:
500
  """Codex doesn't organize by project — return a single 'codex' project.
501
 
@@ -505,7 +509,7 @@ class CodexScanner(ConversationScanner):
505
  if not self.sessions_dir.exists():
506
  return []
507
 
508
- session_files = list(self.sessions_dir.glob("*.json"))
509
  if not session_files:
510
  return []
511
 
@@ -526,13 +530,19 @@ class CodexScanner(ConversationScanner):
526
  def scan_project(self, project: ProjectInfo) -> list[SessionData]:
527
  """Scan all Codex session JSON files."""
528
  sessions = []
529
- for json_path in sorted(project.data_path.glob("*.json")):
530
  session = self._scan_session(json_path)
531
  if session and session.tool_calls:
532
  sessions.append(session)
533
  return sessions
534
 
535
  def _scan_session(self, json_path: Path) -> SessionData | None:
 
 
 
 
 
 
536
  """Parse a single Codex session file."""
537
  try:
538
  with open(json_path) as f:
@@ -623,3 +633,121 @@ class CodexScanner(ConversationScanner):
623
  )
624
 
625
  return SessionData(session_id=session_id, tool_calls=tool_calls)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  return None
435
 
436
  try:
437
+ children = sorted(child for child in base.iterdir() if child.is_dir())
 
 
438
  except OSError:
439
  return None
440
 
 
494
  self.codex_dir = codex_dir or Path.home() / ".codex"
495
  self.sessions_dir = self.codex_dir / "sessions"
496
 
497
+ def _iter_session_files(self, root: Path | None = None) -> list[Path]:
498
+ """Return all known Codex session files, including nested rollouts."""
499
+ search_root = root or self.sessions_dir
500
+ session_files = list(search_root.rglob("*.json")) + list(search_root.rglob("*.jsonl"))
501
+ return sorted(path for path in session_files if path.is_file())
502
+
503
  def discover_projects(self) -> list[ProjectInfo]:
504
  """Codex doesn't organize by project — return a single 'codex' project.
505
 
 
509
  if not self.sessions_dir.exists():
510
  return []
511
 
512
+ session_files = self._iter_session_files()
513
  if not session_files:
514
  return []
515
 
 
530
  def scan_project(self, project: ProjectInfo) -> list[SessionData]:
531
  """Scan all Codex session JSON files."""
532
  sessions = []
533
+ for json_path in self._iter_session_files(project.data_path):
534
  session = self._scan_session(json_path)
535
  if session and session.tool_calls:
536
  sessions.append(session)
537
  return sessions
538
 
539
  def _scan_session(self, json_path: Path) -> SessionData | None:
540
+ """Parse a single Codex session file."""
541
+ if json_path.suffix == ".jsonl":
542
+ return self._scan_jsonl_session(json_path)
543
+ return self._scan_json_session(json_path)
544
+
545
+ def _scan_json_session(self, json_path: Path) -> SessionData | None:
546
  """Parse a single Codex session file."""
547
  try:
548
  with open(json_path) as f:
 
633
  )
634
 
635
  return SessionData(session_id=session_id, tool_calls=tool_calls)
636
+
637
+ def _scan_jsonl_session(self, jsonl_path: Path) -> SessionData | None:
638
+ """Parse a modern Codex rollout session stored as JSONL."""
639
+ session_id = jsonl_path.stem
640
+ func_calls: dict[str, tuple[str, dict]] = {}
641
+ tool_calls: list[ToolCall] = []
642
+ msg_index = 0
643
+
644
+ try:
645
+ with open(jsonl_path) as f:
646
+ for line in f:
647
+ try:
648
+ entry = json.loads(line)
649
+ except json.JSONDecodeError:
650
+ continue
651
+
652
+ if entry.get("type") == "session_meta":
653
+ payload = entry.get("payload", {})
654
+ if isinstance(payload, dict):
655
+ session_id = payload.get("id", session_id)
656
+ continue
657
+
658
+ if entry.get("type") != "response_item":
659
+ continue
660
+
661
+ payload = entry.get("payload", {})
662
+ if not isinstance(payload, dict):
663
+ continue
664
+
665
+ msg_index += 1
666
+ item_type = payload.get("type", "")
667
+
668
+ if item_type in ("function_call", "custom_tool_call"):
669
+ call_id = payload.get("call_id", "")
670
+ name = payload.get("name", "")
671
+ parsed = self._parse_codex_arguments(payload)
672
+ name, parsed = self._normalize_codex_tool(name, parsed)
673
+ if call_id and name:
674
+ func_calls[call_id] = (name, parsed)
675
+ continue
676
+
677
+ if item_type not in ("function_call_output", "custom_tool_call_output"):
678
+ continue
679
+
680
+ call_id = payload.get("call_id", "")
681
+ if call_id not in func_calls:
682
+ continue
683
+
684
+ name, inp = func_calls[call_id]
685
+ result_content = self._parse_codex_output(payload.get("output", ""))
686
+ is_err = is_error_content(result_content)
687
+ error_cat = classify_error(result_content) if is_err else ErrorCategory.UNKNOWN
688
+
689
+ tool_calls.append(
690
+ ToolCall(
691
+ name=name,
692
+ tool_call_id=call_id,
693
+ input_data=inp,
694
+ output=result_content,
695
+ is_error=is_err,
696
+ error_category=error_cat,
697
+ msg_index=msg_index,
698
+ output_bytes=len(result_content.encode("utf-8")),
699
+ )
700
+ )
701
+
702
+ except OSError as e:
703
+ logger.debug("Failed to read Codex session %s: %s", jsonl_path, e)
704
+ return None
705
+
706
+ if not tool_calls:
707
+ return None
708
+
709
+ return SessionData(session_id=session_id, tool_calls=tool_calls)
710
+
711
+ def _parse_codex_arguments(self, payload: dict) -> dict:
712
+ """Parse arguments for either legacy or rollout Codex tool calls."""
713
+ raw_args = payload.get("arguments", payload.get("input", ""))
714
+ if isinstance(raw_args, str):
715
+ try:
716
+ parsed = json.loads(raw_args)
717
+ return parsed if isinstance(parsed, dict) else {"raw": raw_args}
718
+ except (json.JSONDecodeError, TypeError):
719
+ return {"raw": raw_args}
720
+ if isinstance(raw_args, dict):
721
+ return raw_args
722
+ return {"raw": str(raw_args)}
723
+
724
+ def _normalize_codex_tool(self, name: str, parsed: dict) -> tuple[str, dict]:
725
+ """Normalize modern Codex tool names to the cross-agent schema."""
726
+ if name == "shell" and "command" in parsed:
727
+ cmd = parsed["command"]
728
+ if isinstance(cmd, list):
729
+ parsed["command"] = cmd[-1] if cmd else ""
730
+ return "Bash", parsed
731
+
732
+ if name == "exec_command" and "cmd" in parsed:
733
+ parsed = dict(parsed)
734
+ parsed["command"] = parsed.get("cmd", "")
735
+ return "Bash", parsed
736
+
737
+ return name, parsed
738
+
739
+ def _parse_codex_output(self, output_raw: object) -> str:
740
+ """Parse tool output from Codex rollout records."""
741
+ if isinstance(output_raw, str):
742
+ try:
743
+ parsed_out = json.loads(output_raw)
744
+ except (json.JSONDecodeError, TypeError):
745
+ return output_raw
746
+
747
+ if isinstance(parsed_out, dict):
748
+ if "output" in parsed_out:
749
+ return str(parsed_out["output"])
750
+ return json.dumps(parsed_out)
751
+ return output_raw
752
+
753
+ return str(output_raw)
tests/test_learn/test_integration.py CHANGED
@@ -123,6 +123,9 @@ class TestFalsePositiveFiltering:
123
  CLAUDE_DIR = Path.home() / ".claude" / "projects"
124
  CODEX_DIR = Path.home() / ".codex" / "sessions"
125
  HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
 
 
 
126
 
127
 
128
  @pytest.mark.skipif(not CLAUDE_DIR.exists(), reason="No Claude Code data")
@@ -193,7 +196,7 @@ class TestClaudeCodeIntegration:
193
  }
194
  with patch("headroom.learn.analyzer._call_llm", return_value=mock_response):
195
  sessions = scanner.scan_project(best)
196
- result = SessionAnalyzer().analyze(best, sessions)
197
  recs = result.recommendations
198
 
199
  writer = ClaudeCodeWriter()
@@ -240,7 +243,7 @@ class TestDecodeProjectPath:
240
  assert result == tmp_path / "GitHub.nosync"
241
 
242
 
243
- @pytest.mark.skipif(not CODEX_DIR.exists(), reason="No Codex data")
244
  class TestCodexIntegration:
245
  """Integration tests against real Codex session data."""
246
 
 
123
  CLAUDE_DIR = Path.home() / ".claude" / "projects"
124
  CODEX_DIR = Path.home() / ".codex" / "sessions"
125
  HAS_API_KEY = bool(os.environ.get("ANTHROPIC_API_KEY"))
126
+ HAS_CODEX_DATA = CODEX_DIR.exists() and (
127
+ any(CODEX_DIR.rglob("*.json")) or any(CODEX_DIR.rglob("*.jsonl"))
128
+ )
129
 
130
 
131
  @pytest.mark.skipif(not CLAUDE_DIR.exists(), reason="No Claude Code data")
 
196
  }
197
  with patch("headroom.learn.analyzer._call_llm", return_value=mock_response):
198
  sessions = scanner.scan_project(best)
199
+ result = SessionAnalyzer(model="gpt-4o").analyze(best, sessions)
200
  recs = result.recommendations
201
 
202
  writer = ClaudeCodeWriter()
 
243
  assert result == tmp_path / "GitHub.nosync"
244
 
245
 
246
+ @pytest.mark.skipif(not HAS_CODEX_DATA, reason="No Codex data")
247
  class TestCodexIntegration:
248
  """Integration tests against real Codex session data."""
249