From 585b10c3c2fff740189d5265592b7686c44a2689 Mon Sep 17 00:00:00 2001 From: rehan Date: Mon, 13 Jul 2026 11:24:34 +0530 Subject: [PATCH 1/5] fix(sessions): add Codex transcript adapter support --- .../src/repowise/core/sessions/__init__.py | 2 + .../core/sessions/adapters/__init__.py | 3 +- .../repowise/core/sessions/adapters/codex.py | 221 ++++++++++++++++++ tests/unit/sessions/data/codex_session.jsonl | 1 + tests/unit/sessions/test_codex_adapter.py | 92 ++++++++ 5 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/repowise/core/sessions/adapters/codex.py create mode 100644 tests/unit/sessions/data/codex_session.jsonl create mode 100644 tests/unit/sessions/test_codex_adapter.py diff --git a/packages/core/src/repowise/core/sessions/__init__.py b/packages/core/src/repowise/core/sessions/__init__.py index d7ef78f3e..53c18cf02 100644 --- a/packages/core/src/repowise/core/sessions/__init__.py +++ b/packages/core/src/repowise/core/sessions/__init__.py @@ -19,6 +19,7 @@ parse_timestamp, transcript_dir_for, ) +from repowise.core.sessions.adapters.codex import CodexAdapter from repowise.core.sessions.cursor import CursorStore, iter_new_events from repowise.core.sessions.events import ( INTERRUPT_MARKER, @@ -31,6 +32,7 @@ __all__ = [ "INTERRUPT_MARKER", "ClaudeCodeAdapter", + "CodexAdapter", "CursorStore", "Event", "HarnessAdapter", diff --git a/packages/core/src/repowise/core/sessions/adapters/__init__.py b/packages/core/src/repowise/core/sessions/adapters/__init__.py index 7cc93289e..156c2b3f8 100644 --- a/packages/core/src/repowise/core/sessions/adapters/__init__.py +++ b/packages/core/src/repowise/core/sessions/adapters/__init__.py @@ -2,5 +2,6 @@ from repowise.core.sessions.adapters.base import HarnessAdapter from repowise.core.sessions.adapters.claude_code import ClaudeCodeAdapter +from repowise.core.sessions.adapters.codex import CodexAdapter -__all__ = ["ClaudeCodeAdapter", "HarnessAdapter"] +__all__ = ["ClaudeCodeAdapter", "CodexAdapter", "HarnessAdapter"] diff --git a/packages/core/src/repowise/core/sessions/adapters/codex.py b/packages/core/src/repowise/core/sessions/adapters/codex.py new file mode 100644 index 000000000..3ed9f44b1 --- /dev/null +++ b/packages/core/src/repowise/core/sessions/adapters/codex.py @@ -0,0 +1,221 @@ +"""Codex transcript adapter. + +Codex stores session rollout files in a date-based hierarchy under +``~/.codex/sessions/YYYY/MM/DD/``. This adapter keeps the implementation +simple and best-effort: it locates those files for a repo and turns each +JSONL line into the shared session ``Event`` shape used by the miners. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, ClassVar + +from repowise.core.sessions.adapters.base import HarnessAdapter +from repowise.core.sessions.events import Event, ToolResult, ToolUse + + +class CodexAdapter(HarnessAdapter): + """Normalizes Codex session JSONL into the shared Event stream.""" + + name: ClassVar[str] = "codex" + + def discover(self, repo_root: Path, *, projects_root: Path | None = None) -> list[Path]: + root = projects_root if projects_root is not None else Path.home() / ".codex" / "sessions" + if not root.is_dir(): + return [] + return sorted(path for path in root.rglob("*.jsonl") if path.is_file()) + + def normalize(self, raw_line: str) -> Event | None: + try: + entry = json.loads(raw_line) + except ValueError: + return None + if not isinstance(entry, dict): + return None + + payload = entry.get("payload") if isinstance(entry.get("payload"), dict) else {} + entry_kind = entry.get("type") if isinstance(entry.get("type"), str) and entry.get("type") else None + payload_kind = payload.get("type") if isinstance(payload.get("type"), str) and payload.get("type") else None + kind = _event_kind(entry_kind, payload_kind, payload) + + event = Event( + kind=kind, + ts=parse_timestamp(_first_non_empty(entry.get("timestamp"), payload.get("timestamp"))), + session_id=_first_non_empty_str( + _str_or_none(payload.get("session_id")), + _str_or_none(entry.get("session_id")), + _str_or_none(payload.get("id")), + ), + cwd=_first_non_empty_str(_str_or_none(payload.get("cwd")), _str_or_none(entry.get("cwd"))), + usage=None, + message_id=_str_or_none(payload.get("id")) or _str_or_none(entry.get("message_id")), + model=_str_or_none(payload.get("model")) or _str_or_none(entry.get("model")), + sidechain=False, + is_meta=entry_kind == "session_meta", + is_compact_summary=False, + ) + + if entry_kind == "session_meta": + event.text = "" + return event + + text = _extract_text(entry.get("text"), payload.get("message"), payload.get("content"), payload.get("output")) + if text: + event.text = text + + if entry_kind == "response_item" and isinstance(payload, dict): + self._fill_response_item(event, payload) + + for tool_call in entry.get("tool_calls") or []: + if not isinstance(tool_call, dict): + continue + tool_id = tool_call.get("id") + name = tool_call.get("name") + if isinstance(tool_id, str) and isinstance(name, str): + event.tool_uses.append( + ToolUse( + id=tool_id, + name=_normalize_tool_name(name), + input=tool_call.get("input") if isinstance(tool_call.get("input"), dict) else {}, + ) + ) + + return event + + @staticmethod + def _fill_response_item(event: Event, payload: dict[str, Any]) -> None: + """Handles response_item entries in Codex transcripts, and converts them to either text, tool uses or tool results + + Codex uses response_item entries to represent things like message, tool call or tool results + """ + payload_type = payload.get("type") + if payload_type == "message": + event.text = _extract_text(None, None, payload.get("content"), None) or event.text + event.message_id = _str_or_none(payload.get("id")) or event.message_id + event.model = _str_or_none(payload.get("model")) or event.model + return + + if payload_type == "custom_tool_call": + tool_id = payload.get("call_id") or payload.get("id") + name = payload.get("name") + if isinstance(tool_id, str) and isinstance(name, str): + event.tool_uses.append( + ToolUse( + id=tool_id, + name=_normalize_tool_name(name), + input=payload.get("input") if isinstance(payload.get("input"), dict) else {}, + ) + ) + return + + if payload_type == "custom_tool_call_output": + output = payload.get("output") + text = _extract_text(None, None, output, None) + if text: + event.text = text + call_id = payload.get("call_id") + if isinstance(call_id, str): + event.tool_results.append( + ToolResult(tool_use_id=call_id, is_error=False, content=output, payload=output) + ) + + +def _normalize_tool_name(name: str) -> str: + aliases = { + "search": "search_codebase", + "search_codebase": "search_codebase", + "answer": "get_answer", + "get_answer": "get_answer", + "edit": "edit_file", + "apply_patch": "edit_file", + "write_file": "edit_file", + "bash": "bash", + "run_command": "bash", + "exec": "bash", + } + return aliases.get(name.lower(), name) + + +def _event_kind(entry_kind: str | None, payload_kind: str | None, payload: dict[str, Any]) -> str: + if entry_kind == "session_meta": + return "session_meta" + if entry_kind == "response_item": + if payload_kind == "message": + role = payload.get("role") + if role == "user": + return "user" + if role == "assistant": + return "assistant" + if role == "developer": + return "system" + if payload_kind == "custom_tool_call": + return "assistant" + if payload_kind == "custom_tool_call_output": + return "assistant" + return payload_kind or "assistant" + if entry_kind == "event_msg": + if payload_kind == "user_message": + return "user" + if payload_kind == "agent_message": + return "assistant" + return payload_kind or "assistant" + return entry_kind or "assistant" + + +def _extract_text(*candidates: Any) -> str: + for candidate in candidates: + if isinstance(candidate, str): + if candidate: + return candidate + continue + if isinstance(candidate, list): + parts: list[str] = [] + for item in candidate: + if isinstance(item, dict): + text = item.get("text") + if isinstance(text, str) and text: + parts.append(text) + else: + kind = item.get("type") + if kind in {"input_text", "output_text"}: + text = item.get("text") + if isinstance(text, str) and text: + parts.append(text) + elif isinstance(item, str) and item: + parts.append(item) + if parts: + return "\n".join(parts) + elif isinstance(candidate, dict): + if isinstance(candidate.get("message"), str): + return candidate["message"] + return "" + + +def parse_timestamp(value: Any) -> float | None: + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except ValueError: + return None + + +def _first_non_empty_str(*values: Any) -> str | None: + for value in values: + if isinstance(value, str) and value: + return value + return None + + +def _first_non_empty(*values: Any) -> Any: + for value in values: + if value is not None and value != "": + return value + return None + + +def _str_or_none(value: Any) -> str | None: + return value if isinstance(value, str) and value else None \ No newline at end of file diff --git a/tests/unit/sessions/data/codex_session.jsonl b/tests/unit/sessions/data/codex_session.jsonl new file mode 100644 index 000000000..018351e75 --- /dev/null +++ b/tests/unit/sessions/data/codex_session.jsonl @@ -0,0 +1 @@ +{"type":"assistant","session_id":"sess-1","cwd":"C:\\Users\\x\\repo","model":"gpt-5-codex","message_id":"msg_01","text":"Searching the codebase.","tool_calls":[{"id":"toolu_01","name":"search_codebase","input":{"query":"repowise"}}]} diff --git a/tests/unit/sessions/test_codex_adapter.py b/tests/unit/sessions/test_codex_adapter.py new file mode 100644 index 000000000..9a5779dc1 --- /dev/null +++ b/tests/unit/sessions/test_codex_adapter.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from repowise.core.sessions import CodexAdapter, Event + +FIXTURE = Path(__file__).parent / "data" / "codex_session.jsonl" + +ADAPTER = CodexAdapter() + + +def test_discover_lists_jsonl_sorted(tmp_path: Path) -> None: + root = tmp_path / "sessions" + (root / "2026" / "07" / "13").mkdir(parents=True) + (root / "2026" / "07" / "13" / "b.jsonl").write_text("", encoding="utf-8") + (root / "2026" / "07" / "13" / "a.jsonl").write_text("", encoding="utf-8") + (root / "2026" / "07" / "13" / "notes.txt").write_text("", encoding="utf-8") + + found = ADAPTER.discover(Path("/tmp/repo"), projects_root=root) + + assert [p.name for p in found] == ["a.jsonl", "b.jsonl"] + + +def test_normalize_maps_common_fields_and_tools() -> None: + event = ADAPTER.normalize((FIXTURE.read_text(encoding="utf-8").splitlines()[0])) + + assert isinstance(event, Event) + assert event.kind == "assistant" + assert event.session_id == "sess-1" + assert event.cwd == r"C:\Users\x\repo" + assert event.model == "gpt-5-codex" + assert event.message_id == "msg_01" + assert event.text == "Searching the codebase." + assert event.tool_uses[0].name == "search_codebase" + + +def test_normalize_handles_session_meta_and_custom_tool_payloads() -> None: + session_meta = json.dumps( + { + "timestamp": "2026-07-12T14:39:30.546Z", + "type": "session_meta", + "payload": { + "session_id": "sess-2", + "id": "sess-2", + "timestamp": "2026-07-12T14:39:30.546Z", + "cwd": r"C:\Users\x\repo", + }, + } + ) + tool_call = json.dumps( + { + "timestamp": "2026-07-12T14:39:31.000Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "id": "ctc_1", + "call_id": "call_1", + "name": "exec", + "input": {"command": "pwd"}, + }, + } + ) + tool_output = json.dumps( + { + "timestamp": "2026-07-12T14:39:31.100Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call_1", + "output": [{"type": "input_text", "text": "done"}], + }, + } + ) + + meta_event = ADAPTER.normalize(session_meta) + call_event = ADAPTER.normalize(tool_call) + output_event = ADAPTER.normalize(tool_output) + + assert meta_event is not None + assert meta_event.kind == "session_meta" + assert meta_event.session_id == "sess-2" + assert meta_event.cwd == r"C:\Users\x\repo" + assert meta_event.is_meta is True + + assert call_event is not None + assert call_event.tool_uses[0].name == "bash" + assert call_event.tool_uses[0].id == "call_1" + + assert output_event is not None + assert output_event.tool_results[0].tool_use_id == "call_1" + assert output_event.text == "done" From 26ccbb3d3e2aa01489ba0e33a77df7f8975c008e Mon Sep 17 00:00:00 2001 From: rehan Date: Mon, 13 Jul 2026 11:39:57 +0530 Subject: [PATCH 2/5] chore(sessions): fix ruff lint issue in Codex adapter test --- tests/unit/sessions/test_codex_adapter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/sessions/test_codex_adapter.py b/tests/unit/sessions/test_codex_adapter.py index 9a5779dc1..08ce39514 100644 --- a/tests/unit/sessions/test_codex_adapter.py +++ b/tests/unit/sessions/test_codex_adapter.py @@ -23,7 +23,7 @@ def test_discover_lists_jsonl_sorted(tmp_path: Path) -> None: def test_normalize_maps_common_fields_and_tools() -> None: - event = ADAPTER.normalize((FIXTURE.read_text(encoding="utf-8").splitlines()[0])) + event = ADAPTER.normalize(FIXTURE.read_text(encoding="utf-8").splitlines()[0]) assert isinstance(event, Event) assert event.kind == "assistant" From 6ac52f0b60bd9727c21ef34d8834952ddbccfa0c Mon Sep 17 00:00:00 2001 From: rehan Date: Tue, 14 Jul 2026 15:07:35 +0530 Subject: [PATCH 3/5] fix(sessions): use non-recursive glob for Codex transcripts --- packages/core/src/repowise/core/sessions/adapters/codex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/repowise/core/sessions/adapters/codex.py b/packages/core/src/repowise/core/sessions/adapters/codex.py index 3ed9f44b1..d9aa6db6b 100644 --- a/packages/core/src/repowise/core/sessions/adapters/codex.py +++ b/packages/core/src/repowise/core/sessions/adapters/codex.py @@ -26,7 +26,7 @@ def discover(self, repo_root: Path, *, projects_root: Path | None = None) -> lis root = projects_root if projects_root is not None else Path.home() / ".codex" / "sessions" if not root.is_dir(): return [] - return sorted(path for path in root.rglob("*.jsonl") if path.is_file()) + return sorted(path for path in root.glob("*.jsonl") if path.is_file()) def normalize(self, raw_line: str) -> Event | None: try: From 8866c3880bdb3b85cd0543fae8410ff548335fbe Mon Sep 17 00:00:00 2001 From: rehan Date: Tue, 14 Jul 2026 21:44:21 +0530 Subject: [PATCH 4/5] fix(sessions): restore recursive codex session discovery --- .../src/repowise/core/sessions/adapters/codex.py | 13 +++---------- tests/unit/sessions/data/codex_session.jsonl | 2 +- tests/unit/sessions/test_codex_adapter.py | 2 +- 3 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/core/src/repowise/core/sessions/adapters/codex.py b/packages/core/src/repowise/core/sessions/adapters/codex.py index d9aa6db6b..4f4e6eabb 100644 --- a/packages/core/src/repowise/core/sessions/adapters/codex.py +++ b/packages/core/src/repowise/core/sessions/adapters/codex.py @@ -15,6 +15,8 @@ from repowise.core.sessions.adapters.base import HarnessAdapter from repowise.core.sessions.events import Event, ToolResult, ToolUse +from repowise.core.sessions.adapters.claude_code import parse_timestamp +from repowise.core.fs_walk import iter_glob class CodexAdapter(HarnessAdapter): @@ -26,7 +28,7 @@ def discover(self, repo_root: Path, *, projects_root: Path | None = None) -> lis root = projects_root if projects_root is not None else Path.home() / ".codex" / "sessions" if not root.is_dir(): return [] - return sorted(path for path in root.glob("*.jsonl") if path.is_file()) + return sorted(path for path in iter_glob(root, "*.jsonl") if path.is_file()) def normalize(self, raw_line: str) -> Event | None: try: @@ -194,15 +196,6 @@ def _extract_text(*candidates: Any) -> str: return "" -def parse_timestamp(value: Any) -> float | None: - if not isinstance(value, str): - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() - except ValueError: - return None - - def _first_non_empty_str(*values: Any) -> str | None: for value in values: if isinstance(value, str) and value: diff --git a/tests/unit/sessions/data/codex_session.jsonl b/tests/unit/sessions/data/codex_session.jsonl index 018351e75..09e70e999 100644 --- a/tests/unit/sessions/data/codex_session.jsonl +++ b/tests/unit/sessions/data/codex_session.jsonl @@ -1 +1 @@ -{"type":"assistant","session_id":"sess-1","cwd":"C:\\Users\\x\\repo","model":"gpt-5-codex","message_id":"msg_01","text":"Searching the codebase.","tool_calls":[{"id":"toolu_01","name":"search_codebase","input":{"query":"repowise"}}]} +{"type":"assistant","session_id":"sess-1","cwd":"C:\\Users\\x\\repo","model":"gpt-5-codex","message_id":"msg_01","text":"Searching the codebase.","tool_calls":[{"id":"toolu_01","name":"search_codebase","input":{"query":"repowise"}}]} \ No newline at end of file diff --git a/tests/unit/sessions/test_codex_adapter.py b/tests/unit/sessions/test_codex_adapter.py index 08ce39514..e8d048e12 100644 --- a/tests/unit/sessions/test_codex_adapter.py +++ b/tests/unit/sessions/test_codex_adapter.py @@ -23,7 +23,7 @@ def test_discover_lists_jsonl_sorted(tmp_path: Path) -> None: def test_normalize_maps_common_fields_and_tools() -> None: - event = ADAPTER.normalize(FIXTURE.read_text(encoding="utf-8").splitlines()[0]) + lines = FIXTURE.read_text(encoding="utf-8").splitlines() assert isinstance(event, Event) assert event.kind == "assistant" From 676df0d5ddbffdcaf2ba35403d5156987d4ac03e Mon Sep 17 00:00:00 2001 From: rehan Date: Wed, 15 Jul 2026 16:04:05 +0530 Subject: [PATCH 5/5] test(sessions): add end-to-end Codex adapter miner coverage --- tests/unit/sessions/data/codex_session.jsonl | 16 ++++- tests/unit/sessions/test_codex_adapter.py | 71 +++++++++++++++++--- 2 files changed, 76 insertions(+), 11 deletions(-) diff --git a/tests/unit/sessions/data/codex_session.jsonl b/tests/unit/sessions/data/codex_session.jsonl index 09e70e999..7abc96a81 100644 --- a/tests/unit/sessions/data/codex_session.jsonl +++ b/tests/unit/sessions/data/codex_session.jsonl @@ -1 +1,15 @@ -{"type":"assistant","session_id":"sess-1","cwd":"C:\\Users\\x\\repo","model":"gpt-5-codex","message_id":"msg_01","text":"Searching the codebase.","tool_calls":[{"id":"toolu_01","name":"search_codebase","input":{"query":"repowise"}}]} \ No newline at end of file +{"timestamp":"2026-07-12T14:39:30.546Z","type":"session_meta","payload":{"session_id":"sess-1","id":"sess-1","timestamp":"2026-07-12T14:39:30.546Z","cwd":"C:\\Users\\x\\repo"}} +{"timestamp":"2026-07-12T14:39:31.000Z","type":"response_item","payload":{"type":"message","role":"assistant","id":"msg_01","model":"gpt-5-codex","content":[{"type":"output_text","text":"Searching the codebase."}]}} +{"timestamp":"2026-07-12T14:39:31.050Z","type":"response_item","payload":{"type":"custom_tool_call","id":"tool_01","call_id":"call_01","name":"search","input":{"query":"repowise","path":"pkg/app.py"}}} +{"timestamp":"2026-07-12T14:39:31.100Z","type":"response_item","payload":{"type":"custom_tool_call_output","call_id":"call_01","output":[{"type":"output_text","text":"{\"results\":[{\"file\":\"pkg/app.py\"}]}"}]}} +{"timestamp":"2026-07-14T17:57:24.353Z","type":"session_meta","payload":{"cwd":"C:\\Users\\x\\repo","source":"vscode"}} +{"timestamp":"2026-07-14T17:57:27.329Z","type":"event_msg","payload":{"type":"user_message","message":"Search for app.py and explain what it does"}} +{"timestamp":"2026-07-14T17:57:30.975Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"I'll search the project for app.py and explain its purpose."}]}} +{"timestamp":"2026-07-14T17:57:31.000Z","type":"response_item","payload":{"type":"custom_tool_call","name":"exec","input":"rg --files -g app.py"}} +{"timestamp":"2026-07-14T17:57:31.728Z","type":"response_item","payload":{"type":"custom_tool_call_output","output":"outputs/sqlite-signup-demo/app.py"}} +{"timestamp":"2026-07-14T17:57:34.375Z","type":"response_item","payload":{"type":"custom_tool_call","name":"exec","input":"Get-Content outputs/sqlite-signup-demo/app.py"}} +{"timestamp":"2026-07-14T17:57:35.100Z","type":"response_item","payload":{"type":"custom_tool_call_output","output":""}} +{"timestamp":"2026-07-14T17:58:58.000Z","type":"event_msg","payload":{"type":"user_message","message":"Edit app.py to add a comment explaining the endpoints so the code is easier to read."}} +{"timestamp":"2026-07-14T17:59:06.357Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"We chose Flask because it is lightweight, keeps SQLite integration straightforward, and avoids unnecessary boilerplate for this small application."}]}} +{"timestamp":"2026-07-14T17:59:09.075Z","type":"response_item","payload":{"type":"custom_tool_call","name":"exec","input":"apply_patch"}} +{"timestamp":"2026-07-14T17:59:11.544Z","type":"response_item","payload":{"type":"custom_tool_call_output","output":"Success. Updated app.py"}} \ No newline at end of file diff --git a/tests/unit/sessions/test_codex_adapter.py b/tests/unit/sessions/test_codex_adapter.py index e8d048e12..1928e147e 100644 --- a/tests/unit/sessions/test_codex_adapter.py +++ b/tests/unit/sessions/test_codex_adapter.py @@ -5,7 +5,11 @@ from repowise.core.sessions import CodexAdapter, Event +from repowise.core.sessions.miners.demand import mine_events_demand +from repowise.core.sessions.miners.decisions import mine_events + FIXTURE = Path(__file__).parent / "data" / "codex_session.jsonl" +REPO_PREFIX = "c:\\users\\x\\repo" ADAPTER = CodexAdapter() @@ -22,19 +26,35 @@ def test_discover_lists_jsonl_sorted(tmp_path: Path) -> None: assert [p.name for p in found] == ["a.jsonl", "b.jsonl"] -def test_normalize_maps_common_fields_and_tools() -> None: +def test_normalize_real_codex_rollout() -> None: lines = FIXTURE.read_text(encoding="utf-8").splitlines() - assert isinstance(event, Event) - assert event.kind == "assistant" - assert event.session_id == "sess-1" - assert event.cwd == r"C:\Users\x\repo" - assert event.model == "gpt-5-codex" - assert event.message_id == "msg_01" - assert event.text == "Searching the codebase." - assert event.tool_uses[0].name == "search_codebase" - + meta = ADAPTER.normalize(lines[0]) + message = ADAPTER.normalize(lines[1]) + tool = ADAPTER.normalize(lines[2]) + + assert isinstance(meta, Event) + assert meta.kind == "session_meta" + assert meta.session_id == "sess-1" + assert meta.cwd == r"C:\Users\x\repo" + assert meta.is_meta is True + assert meta.text == "" + + assert isinstance(message, Event) + assert message.kind == "assistant" + assert message.message_id == "msg_01" + assert message.model == "gpt-5-codex" + assert message.text == "Searching the codebase." + + assert isinstance(tool, Event) + assert tool.kind == "assistant" + assert tool.tool_uses[0].name == "search_codebase" + assert tool.tool_uses[0].id == "call_01" + assert tool.tool_uses[0].input == { + "query": "repowise", + "path": "pkg/app.py", + } def test_normalize_handles_session_meta_and_custom_tool_payloads() -> None: session_meta = json.dumps( { @@ -90,3 +110,34 @@ def test_normalize_handles_session_meta_and_custom_tool_payloads() -> None: assert output_event is not None assert output_event.tool_results[0].tool_use_id == "call_1" assert output_event.text == "done" + + +def test_codex_rollout_feeds_demand_miner() -> None: + events = [ + event + for line in FIXTURE.read_text(encoding="utf-8").splitlines() + if (event := ADAPTER.normalize(line)) is not None + ] + + demand = mine_events_demand(events, REPO_PREFIX) + + assert demand + assert demand == {"pkg/app.py": 1} + +def test_codex_rollout_feeds_decision_miner() -> None: + events = [ + event + for line in FIXTURE.read_text(encoding="utf-8").splitlines() + if (event := ADAPTER.normalize(line)) is not None + ] + + decisions = mine_events(events, REPO_PREFIX) + + assert len(decisions) == 1 + + decision = decisions[0] + assert decision.kind == "explicit_choice" + assert decision.files == ["pkg/app.py"] + assert decision.quotes == [ + "We chose Flask because it is lightweight, keeps SQLite integration straightforward, and avoids unnecessary boilerplate for this small application." + ] \ No newline at end of file