Spaces:
Build error
Build error
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 =
|
| 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
|
| 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
|
| 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 |
|