fix(codex): retag threads on init so Codex Desktop history stays visible (#961) (#1349)

## Description

Installing Headroom for Codex via `headroom init` can make Codex Desktop
appear to lose its local chat/thread history. The data is never deleted
— Codex filters its sidebar/search by the active `model_provider`, and
the init path set `model_provider = "headroom"` without retagging
existing threads, so native `openai` threads disappeared from the menu.

The install (`headroom.providers.codex.install`) and wrap
(`headroom.cli.wrap`) paths already reconcile thread provider tags
across the proxy boundary (retag `openai -> headroom` on enable). The
init path — `_ensure_codex_provider` in `headroom/cli/init.py`, which is
exactly what the issue reproduces ("Headroom init proxy" provider,
`headroom-init-codex` hook) — was the one place that injected the
provider without retagging. This wires the same reconciliation into the
init path. The revert direction is already handled by `headroom unwrap
codex`.

Closes #961

## 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

- `headroom/cli/init.py`: `_ensure_codex_provider` now calls
`retag_to_headroom(path.parent)` after writing the init provider block,
so existing native threads stay visible under the active `headroom`
provider. Third-party providers (e.g. `anthropic`) are left untouched
(existing `retag_thread_providers` behaviour).
- `tests/test_cli/test_init_cli.py`: regression test seeding a Codex
Desktop `state_5.sqlite` and asserting `_ensure_codex_provider` retags
`openai -> headroom` while leaving other providers alone.
- `CHANGELOG.md`: Unreleased → Bug Fixes entry.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_cli/test_init_cli.py tests/test_provider_codex_threads.py tests/test_provider_codex_install.py -q
84 passed in 0.84s

$ uv run ruff check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!

$ uv run mypy headroom/cli/init.py
Success: no issues found in 1 source file
```

#### RED → GREEN proof

RED — new test with the prod fix (the `retag_to_headroom` call)
reverted:
```text
E   AssertionError: existing openai threads not retagged: {'anthropic': 1, 'openai': 2}
1 failed in 0.44s
```
GREEN — with the fix applied:
```text
tests/test_cli/test_init_cli.py::test_init_codex_provider_retags_existing_threads
1 passed in 0.36s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: seed a Codex Desktop store
(`<codex_home>/sqlite/state_5.sqlite`) with native threads, then run the
init provider injection:
  ```text
  before init: {'anthropic': 1, 'openai': 2}
  after  init: {'anthropic': 1, 'headroom': 2}
config model_provider line: ['model_provider = "headroom"',
'[model_providers.headroom]']
  ```
- Observed result: after init, the two `openai` threads are retagged to
`headroom` (so they stay visible under the now-active provider), while
the `anthropic` thread is left untouched. Before the fix they stayed
`openai` and were filtered out of Codex Desktop's menu.
- Not tested: the live Codex Desktop GUI itself (proprietary, no
sandbox); the filtering behaviour is the documented `thread/list`
provider filter described in the issue, and the store-level retag that
makes history visible is covered above and by the unit test.

## 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
- [x] 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
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

Scope is limited to the init provider path; the install and wrap paths
already perform this reconciliation. Screenshots N/A (no UI change on
Headroom's side).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
This commit is contained in:
Ben Younes 2026-06-24 17:14:40 +02:00 committed by GitHub
parent feedead077
commit e6bbc40b11
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 50 additions and 0 deletions

View file

@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **codex:** retag existing Codex threads when `headroom init` injects the `headroom` provider, so Codex Desktop history stays visible. Codex filters its sidebar/search by the active `model_provider`; the init path set `model_provider = "headroom"` without retagging, so existing native `openai` threads disappeared from the menu (data was never deleted, only hidden). `_ensure_codex_provider` now reconciles thread tags openai→headroom, matching what the install and `wrap` paths already do; `headroom unwrap codex` handles the revert direction ([#961](https://github.com/chopratejas/headroom/issues/961)).
* **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)).
* **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)).
* **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)).

View file

@ -42,6 +42,7 @@ from headroom.install.state import load_manifest, save_manifest
from headroom.install.supervisors import start_supervisor
from headroom.providers.claude import TOOL_SEARCH_DEFAULT, TOOL_SEARCH_ENV
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom
from .main import main
@ -333,6 +334,12 @@ def _ensure_codex_provider(path: Path, port: int) -> None:
)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
# Codex filters its history menu by the active model_provider, so existing
# native threads vanish once we switch to "headroom". Retag them to match the
# active provider so the history stays whole (#961), mirroring the install
# (providers.codex.install) and wrap (cli.wrap) paths. The revert direction is
# handled by `headroom unwrap codex`.
retag_to_headroom(path.parent)
def _codex_feature_block() -> str:

View file

@ -1404,6 +1404,48 @@ def test_init_codex_writes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
)
def test_init_codex_provider_retags_existing_threads(monkeypatch, tmp_path: Path) -> None:
"""`headroom init` injects `model_provider = "headroom"` for Codex, which
Codex Desktop filters its history menu by. Without retagging, existing native
`openai` threads vanish from the sidebar/search (#961). `_ensure_codex_provider`
must retag existing threads openai->headroom so the history stays visible
the same reconciliation the install and wrap paths already perform."""
import sqlite3
init_cli, _ = _load_init_module(monkeypatch)
codex_home = tmp_path / ".codex"
config_path = codex_home / "config.toml"
# Codex Desktop reads <codex_home>/sqlite/state_5.sqlite.
db = codex_home / "sqlite" / "state_5.sqlite"
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db))
try:
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL)")
conn.executemany(
"INSERT INTO threads (id, model_provider) VALUES (?, ?)",
[("t1", "openai"), ("t2", "openai"), ("t3", "anthropic")],
)
conn.commit()
finally:
conn.close()
init_cli._ensure_codex_provider(config_path, 8787)
conn = sqlite3.connect(str(db))
try:
counts = dict(
conn.execute("SELECT model_provider, COUNT(*) FROM threads GROUP BY model_provider")
)
finally:
conn.close()
# Native threads now live under the active headroom provider (stay visible);
# third-party providers are left untouched.
assert counts.get("headroom") == 2, f"existing openai threads not retagged: {counts}"
assert counts.get("openai", 0) == 0, f"openai threads still hidden: {counts}"
assert counts.get("anthropic") == 1, f"third-party provider must be left alone: {counts}"
def test_init_codex_strip_removes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
"""_strip_codex_init_block must remove both the managed block and any orphaned
openai_base_url lines left by a crashed or partial init."""