---
docType: tasks
slice: review-transport-unification-provider-decoupling
project: squadron
lld: user/slices/128-slice.review-transport-unification-provider-decoupling.md
dependencies: [review-provider-model-selection, auth-strategy-credential-management]
projectState: "M1 shipped (v0.2.7). Review system has two bespoke transport paths (SDK via runner.py, non-SDK via AsyncOpenAI in review_client.py). Neither uses Agent/AgentProvider Protocols. String dispatch on profile names and auth types. Slice 124 (Codex) rewound — provider code not on main."
dateCreated: 20260327
dateUpdated: 20260327
status: complete
---

## Context Summary
- Working on slice 128: Review Transport Unification & Provider Decoupling
- Review system bypasses Agent/AgentProvider Protocols — directly instantiates AsyncOpenAI and ClaudeSDKClient
- String-based dispatch (`if profile == "sdk"`, `if auth_type == "codex"`) throughout codebase
- Goal: reviews use `Agent.handle_message()` via provider registry; providers declare capabilities; no string dispatch
- Codex provider (from rewound slice 124) rebuilt as part of this slice, now usable for reviews
- Key files: `review_client.py`, `runner.py` (to delete), `auth.py`, `base.py`, `cli/commands/auth.py`
- Existing providers to update: `sdk/`, `openai/`; new provider: `codex/`

---

## Tasks

### T1: ProviderCapabilities dataclass and AgentProvider Protocol update

- [x] **Add `ProviderCapabilities` to `src/squadron/providers/base.py`**
  - [x] Create frozen dataclass: `can_read_files: bool = False`, `supports_system_prompt: bool = True`, `supports_streaming: bool = False`
  - [x] Add `capabilities` property to `AgentProvider` Protocol
  - [x] Update `__all__` exports
- [x] **Add `capabilities` to `OpenAICompatibleProvider`**
  - [x] Return `ProviderCapabilities(can_read_files=False, supports_system_prompt=True, supports_streaming=True)`
- [x] **Add `capabilities` to `SDKAgentProvider` (pre-rename)**
  - [x] Return `ProviderCapabilities(can_read_files=True, supports_system_prompt=True, supports_streaming=True)`
- [x] Success: all providers expose `capabilities`; existing tests pass

### T2: ProviderCapabilities tests

- [x] **Add tests for ProviderCapabilities**
  - [x] Test: `ProviderCapabilities()` defaults are `can_read_files=False, supports_system_prompt=True, supports_streaming=False`
  - [x] Test: OpenAI provider `capabilities.can_read_files` is `False`
  - [x] Test: SDK provider `capabilities.can_read_files` is `True`
  - [x] Test: capabilities dataclass is frozen (assignment raises)
  - [x] Success: all tests pass

**Commit**: `feat: add ProviderCapabilities dataclass to AgentProvider Protocol`

---

### T3: Auth strategy `from_config` factory and registry-driven dispatch

- [x] **Add `from_config` classmethod to `ApiKeyStrategy`**
  - [x] Signature: `from_config(cls, config: AgentConfig, profile: ProviderProfile | None) -> ApiKeyStrategy`
  - [x] Extracts `explicit_key`, `env_var`, `fallback_env_var`, `base_url` from config and profile (same logic currently in `resolve_auth_strategy`)
- [x] **Add `active_source` property and `setup_hint` property to `ApiKeyStrategy`**
  - [x] `active_source`: returns the env var name or "explicit" or "localhost" depending on which source resolved
  - [x] `setup_hint`: returns actionable instruction (e.g., "Set OPENAI_API_KEY environment variable")
- [x] **Refactor `resolve_auth_strategy()` to use registry only**
  - [x] Remove the `if auth_type == "api_key"` and `if auth_type == "codex"` branches
  - [x] Use: `strategy_cls = AUTH_STRATEGIES[auth_type]; return strategy_cls.from_config(config, profile)`
  - [x] Raise `ProviderAuthError` for unknown auth_type (unchanged)
- [x] **Add `resolve_auth_strategy_for_profile()` convenience function**
  - [x] Takes `ProviderProfile` only (no `AgentConfig` needed for status checks)
  - [x] Constructs minimal config, delegates to `resolve_auth_strategy()`
- [x] Success: `resolve_auth_strategy()` has no if/elif on auth_type; all tests pass

### T4: Auth strategy refactor tests

- [x] **Update existing auth resolution tests in `tests/providers/test_auth_resolution.py`**
  - [x] Tests should still pass — `resolve_auth_strategy` returns same results
- [x] **Add tests for `from_config` classmethods**
  - [x] Test: `ApiKeyStrategy.from_config()` with profile containing `api_key_env`
  - [x] Test: `ApiKeyStrategy.from_config()` with explicit key in config
  - [x] Test: `ApiKeyStrategy.from_config()` with localhost base_url
- [x] **Add tests for `active_source` and `setup_hint`**
  - [x] Test: `active_source` returns env var name when key found via env var
  - [x] Test: `setup_hint` returns actionable message
- [x] **Test `resolve_auth_strategy_for_profile()`**
  - [x] Test: returns valid strategy for `openai` profile
  - [x] Test: returns valid strategy for `sdk` profile
- [x] Success: all auth tests pass; no string dispatch in `resolve_auth_strategy`

**Commit**: `refactor: registry-driven auth strategy dispatch with from_config factory`

---

### T5: OAuthFileStrategy (rename + from_config)

- [x] **Create `src/squadron/providers/codex/auth.py` with `OAuthFileStrategy`**
  - [x] Resolution order: `~/.codex/auth.json` (subscription) → `OPENAI_API_KEY` (fallback) → raise `ProviderAuthError`
  - [x] `from_config` classmethod (no-op — reads from fixed file path)
  - [x] `active_source` property: returns `"~/.codex/auth.json"` or `"OPENAI_API_KEY"` or `None`
  - [x] `setup_hint` property: returns `"Run 'codex' CLI to authenticate, or set OPENAI_API_KEY"`
  - [x] `is_valid()`, `get_credentials()`, `refresh_if_needed()` per AuthStrategy Protocol
- [x] **Register `"oauth"` in `AUTH_STRATEGIES` dict**
- [x] **Update codex profile in `BUILT_IN_PROFILES` (`profiles.py`)**
  - [x] Add `codex` profile with `provider="codex"`, `auth_type="oauth"`
- [x] Success: `OAuthFileStrategy` satisfies `AuthStrategy` Protocol; registered as `"oauth"`

### T6: OAuthFileStrategy tests

- [x] **Create `tests/providers/codex/test_auth.py`**
  - [x] Test: auth file exists → `is_valid()` True, `get_credentials()` returns `{"auth_file": path}`
  - [x] Test: no auth file, `OPENAI_API_KEY` set → `is_valid()` True, `get_credentials()` returns `{"api_key": value}`
  - [x] Test: neither source → `is_valid()` False, `get_credentials()` raises `ProviderAuthError`
  - [x] Test: auth file preferred over API key when both exist
  - [x] Test: `from_config` returns working strategy
  - [x] Test: `active_source` reports correct source
  - [x] Test: `setup_hint` returns actionable message
  - [x] Use `monkeypatch` for env vars and `tmp_path` for auth file fixture
- [x] Success: all tests pass

**Commit**: `feat: add OAuthFileStrategy for subscription credential resolution`

---

### T7: Rename SDKAgent → ClaudeSDKAgent, SDKAgentProvider → ClaudeSDKProvider

- [x] **Rename classes in `src/squadron/providers/sdk/agent.py`**
  - [x] `SDKAgent` → `ClaudeSDKAgent`
  - [x] Update all internal references
- [x] **Rename classes in `src/squadron/providers/sdk/provider.py`**
  - [x] `SDKAgentProvider` → `ClaudeSDKProvider`
  - [x] Update all internal references
- [x] **Update `src/squadron/providers/sdk/__init__.py`**
  - [x] Update imports and `__all__` exports
  - [x] Registration: `register_provider("sdk", ClaudeSDKProvider())` (key unchanged)
- [x] **Update all test files referencing old names**
  - [x] `tests/providers/sdk/test_agent.py`, `test_provider.py`, `test_registration.py`
- [x] **Update any other imports across codebase**
  - [x] Search for `SDKAgent` and `SDKAgentProvider` in all `.py` files
- [x] Success: `ruff check` clean; all tests pass; `get_provider("sdk")` returns `ClaudeSDKProvider`

### T8: Rename tests

- [x] **Verify all SDK tests pass after rename**
  - [x] `pytest tests/providers/sdk/ -v` — all green
  - [x] `pytest tests/ -v` — full suite green (no broken imports)
- [x] Success: zero test failures

**Commit**: `refactor: rename SDKAgent to ClaudeSDKAgent for clarity`

---

### T9: Codex provider implementation

- [x] **Create `src/squadron/providers/codex/agent.py` with `CodexAgent`**
  - [x] Implement `Agent` Protocol: `name`, `agent_type`, `state`, `handle_message()`, `shutdown()`
  - [x] `agent_type` returns `"codex"`
  - [x] Lazy MCP client initialization on first `handle_message()` call
  - [x] First message: start Codex session via `codex` MCP tool
  - [x] Subsequent messages: continue session via `codex-reply` with stored thread ID
  - [x] Convert Codex response to squadron `Message` objects
  - [x] `shutdown()`: clean up MCP client and subprocess
  - [x] Handle missing `codex` CLI with actionable error message
- [x] **Create `src/squadron/providers/codex/provider.py` with `CodexProvider`**
  - [x] Implement `AgentProvider` Protocol: `provider_type`, `capabilities`, `create_agent()`, `validate_credentials()`
  - [x] `capabilities`: `can_read_files=True, supports_system_prompt=False, supports_streaming=False`
  - [x] `create_agent()`: validate credentials via `OAuthFileStrategy`, return `CodexAgent`
  - [x] `validate_credentials()`: check `codex` CLI on PATH + credentials exist
- [x] **Create `src/squadron/providers/codex/__init__.py`**
  - [x] Auto-register: `register_provider("codex", CodexProvider())`
- [x] Success: `get_provider("codex")` returns `CodexProvider`; `CodexAgent` satisfies `Agent` Protocol

### T10: Codex provider tests

- [x] **Create `tests/providers/codex/test_agent.py`**
  - [x] Test: agent starts idle, transitions processing → idle during handle_message
  - [x] Test: first message initializes MCP client lazily (mock)
  - [x] Test: subsequent messages reuse thread (mock)
  - [x] Test: shutdown sets terminated, cleans up
  - [x] Test: handle_message yields Message with correct fields
  - [x] Test: missing codex CLI raises ProviderError with install instructions
- [x] **Create `tests/providers/codex/test_provider.py`**
  - [x] Test: `provider_type` returns `"codex"`
  - [x] Test: `capabilities.can_read_files` is `True`
  - [x] Test: `create_agent()` returns CodexAgent when credentials valid
  - [x] Test: `create_agent()` raises ProviderAuthError when no credentials
- [x] **Create `tests/providers/codex/test_registration.py`**
  - [x] Test: importing `squadron.providers.codex` registers `"codex"` in registry
  - [x] Test: `get_provider("codex")` returns CodexProvider instance
- [x] Success: all codex tests pass

**Commit**: `feat: add CodexProvider with MCP transport and OAuthFileStrategy`

---

### T11: Migrate SDK review path from runner.py into ClaudeSDKAgent

- [x] **Absorb `runner.py` rate-limit retry logic into `ClaudeSDKAgent.handle_message()`**
  - [x] The `ClaudeSDKClient` lifecycle (query + receive_response + rate-limit retry loop) from `runner.py` becomes the internal implementation of `ClaudeSDKAgent.handle_message()` in "query" mode
  - [x] Preserve `MAX_PARSE_RETRIES` and the `rate_limit_event` handling
  - [x] `handle_message()` must yield `Message` objects with the extracted text content
- [x] **Ensure `ClaudeSDKProvider.create_agent()` accepts review-relevant config**
  - [x] `AgentConfig.credentials.get("hooks")` → passed to `ClaudeAgentOptions` if present
  - [x] `allowed_tools`, `permission_mode`, `setting_sources` already on `AgentConfig` and consumed
- [x] **Delete `src/squadron/review/runner.py`**
- [x] **Remove `run_review` import from `review_client.py`**
- [x] Success: SDK agent handles the full review lifecycle internally; `runner.py` is gone

### T12: Migrate runner.py tests

- [x] **Migrate `tests/review/test_runner.py` tests to `tests/providers/sdk/test_agent.py`**
  - [x] Adapt test fixtures to use `ClaudeSDKAgent.handle_message()` instead of `run_review()`
  - [x] Preserve coverage for rate-limit retry behavior
  - [x] Preserve coverage for text extraction from SDK messages
- [x] **Delete `tests/review/test_runner.py`** (or empty it with a note pointing to new location)
- [x] Success: equivalent coverage exists in SDK agent tests; no test file references `runner.py`

**Commit**: `refactor: absorb runner.py SDK review path into ClaudeSDKAgent`

---

### T13: Unify review_client.py — replace bespoke transports with handle_message()

- [x] **Rewrite `run_review_with_profile()` to use provider registry**
  - [x] Look up profile: `profile_obj = get_profile(profile_name)`
  - [x] Load provider: ensure provider module imported (same pattern as `engine.py:_load_provider`)
  - [x] Get provider: `provider = get_provider(profile_obj.provider)`
  - [x] Check capabilities: `if not provider.capabilities.can_read_files: prompt = _inject_file_contents(prompt, inputs)`
  - [x] Build `AgentConfig` from profile, model, system_prompt, review template settings
  - [x] Create agent: `agent = await provider.create_agent(config)`
  - [x] Send message: collect all `Message` objects from `agent.handle_message(message)`
  - [x] Extract raw text, shut down agent
  - [x] Pass to `parse_review_output()` (unchanged)
- [x] **Remove `_run_non_sdk_review()` function entirely**
- [x] **Remove `_resolve_api_key()` function** (auth is now the provider's concern via `create_agent`)
- [x] **Remove `from openai import AsyncOpenAI`** import
- [x] **Remove `from squadron.review.runner import run_review`** import
- [x] **Preserve prompt logging and verbosity behavior** (debug output at -vvv, prompt capture at -vv)
- [x] **Preserve file injection logic** (`_inject_file_contents` stays — called conditionally based on capabilities)
- [x] Success: `review_client.py` has no provider-specific imports; one code path for all profiles

### T14: Review client unification tests

- [x] **Update `tests/review/test_review_client.py`**
  - [x] Existing tests should pass against the new unified path (may need fixture updates)
  - [x] Test: SDK profile routes through provider registry and ClaudeSDKAgent
  - [x] Test: OpenAI profile routes through provider registry and OpenAICompatibleAgent
  - [x] Test: Codex profile routes through provider registry and CodexAgent (mocked MCP client)
  - [x] Test: file injection happens when `can_read_files=False`
  - [x] Test: file injection skipped when `can_read_files=True` (SDK and Codex profiles)
  - [x] Test: prompt logging preserved at verbosity >= 3
  - [x] Test: prompt capture fields populated at verbosity >= 2
- [x] **Update `tests/review/test_content_injection.py`** if it references old functions
- [x] **Update `tests/review/test_verbosity.py`** if it references old functions
- [x] Success: all review tests pass; `review_client.py` has zero provider-specific imports

**Commit**: `refactor: unify review transport through Agent.handle_message()`

---

### T15: CLI auth status cleanup

- [x] **Refactor `auth_status()` in `src/squadron/cli/commands/auth.py`**
  - [x] Remove `from squadron.providers.codex.auth import CodexAuthStrategy` (if present)
  - [x] For each profile: call `resolve_auth_strategy_for_profile(profile)`
  - [x] Use `strategy.is_valid()` for status, `strategy.active_source` for source column
  - [x] No branching on `auth_type` or `api_key_env` — strategy handles it
- [x] **Update `auth_login()` if it has string dispatch**
- [x] Success: `sq auth status` shows correct output; no string dispatch in auth CLI

### T16: CLI auth status tests

- [x] **Update `tests/cli/test_auth.py`**
  - [x] Test: `sq auth status` includes rows for all built-in profiles including codex
  - [x] Test: codex profile shows correct auth source
  - [x] Test: profiles with no auth needed show correct status
- [x] Success: all CLI auth tests pass

**Commit**: `refactor: eliminate string dispatch from CLI auth status`

---

### T17: Model aliases for Codex

- [x] **Add `codex-agent` alias to `BUILT_IN_ALIASES` in `src/squadron/models/aliases.py`**
  - [x] `"codex-agent"`: `profile: "codex"`, `model: "gpt-5.3-codex"`, `notes: "Agentic: sandbox, subscription auth"`
- [x] **Add `codex-spark` alias**
  - [x] `"codex-spark"`: `profile: "openai"`, `model: "gpt-5.3-codex-spark"`, `notes: "Near-instant, Pro only"`
- [x] **Existing `codex` alias stays unchanged** (`profile: "openai"`)
- [x] **Add alias tests**
  - [x] Test: `codex` resolves to `profile="openai"` (unchanged)
  - [x] Test: `codex-agent` resolves to `profile="codex"`
  - [x] Test: `codex-spark` resolves correctly
- [x] Success: all alias tests pass; existing aliases unchanged

**Commit**: `feat: add codex-agent and codex-spark model aliases`

---

### T18: Full validation pass

- [x] **Run full test suite**
  - [x] `pytest tests/ -v` — all tests pass
  - [x] `ruff check src/ tests/` — no lint errors
  - [x] `ruff format --check src/ tests/` — no formatting issues
  - [x] `pyright src/` — no type errors (or only pre-existing ones)
- [x] **Verify no review system regression**
  - [x] Confirm `codex` model alias still has `profile: "openai"` (not `"codex"`)
  - [x] Confirm no `if profile == "sdk"` or `if auth_type ==` in `review_client.py` or `auth.py`
  - [x] Confirm no `AsyncOpenAI` import in `review_client.py`
  - [x] Confirm `runner.py` is deleted
- [x] **Verify provider registration**
  - [x] `get_provider("openai")`, `get_provider("sdk")`, `get_provider("codex")` all work
  - [x] Each provider exposes `capabilities` property
- [x] **Verify CLI end-to-end**
  - [x] `sq auth status` shows codex row with correct source
  - [x] Error message when codex CLI not installed is user-actionable
  - [x] Error message when no credentials is user-actionable
- [x] Success: all checks green; no regressions

---

### T19: Documentation and slice completion

- [x] **Update CHANGELOG.md**
  - [x] Add entries for: provider capabilities, auth strategy refactor, SDK rename, Codex provider, review transport unification
- [x] **Update DEVLOG.md**
  - [x] Add Phase 6 implementation entry for slice 128
- [x] **Update slice design status**
  - [x] Set status to `complete` in `128-slice.review-transport-unification-provider-decoupling.md`
  - [x] Update `dateUpdated`
- [x] **Update slice plan**
  - [x] Check off slice 128 in `100-slices.orchestration-v2.md`
- [x] **Update Verification Walkthrough** with actual commands and results
- [x] Success: all documentation updated; slice marked complete

**Commit**: `docs: complete slice 128 review transport unification`
