diff --git a/.agents/skills/lib_py/agents/adapters/codex.py b/.agents/skills/lib_py/agents/adapters/codex.py index 4b4fa53..d330550 100644 --- a/.agents/skills/lib_py/agents/adapters/codex.py +++ b/.agents/skills/lib_py/agents/adapters/codex.py @@ -1,6 +1,6 @@ -import os, json, glob, sqlite3, subprocess +import os, json, glob, shutil, sqlite3, subprocess from typing import Optional, Any, List -from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _ro_connect, scan_names, db_rows_naming +from lib_py.agents.base import BaseAgentAdapter, DiscoveryContext, _plausible_id, _ro_connect, scan_names, db_rows_naming from lib_py.verify_session import workspace_key class CodexAgentAdapter(BaseAgentAdapter): @@ -131,6 +131,22 @@ class CodexAgentAdapter(BaseAgentAdapter): except Exception: return False + def _purge_tui_thread_capability(self, uuid: str, ctx: DiscoveryContext, purged: List[str]) -> None: + if not _plausible_id(uuid): + return + cap = os.path.join(self._codex_home(ctx), "tui-thread-reference-capabilities", uuid) + if not os.path.lexists(cap): + return + try: + if os.path.isdir(cap) and not os.path.islink(cap): + shutil.rmtree(cap) + else: + os.unlink(cap) + if not os.path.lexists(cap): + purged.append(cap) + except Exception: + pass + def purge_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]: purged = [] path = self._find_rollout(uuid, ctx) @@ -140,12 +156,16 @@ class CodexAgentAdapter(BaseAgentAdapter): if res.returncode == 0: if path: purged.append(path) + self._purge_tui_thread_capability(uuid, ctx, purged) return purged except Exception: pass if path and os.path.exists(path): - os.remove(path) - purged.append(path) + try: + os.remove(path) + purged.append(path) + except Exception: + pass db = self._state_db(ctx) if db and os.path.exists(db): try: @@ -156,6 +176,7 @@ class CodexAgentAdapter(BaseAgentAdapter): purged.append(f"state db row for thread: {uuid}") except Exception: pass + self._purge_tui_thread_capability(uuid, ctx, purged) return purged def residual_artifacts(self, uuid: str, ctx: DiscoveryContext) -> List[str]: diff --git a/tests/test_issue78_hints_codex.py b/tests/test_issue78_hints_codex.py index a19d12f..efce778 100644 --- a/tests/test_issue78_hints_codex.py +++ b/tests/test_issue78_hints_codex.py @@ -249,3 +249,76 @@ def test_stop_purge_refusal_prints_holders(mam_sandbox, mock_herdr, monkeypatch) finally: holder.kill() holder.wait() + + +@pytest.mark.parametrize("delete_ok", [True, False], ids=["delete_ok", "delete_fails"]) +def test_codex_purge_removes_tui_thread_capability(tmp_path, monkeypatch, delete_ok): + home = tmp_path / "home" + codex_home = tmp_path / "codex-home" + ws = tmp_path / "ws" + bindir = tmp_path / "bin" + for d in (home, codex_home, ws, bindir): + d.mkdir(parents=True, exist_ok=True) + + conv = str(uuidlib.uuid4()) + other = str(uuidlib.uuid4()) + + rollout = codex_home / "sessions" / "2026" / "10" / "28" / f"rollout-2026-10-28T00-00-00-{conv}.jsonl" + rollout.parent.mkdir(parents=True, exist_ok=True) + rollout.write_text(json.dumps({"payload": {"id": conv, "cwd": str(ws)}}) + "\n", encoding="utf-8") + + cap_dir = codex_home / "tui-thread-reference-capabilities" + cap_dir.mkdir(parents=True, exist_ok=True) + cap_mine = cap_dir / conv + cap_other = cap_dir / other + cap_mine.write_text("cap-mine", encoding="utf-8") + cap_other.write_text("cap-other", encoding="utf-8") + + state_db = codex_home / "state_5.sqlite" + conn = sqlite3.connect(str(state_db)) + conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, rollout_path TEXT, cwd TEXT)") + conn.execute("INSERT INTO threads VALUES (?, ?, ?)", (conv, str(rollout), str(ws))) + conn.execute("INSERT INTO threads VALUES (?, ?, ?)", (other, "", str(ws))) + conn.commit() + conn.close() + + fake_codex = bindir / "codex" + if delete_ok: + # Simulate `codex delete --force` removing rollout + DB row, leaving tui-thread-reference-capabilities. + fake_codex.write_text( + f"#!{sys.executable}\n" + "import os, sqlite3, sys\n" + "if len(sys.argv) >= 3 and sys.argv[1] == 'delete':\n" + f" p = {str(rollout)!r}\n" + " if os.path.exists(p):\n" + " os.remove(p)\n" + f" conn = sqlite3.connect({str(state_db)!r})\n" + " conn.execute('DELETE FROM threads WHERE id=?', (sys.argv[2],))\n" + " conn.commit()\n" + " conn.close()\n" + " sys.exit(0)\n" + "sys.exit(1)\n", + encoding="utf-8", + ) + else: + fake_codex.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + fake_codex.chmod(0o755) + + monkeypatch.setenv("HOME", str(home)) + monkeypatch.setenv("HOME_DIR", str(home)) + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + monkeypatch.setenv("PATH", f"{bindir}:{os.environ.get('PATH', '')}") + + adapter = get_adapter("codex") + ctx = DiscoveryContext(workspace=str(ws), agent_name="codex", home_dir=str(home)) + assert str(cap_mine) in adapter.residual_artifacts(conv, ctx) + + purged = adapter.purge_artifacts(conv, ctx) + + assert str(cap_mine) in purged + assert not cap_mine.exists() + assert cap_other.exists() + assert not rollout.exists() + assert adapter.residual_artifacts(conv, ctx) == [] + assert str(cap_other) in adapter.residual_artifacts(other, ctx) +