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

13 KiB
Raw Blame History

Harness-Specific Model Selection — Tech Spec

Linear: 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.rspopulate_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.rsHarnessAvailabilityModel 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.rsdisplay_name(), icon_for(), brand_color() per Harness variant

Model ID delivery to harness processes

  • app/src/ai/agent_sdk/driver/harness/mod.rsharness_model_env_vars() (line 373): sets ANTHROPIC_MODEL for Claude, no-op for Codex
  • app/src/pane_group/pane/local_harness_launch.rsprepare_local_harness_child_launch() (line 77): builds child env_vars but does not include model_id
  • app/src/pane_group/pane/terminal_pane.rslaunch_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.rsprepare_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.rspopulate_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.rspopulate_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.