Files
galaxy/specs/QUALITY-643/TECH.md
T

190 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Harness-Specific Model Selection — Tech Spec
Linear: [QUALITY-643](https://linear.app/warpdotdev/issue/QUALITY-643)
Companion product spec: `specs/QUALITY-643/PRODUCT.md`
## Context
The orchestration config UI (plan card and run_agents confirmation card) lets users pick a harness and model for child agents. Both cards share picker logic in `orchestration_controls.rs`. Two problems exist today:
1. **Harness picker** is hardcoded to `[Oz, Claude, Codex]` — it doesn't read from the server's `availableHarnesses` list, doesn't include Gemini, and doesn't respect admin enabled/disabled state.
2. **Model picker** always shows Warp's internal LLM catalog filtered by provider (Anthropic for Claude, OpenAI for Codex). Those IDs (e.g. `claude-4-6-opus-high`) are not recognized by third-party harness CLIs. The server maintains separate harness-specific model catalogs that the desktop client already fetches and caches in `HarnessAvailabilityModel`, but the orchestration UI doesn't use them.
Additionally, model_id is not delivered to local child harness processes: Claude Code doesn't receive `ANTHROPIC_MODEL`. Codex model delivery uses `~/.codex/config.toml`, which is only safe in cloud/remote environments where the filesystem is isolated — local children must not touch it (see `local_harness_launch.rs:143` comment).
### Relevant files
**Shared picker logic (model/harness dropdowns)**
- `app/src/ai/blocklist/inline_action/orchestration_controls.rs``populate_harness_picker()` (line 380), `populate_model_picker_for_harness()` (line 314), `is_model_in_filtered_choices()` (line 353), `first_filtered_model_id()` (line 368), `sync_picker_selections()` (line 508), `matches_harness_filter()` (line 299)
**UI card views that consume the shared pickers**
- `app/src/ai/document/orchestration_config_block.rs` — plan card; subscribes to `LLMPreferencesEvent` for model refresh
- `app/src/ai/blocklist/inline_action/run_agents_card_view.rs` — confirmation card; `HarnessChanged` handler (line 752) resets model on harness change
**Harness availability and model data (already fetched and cached)**
- `app/src/ai/harness_availability.rs``HarnessAvailabilityModel` singleton with:
- `available_harnesses()``&[HarnessAvailability]` (harness, display_name, enabled, available_models)
- `models_for(harness)``Option<&[HarnessModelInfo]>` (id, display_name)
- `is_harness_enabled(harness)``bool`
- Emits `HarnessAvailabilityEvent::Changed`
**Harness display metadata**
- `app/src/ai/harness_display.rs``display_name()`, `icon_for()`, `brand_color()` per `Harness` variant
**Model ID delivery to harness processes**
- `app/src/ai/agent_sdk/driver/harness/mod.rs``harness_model_env_vars()` (line 373): sets `ANTHROPIC_MODEL` for Claude, no-op for Codex
- `app/src/pane_group/pane/local_harness_launch.rs``prepare_local_harness_child_launch()` (line 77): builds child env_vars but does not include model_id
- `app/src/pane_group/pane/terminal_pane.rs``launch_local_harness_child()` (line 1302): passes `model_id` to `apply_child_model_id_override` which only sets Oz LLM preference
- `app/src/ai/agent_sdk/driver/harness/codex.rs``prepare_codex_config_toml()` (line 573): writes `~/.codex/config.toml` but does not write a `model` key; top-level key name is `"model"` (confirmed by test at `codex_tests.rs:201`)
## Proposed changes
### 1. Populate harness picker from `HarnessAvailabilityModel`
**File**: `orchestration_controls.rs``populate_harness_picker()`
Replace the hardcoded `[Harness::Oz, Harness::Claude, Harness::Codex]` iteration (line 392) with a read from `HarnessAvailabilityModel::as_ref(ctx).available_harnesses()`.
For each `HarnessAvailability` entry:
- Use `harness_display::icon_for()` and `harness_display::brand_color()` for icons (these already cover all variants including Gemini).
- Use `harness_display::display_name()` for the label (the server's `display_name` field could also be used, but the client-side names already match and are guaranteed non-empty before the server responds).
- If `!entry.enabled`: render as disabled (non-selectable, greyed text). The `MenuItemFields` API supports `.with_disabled(true)` or equivalent — check the existing `MenuItem` disabled patterns in the codebase.
- Sort enabled entries before disabled entries.
Also subscribe both card views to `HarnessAvailabilityEvent::Changed` to repopulate the harness picker when the server list updates.
Addresses PRODUCT.md behaviors 15.
### 2. Switch model picker to harness-specific models with "Default model" entry
**File**: `orchestration_controls.rs``populate_model_picker_for_harness()`
Add an `is_local: bool` parameter (or pass the current `RunAgentsExecutionMode`) so the picker can be execution-mode-aware. Replace the current provider-filtered `LLMPreferences` logic with harness-aware branching:
```
let harness = Harness::parse_orchestration_harness(harness_type);
match harness {
Some(Harness::Oz) | None => {
// Current behavior: LLMPreferences filtered by provider
}
Some(Harness::Codex) if is_local => {
// Local Codex: only "Default model" entry (no model delivery possible)
}
Some(harness) => {
// 1. Always add a "Default model" entry first (value: empty string)
// 2. Read HarnessAvailabilityModel::as_ref(ctx).models_for(harness)
// 3. If Some(models): append each HarnessModelInfo as a menu item
// with display_name as label and id as the model_changed action value
// 4. If None: only "Default model" is shown (loading/empty state)
}
}
```
The "Default model" entry should use label `"Default model"` and emit `A::model_changed(String::new())` (empty string). This matches the web UI which adds this entry with value `""`.
When execution mode toggles between Local and Cloud, the `HarnessChanged` / `ExecutionModeToggled` handlers must repopulate the model picker since Codex's available models depend on the mode.
Apply the same Oz-vs-non-Oz branching to:
- `is_model_in_filtered_choices()` — for non-Oz, check model_id against `HarnessAvailabilityModel::models_for()` OR accept empty string (the "Default model" entry). For local Codex, only empty string is valid.
- `first_filtered_model_id()` — for non-Oz, return `Some(String::new())` (the "Default model" entry) as the default.
- `sync_picker_selections()` — for non-Oz, find display_name from `HarnessAvailabilityModel::models_for()` instead of `LLMPreferences`. Map empty model_id to the "Default model" label.
Addresses behaviors 610, 1114, 1516.
### 3. Subscribe both card views to `HarnessAvailabilityEvent::Changed`
**Files**: `orchestration_config_block.rs`, `run_agents_card_view.rs`
Both views already subscribe to `LLMPreferencesEvent::UpdatedAvailableLLMs`. Add an analogous subscription to `HarnessAvailabilityModel`:
- Repopulate the **harness picker** when the harness list changes (behaviors 15).
- Repopulate the **model picker** when harness models arrive, but only when the current harness is non-Oz (behavior 15).
Addresses behaviors 15, 15.
### 4. Propagate model_id to local child harness processes (Claude Code only)
**File**: `local_harness_launch.rs`
Add `model_id: Option<String>` parameter to `prepare_local_harness_child_launch()`. After building `env_vars` from `task_env_vars()`:
- For Claude: merge `harness_model_env_vars(harness, model_id.as_deref())` into env_vars. This sets `ANTHROPIC_MODEL` when model_id is non-empty.
- For Codex: no model delivery for local children. The UI ensures model_id is empty for local Codex (behavior 8), so no action is needed here. The existing code already skips `prepare_codex_environment_config()` for local children (`local_harness_launch.rs:143`) and this spec preserves that constraint.
**File**: `terminal_pane.rs`
Update the call to `prepare_local_harness_child_launch()` in `launch_local_harness_child()` (line 1325) to pass the `model_id` value.
Addresses behaviors 17, 20.
### 5. Write Codex model to config.toml (cloud/remote path only)
**File**: `codex.rs`
Add `model_id: Option<&str>` parameter to `prepare_codex_environment_config()` and `prepare_codex_config_toml()`.
In `prepare_codex_config_toml()`, after `set_codex_openai_base_url()`:
- If `model_id` is `Some(id)` where `id` is non-empty and not `"default"`: `doc["model"] = toml_edit::value(id)`.
- Otherwise: `doc.remove("model")` to clear any pre-existing key.
Add constant `CODEX_MODEL_KEY: &str = "model"`.
The existing test at `codex_tests.rs:201` verifies a pre-existing `model` key is preserved — update it to verify the new write/remove behavior.
Update the caller of `prepare_codex_environment_config()` in `codex.rs` `build_runner()` to pass `model_id`. No changes needed in `local_harness_launch.rs` — local Codex children don't call this function and don't receive model overrides.
Addresses behavior 18.
### 6. No changes needed for remote launch path
The remote launch path already passes `model_id` to the server via `StartAgentExecutionMode::Remote { model_id }` in `run_agents_to_start_agent_mode()`. With the UI now storing harness-native IDs (or empty for "Default model"), the server receives the correct value without translation.
Addresses behaviors 17 (remote), 18 (remote).
## Testing and validation
### Unit tests
**orchestration_controls tests** — new tests:
- Harness picker populated from `HarnessAvailabilityModel`; disabled harnesses shown but not selectable. (Behaviors 14)
- `populate_model_picker_for_harness` with harness="claude": "Default model" entry at top, then harness-specific models from `HarnessAvailabilityModel`. (Behavior 7)
- `populate_model_picker_for_harness` with harness="codex", cloud mode: "Default model" entry at top, then Codex models. (Behavior 8)
- `populate_model_picker_for_harness` with harness="codex", local mode: only "Default model" entry. (Behavior 8)
- `populate_model_picker_for_harness` with harness="oz": Warp LLM catalog (existing behavior). (Behavior 6)
- `is_model_in_filtered_choices` returns false for Warp IDs when harness is non-Oz, true for empty string ("Default model"). (Behavior 12)
- `first_filtered_model_id` returns empty string for non-Oz harness. (Behavior 11)
- Harness change from Claude (model="opus") to Oz: model resets to first Warp LLM. (Behavior 12)
**local_harness_launch tests** — new/updated tests:
- `prepare_local_harness_child_launch` merges `ANTHROPIC_MODEL` into env_vars when harness is Claude and model_id is provided. (Behavior 17)
- `prepare_local_harness_child_launch` does NOT set `ANTHROPIC_MODEL` when model_id is None or empty. (Behavior 20)
**codex config.toml tests** — update existing tests in `codex_tests.rs`:
- `prepare_codex_config_toml` writes `model = "gpt-5.4"` when model_id is `Some("gpt-5.4")`. (Behavior 18)
- `prepare_codex_config_toml` removes existing `model` key when model_id is `Some("default")`. (Behavior 18)
- `prepare_codex_config_toml` removes existing `model` key when model_id is `None`. (Behavior 20)
- Pre-existing non-model keys (openai_base_url, projects, mcp_servers) are preserved in all cases.
**orchestration_config_tests** — existing tests must continue to pass. (Behavior 21)
### Presubmit
Run `cargo fmt`, `cargo clippy`, and `./script/presubmit` before PR.
### Manual validation
- Open orchestration config on a plan card → harness picker shows Oz, Claude Code, Codex (and Gemini if server returns it, possibly disabled).
- Select Claude Code → model picker shows "Default model" at top, then `best`, `opus`, `sonnet`, etc.
- Select Codex (Cloud mode) → model picker shows "Default model" at top, then `default`, `GPT-5.5`, `GPT-5.4`, etc.
- Select Codex (Local mode) → model picker shows only "Default model".
- Toggle Local → Cloud with Codex selected → model picker repopulates with full Codex catalog.
- Select Oz → model picker returns to Warp LLM catalog.
- Change harness from Claude (with "opus" selected) to Oz → model resets.
- Launch local agents with Claude Code + "opus" → verify `ANTHROPIC_MODEL=opus` in child env.
- Launch local agents with Claude Code + "Default model" → verify no `ANTHROPIC_MODEL` in child env.
- Launch cloud agents with Codex + "gpt-5.4" → verify `model = "gpt-5.4"` in `~/.codex/config.toml` inside the cloud env.
- Launch cloud agents with Codex + "default" → verify no `model` key in `~/.codex/config.toml`.
## Parallelization
Not recommended. The changes are tightly coupled — the harness picker (change 1) and model picker (change 2) share state in `orchestration_controls.rs`, the card view subscriptions (change 3) depend on both pickers, and the launch-side changes (45) depend on the correct model_id format flowing from the picker. Total scope is ~7 files with moderate changes each, well suited for sequential execution by a single agent.