headroom/tests/test_provider_codex_threads.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

77 lines
2.5 KiB
Python
Raw Normal View History

fix(codex): retag thread providers so history menu stays whole across the proxy boundary (#1034) ## Description Codex stamps every thread with the `model_provider` it ran under and filters its history/projects menu by the currently active provider set. When Headroom rewrites Codex's config to route through the custom `headroom` provider, threads created through Headroom are tagged `headroom` while native threads keep `openai` — so the two sets never appear in the same menu. The visible symptom: enabling Headroom appears to "lose" the entire native Codex history, and disabling it hides everything created while wrapped. This reconciles the thread tags in Codex's SQLite store alongside the existing config edits, so the menu stays whole across the proxy boundary in both directions: `openai -> headroom` on enable/wrap, `headroom -> openai` on revert/unwrap. Only rows matching the source provider are touched, so third-party providers (e.g. `anthropic`) are left alone. The provider key cannot be unified by config — Codex rejects naming a custom provider `openai` ("reserved built-in provider IDs") — so retagging the store is the only path. A DB-only retag is sufficient: resuming a retagged session still routes completions through the active provider; the rollout `.jsonl` files do not need rewriting. Every operation is best-effort: a missing store, a missing `threads` table, or a corrupt store is logged and skipped, never raised, so install/uninstall and wrap/unwrap never fail on account of the history menu. The store is WAL-mode, so the update succeeds even while Codex is running; the short busy timeout only covers a transient checkpoint lock. Closes # ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - New `headroom/providers/codex/threads.py`: best-effort retag of Codex thread provider tags across both known stores (`<codex_home>/sqlite/state_5.sqlite` for the GUI and `<codex_home>/state_5.sqlite` for the CLI/TUI). `retag_to_headroom` / `retag_to_native` wrap the directional helper. `codex_home` is passed in by callers (never re-derived from `Path.home()`), so tests stay pointed at a temp dir. - `providers/codex/install.py`: `apply_provider_scope` calls `retag_to_headroom` after writing the provider block; `revert_provider_scope` calls `retag_to_native` after stripping it. - `cli/wrap.py`: `_inject_codex_provider_config` calls `retag_to_headroom`; `unwrap_codex` calls `retag_to_native` once the config restore reports a `restored`/`cleaned`/`removed` status. - Tests: `tests/test_provider_codex_threads.py` (retag direction, threads-table no-op, missing/corrupt store best-effort) and a wrap/unwrap round-trip integration test in `tests/test_cli/test_wrap_codex.py`. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ uv run --extra dev pytest tests/test_provider_codex_threads.py tests/test_cli/test_wrap_codex.py tests/test_install/test_providers.py -q ... 88 passed, 2 failed # The 2 failures are TestInjectAvoidsDuplicateTopLevelKeys::* — pre-existing on a clean # upstream/main checkout, unrelated to this change: they `import tomllib`, which is # stdlib only on Python 3.11+, and this environment runs Python 3.10.18. $ uv run --extra dev ruff check headroom/cli/wrap.py headroom/providers/codex/threads.py \ headroom/providers/codex/install.py tests/test_cli/test_wrap_codex.py tests/test_provider_codex_threads.py All checks passed! $ uv run --extra dev mypy headroom/providers/codex/threads.py headroom/providers/codex/install.py Success: no issues found in 2 source files ``` ## Real Behavior Proof - Environment: macOS, Codex GUI v148 + Codex CLI, Python 3.10.18. - Exact command / steps: The root cause and fix were confirmed live in the Headroom desktop app, which performs the identical SQLite retag. Connecting Codex to Headroom hid ~140 native (`openai`) threads from the history menu; running `UPDATE threads SET model_provider='headroom' WHERE model_provider='openai'` on the live store (`~/.codex/sqlite/state_5.sqlite`) made the full menu reappear, and resuming a retagged session still routed completions through the active provider. This Python port is a 1:1 of that logic, exercised by the unit + wrap/unwrap integration tests above. - Observed result: full Codex history menu restored across enable/disable; third-party provider rows untouched; the real `~/.codex` stores were snapshotted before/after the test run and were not mutated by the tests. - Not tested: an end-to-end `headroom wrap codex` run against a live Codex GUI in this CI environment (no Codex install here); covered instead by the integration test invoking the real `wrap`/`unwrap` Click commands against a temp `$HOME`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Screenshots (if applicable) N/A — behavior is in Codex's own history menu; covered by the proof above. ## Additional Notes - Documentation / CHANGELOG: N/A — internal behavior with no user-facing surface beyond the restored menu. - The 2 failing `TestInjectAvoidsDuplicateTopLevelKeys` tests are pre-existing on upstream/main and fail only because this environment runs Python 3.10 (no `tomllib`); they are unrelated to this change. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 22:13:21 +02:00
from __future__ import annotations
import sqlite3
from pathlib import Path
from headroom.providers.codex import threads
def _seed(path: Path, rows: list[tuple[str, str]]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL)")
conn.executemany("INSERT INTO threads (id, model_provider) VALUES (?, ?)", rows)
conn.commit()
finally:
conn.close()
def _count(path: Path, provider: str) -> int:
conn = sqlite3.connect(str(path))
try:
(n,) = conn.execute(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?", (provider,)
).fetchone()
return n
finally:
conn.close()
def test_retag_one_moves_only_matching_provider(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
_seed(db, [("a", "openai"), ("b", "openai"), ("c", "headroom"), ("d", "anthropic")])
moved = threads._retag_one(db, frm="openai", to="headroom")
assert moved == 2
assert _count(db, "openai") == 0
assert _count(db, "headroom") == 3
# Third-party providers are left alone.
assert _count(db, "anthropic") == 1
back = threads._retag_one(db, frm="headroom", to="openai")
assert back == 3
assert _count(db, "headroom") == 0
assert _count(db, "openai") == 3
assert _count(db, "anthropic") == 1
def test_retag_one_noop_without_threads_table(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
sqlite3.connect(str(db)).close() # empty schema, no threads table
assert threads._retag_one(db, frm="openai", to="headroom") == 0
def test_retag_thread_providers_silent_when_no_store(tmp_path: Path) -> None:
# No stores exist under this codex_home: must not raise.
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
def test_retag_thread_providers_best_effort_on_corrupt_store(tmp_path: Path) -> None:
bad = tmp_path / "state_5.sqlite"
bad.write_text("not a sqlite database", encoding="utf-8")
# A corrupt store is logged and skipped, never raised.
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
def test_enable_disable_wrappers_retag_expected_direction(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
_seed(db, [("a", "openai"), ("b", "headroom")])
threads.retag_to_headroom(tmp_path)
assert _count(db, "headroom") == 2
assert _count(db, "openai") == 0
threads.retag_to_native(tmp_path)
assert _count(db, "openai") == 2
assert _count(db, "headroom") == 0