openpencil/crates/op-host-web/src/web_chat.rs
Fini 6ed9548c0d merge: land the align-branch work onto the refreshed base
47 commits from the align branch merged onto the force-updated remote
base (which had replayed an earlier snapshot of the same work plus new
overlay/pointer features and CI fixes). Conflict resolution: kept the
newer align side for the generation pipeline (orchestrator, mcp, skills,
design tools), kept the base side for the chat-panel test semantics and
graceful overlay teardown, fused both in sub_agent_session (design-turn
thinking policy + graceful epoch finish), and dropped the files each
side had deleted (legacy concurrent/dashboard paths, retired TS skills).
Deduped two identical replayed hunks (export.rs, chat_session_tests.rs).

Known issue carried over: provider_probe_host::landed_connected_outcome_
without_models_is_failure fails on a host with a live provider config
(env-sensitive test, both sides byte-identical there; green on CI).
2026-07-03 00:08:17 +08:00

711 lines
27 KiB
Rust

// Browser/daemon boundary: chat streams through /api/ai/standard on the desktop
// daemon; this module keeps that web architecture instead of bypassing it.
//! Web chat session — drives a real AI turn through the desktop daemon's
//! `/api/ai/standard` route instead of the offline echo stub.
//!
//! Mirrors the desktop's `chat_session.rs` host-drain pattern with browser
//! plumbing borrowed from `codegen_web.rs`:
//!
//! * The widget layer raises the flags (`ChatState::begin_send` →
//! `pending_send`; Stop / New Chat → `pending_stop_chat` /
//! `pending_new_chat`). [`drain_chat_flags`] consumes them from the DOM
//! listeners once the press/key borrow is released — the same drain points
//! as `dom_io::drain_pending_file_action`.
//! * A send POSTs the selected model + user message over
//! [`crate::web_ai_transport::post_ai_stream`]; streamed events land in a
//! `VecDeque` queue and a `requestAnimationFrame` pump folds them into the
//! trailing streaming assistant bubble each frame (the desktop winit loop
//! pumps `ChatSession::poll` the same way at ~30 fps).
//! * Stop / New Chat / a replacing send abort the in-flight XHR via the
//! transport's [`AiStreamHandle`]; a generation counter on the active-turn
//! slot makes the old pump (and any late events) die silently.
//!
//! Wire notes: sends go through `/api/ai/standard` with the selected model,
//! current user message, trimmed prior text history, current-turn attachments,
//! and the current document / selection snapshot.
use std::cell::{Cell, RefCell};
use std::collections::VecDeque;
use std::rc::Rc;
use base64::Engine as _;
use op_ai::chat_history::{trim_chat_history, DEFAULT_MAX_CHARS, DEFAULT_MAX_MESSAGES};
use op_editor_core::chat::{AgentProvider, ChatState, ModelEntry};
use op_editor_core::EditorState;
use op_editor_host_core::chat::chat_history_from_transcript;
use wasm_bindgen::closure::Closure;
use wasm_bindgen::JsCast;
use crate::repaint_ctx::RepaintContext;
use crate::web_ai_transport::{post_ai_stream_to, AiEvent, AiStreamHandle};
type EventQueue = Rc<RefCell<VecDeque<AiEvent>>>;
/// The in-flight chat turn: the XHR abort handle plus its generation. wasm is
/// single-threaded, so a thread_local slot is the natural owner — every drain
/// site and the rAF pump reach the same instance.
struct ActiveTurn {
handle: AiStreamHandle,
generation: u64,
}
thread_local! {
static ACTIVE_TURN: RefCell<Option<ActiveTurn>> = const { RefCell::new(None) };
static NEXT_GENERATION: Cell<u64> = const { Cell::new(1) };
/// Chat tab index the in-flight turn is bound to (MT.3 session-per-tab).
/// The rAF pump writes streamed events into THIS tab even after the user
/// switches the active tab, so a switch mid-stream never corrupts the
/// now-active (wrong) tab. `None` when no turn is in flight or the binding
/// is stale (the bound tab was closed → falls back to the active tab).
static RUNNING_TAB: Cell<Option<usize>> = const { Cell::new(None) };
}
/// Abort and drop the in-flight turn, if any. The rAF pump notices the slot
/// change (generation mismatch) on its next frame and stops. Also clears the
/// run's tab binding — the aborted turn no longer targets any tab.
fn abort_active_turn() {
let turn = ACTIVE_TURN.with(|slot| slot.borrow_mut().take());
RUNNING_TAB.with(|t| t.set(None));
if let Some(turn) = turn {
turn.handle.abort();
}
}
/// The tab index the in-flight turn is currently bound to.
fn running_tab() -> Option<usize> {
RUNNING_TAB.with(|t| t.get())
}
/// Drain the chat host flags raised by the widget layer. Called from the DOM
/// listeners (mousedown / chat keydown) AFTER their `inner` borrow is
/// released. Order mirrors the desktop event loop: New Chat / Stop first
/// (abort the worker so stale deltas can't repopulate the transcript), then
/// launch a pending send.
pub(crate) fn drain_chat_flags<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>) {
let (new_chat, stop) = {
let Ok(mut b) = inner.try_borrow_mut() else {
return;
};
let chat = &mut b.host_mut().editor_state_mut().chat;
(
std::mem::take(&mut chat.pending_new_chat),
std::mem::take(&mut chat.pending_stop_chat),
)
};
if new_chat || stop {
abort_active_turn();
}
// A pending close-tab (MT.3): abort the run if it's bound to the closed
// tab, shift the binding otherwise, then remove the tab. Done before the
// launch below so a close + send in one drain pass resolves in order.
{
let Ok(mut b) = inner.try_borrow_mut() else {
return;
};
let pending_close = b
.host_mut()
.editor_state_mut()
.editor_ui
.pending_close_chat_tab
.take();
if let Some(idx) = pending_close {
close_chat_tab(b.host_mut().editor_state_mut(), idx);
b.host_mut().mark_editor_state_dirty();
}
}
let prepared = {
let Ok(mut b) = inner.try_borrow_mut() else {
return;
};
let state = b.host_mut().editor_state_mut();
// Bind a run to the tab it starts on BEFORE launching (the active tab
// is the sending tab at this point).
let active = state.chat.active_index();
prepare_turn(state).inspect(|_| RUNNING_TAB.with(|t| t.set(Some(active))))
};
if let Some(prepared) = prepared {
launch_turn(inner, prepared);
}
}
/// Close chat tab `idx` (MT.3 `AIChatHit::CloseTab` web path). Aborts the
/// in-flight turn when it is bound to the closed tab (so the rAF pump can't
/// target a removed / shifted tab), shifts the binding when an earlier tab is
/// removed, then removes the tab.
fn close_chat_tab(state: &mut EditorState, idx: usize) {
if idx >= state.chat.tab_count() {
return; // out of range — mirror ChatSessions::close_tab no-op
}
match running_tab() {
Some(running) if running == idx => abort_active_turn(),
Some(running) => {
RUNNING_TAB
.with(|t| t.set(op_editor_core::adjust_running_tab_after_close(running, idx)));
}
None => {}
}
state.chat.close_tab(idx);
}
/// Launch one streaming turn: abort any in-flight one (a send fired mid-turn
/// replaces it — desktop parity), POST the prepared body, and start the rAF
/// pump that folds streamed events into the transcript.
fn launch_turn<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>, prepared: PreparedTurn) {
abort_active_turn();
let generation = NEXT_GENERATION.with(|g| {
let v = g.get();
g.set(v + 1);
v
});
let queue: EventQueue = Rc::new(RefCell::new(VecDeque::new()));
let queue_cb = queue.clone();
// The transport callback only queues — the pump applies on the next
// frame, so no `inner` borrow is ever taken re-entrantly from XHR events.
let on_event: Rc<dyn Fn(AiEvent)> = Rc::new(move |evt: AiEvent| {
queue_cb.borrow_mut().push_back(evt);
});
let base = crate::daemon_base::daemon_base();
match post_ai_stream_to(&base, prepared.endpoint, prepared.body_json, on_event) {
Ok(handle) => {
ACTIVE_TURN.with(|slot| {
*slot.borrow_mut() = Some(ActiveTurn { handle, generation });
});
start_pump(inner.clone(), queue, generation);
}
Err(_e) => {
// Transport refused to even start (XHR open/send failed) —
// surface that in the streaming bubble instead of hanging it.
let Ok(mut b) = inner.try_borrow_mut() else {
return;
};
let target = b
.host_mut()
.editor_state_mut()
.chat
.run_tab_mut(running_tab());
let _ = apply_event_to_chat(
target,
&AiEvent::Error("AI stream request failed to start".into()),
);
b.host_mut().mark_editor_state_dirty();
let _ = b.repaint();
}
}
}
/// Start the rAF pump for one turn. Each frame drains the queued events into
/// the transcript and repaints; stops when the turn ends (terminal event
/// applied) or when this generation is no longer the active turn (Stop / New
/// Chat / replacing send).
fn start_pump<C: RepaintContext + 'static>(
inner: Rc<RefCell<C>>,
queue: EventQueue,
generation: u64,
) {
let tick: Rc<dyn Fn() -> bool> = Rc::new(move || {
let still_active = ACTIVE_TURN.with(|slot| {
slot.borrow()
.as_ref()
.is_some_and(|t| t.generation == generation)
});
if !still_active {
return false; // aborted — drop queued late events silently
}
let mut terminal = false;
let mut changed = false;
let Ok(mut b) = inner.try_borrow_mut() else {
return true;
};
loop {
// Pop under a tight borrow so the apply below never holds the
// queue borrow across editor-state mutation.
let evt = queue.borrow_mut().pop_front();
let Some(evt) = evt else { break };
// Write into the tab this run is bound to (MT.3 session-per-tab),
// not whichever tab is active now.
let target = b
.host_mut()
.editor_state_mut()
.chat
.run_tab_mut(running_tab());
terminal |= apply_event_to_chat(target, &evt);
changed = true;
}
if changed {
// Repaint once per frame even when several events were queued.
b.host_mut().mark_editor_state_dirty();
let _ = b.repaint();
}
drop(b);
if terminal {
let was_active = ACTIVE_TURN.with(|slot| {
let mut s = slot.borrow_mut();
if s.as_ref().is_some_and(|t| t.generation == generation) {
*s = None;
true
} else {
false
}
});
// This run finished — clear its tab binding (a fresh turn rebinds
// at launch). Guarded on it still being THIS generation's turn so
// a replacing send's binding isn't clobbered.
if was_active {
RUNNING_TAB.with(|t| t.set(None));
}
return false;
}
true
});
crate::raf_pump::start(tick);
}
/// A prepared AI SSE request.
pub(crate) struct PreparedTurn {
pub(crate) endpoint: &'static str,
pub(crate) body_json: String,
}
/// Take `chat.pending_send` and build the standard-turn request body:
/// selected model wire id (or `"default"` — the daemon then picks its
/// configured provider), the user message, per-turn knobs, plus a fresh
/// document/selection snapshot so daemon-side planning sees the same canvas
/// the browser is showing. Clears staged attachments after copying them into
/// the request; `begin_send` already copied image previews into the user
/// transcript bubble.
pub(crate) fn prepare_turn(state: &mut EditorState) -> Option<PreparedTurn> {
let user_text = state.chat.pending_send.take()?;
let model = state
.chat
.selected_model_entry()
.map(|e| e.value.clone())
.unwrap_or_else(|| "default".to_string());
let thinking = state.chat.thinking_mode.as_str();
let effort = state.chat.effort_level.as_str();
let agent_team_size = state.chat.agent_team_size;
let history = trim_chat_history(
&chat_history_from_transcript(&state.chat.messages),
DEFAULT_MAX_MESSAGES,
DEFAULT_MAX_CHARS,
);
let history_json: Vec<serde_json::Value> = history
.iter()
.map(|(role, content)| {
serde_json::json!({
"role": role.as_str(),
"content": content,
})
})
.collect();
let attachments_json: Vec<serde_json::Value> = state
.chat
.pending_attachments
.iter()
.map(|att| {
serde_json::json!({
"name": att.name.as_str(),
"media_type": att.media_type.as_str(),
"data_base64": base64::engine::general_purpose::STANDARD.encode(&att.data),
})
})
.collect();
state.chat.pending_attachments.clear();
let selected_ids: Vec<&str> = state.selection.set.iter().map(|id| id.as_str()).collect();
let active_page_id = state
.doc
.pages
.as_ref()
.and_then(|pages| pages.get(state.ui.active_page_index))
.map(|page| page.id.as_str());
let body = serde_json::json!({
"model": model,
// Standard turns route through the daemon classifier + design
// orchestrator; skills are resolved daemon-side per route.
"skills": [],
"user": user_text,
"max_output_tokens": 4096u32,
"thinking": thinking,
"effort": effort,
"agent_team_size": agent_team_size,
"history": history_json,
"attachments": attachments_json,
"document": state.doc,
"selectedIds": selected_ids,
"activePageId": active_page_id,
});
Some(PreparedTurn {
endpoint: "/api/ai/standard",
body_json: serde_json::to_string(&body).unwrap_or_else(|_| "{}".to_string()),
})
}
/// Fold one streamed event into the trailing streaming assistant bubble.
/// Mirrors the desktop's `apply_poll_to_message`: an error replaces the
/// visible body, answer/thinking text accumulates, a terminal event clears
/// the `streaming` flag. Returns true when the event was terminal. A missing
/// streaming bubble (e.g. the user stopped the turn a frame earlier) drops
/// the event without touching the transcript.
pub(crate) fn apply_event_to_chat(chat: &mut ChatState, evt: &AiEvent) -> bool {
let terminal = evt.is_terminal();
let Some(msg) = chat.messages.iter_mut().rev().find(|m| m.streaming) else {
return terminal;
};
match evt {
AiEvent::Delta(text) => msg.content.push_str(text),
AiEvent::Thinking(text) => msg.thinking.push_str(text),
AiEvent::Error(e) => {
msg.content = format!("error: {e}");
msg.streaming = false;
}
AiEvent::Done => msg.streaming = false,
}
terminal
}
// ---------------------------------------------------------------------
// Model discovery (`GET /api/ai/models`)
// ---------------------------------------------------------------------
/// Fetch the daemon's model catalog once (called from `mount()`) and populate
/// the chat model picker. Best-effort: a missing daemon / bad payload leaves
/// the catalog empty and sends fall back to the `"default"` model.
pub(crate) fn fetch_models<C: RepaintContext + 'static>(inner: &Rc<RefCell<C>>) {
let base = crate::daemon_base::daemon_base();
let url = format!("{base}/api/ai/models");
let Ok(xhr) = web_sys::XmlHttpRequest::new() else {
return;
};
if xhr.open_with_async("GET", &url, true).is_err() {
return;
}
let xhr_cb = xhr.clone();
let inner_cb = inner.clone();
// `once_into_js` self-cleans after the single firing (live_sync idiom).
let onloadend = Closure::<dyn FnMut()>::once_into_js(move || {
let Ok(Some(text)) = xhr_cb.response_text() else {
return;
};
let ids = parse_models_json(&text);
if ids.is_empty() {
return;
}
let Ok(mut b) = inner_cb.try_borrow_mut() else {
return;
};
apply_models(b.host_mut().editor_state_mut(), &ids);
b.host_mut().mark_editor_state_dirty();
let _ = b.repaint();
});
xhr.set_onloadend(Some(onloadend.unchecked_ref()));
let _ = xhr.send();
}
/// Parse the `GET /api/ai/models` body — a JSON array of model-id strings
/// (`ai_proxy::models_json`). Lenient: non-string entries and blanks are
/// skipped; a non-array payload yields the empty catalog.
pub(crate) fn parse_models_json(body: &str) -> Vec<String> {
let Ok(value) = serde_json::from_str::<serde_json::Value>(body) else {
return Vec::new();
};
let Some(arr) = value.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
/// Heuristic provider tag for a daemon model id — drives only the picker's
/// branding/grouping on web (the daemon resolves the real provider by id, so
/// a mismatch here cannot misroute a request).
pub(crate) fn provider_for_model_id(id: &str) -> AgentProvider {
let lower = id.to_ascii_lowercase();
if lower.contains("gemini") {
AgentProvider::GeminiCli
} else if lower.contains("gpt") || lower.contains("codex") {
AgentProvider::CodexCli
} else if lower.contains("copilot") {
AgentProvider::GithubCopilot
} else {
// claude-* and anything unrecognized group under Claude.
AgentProvider::ClaudeCode
}
}
/// Install the daemon catalog into the chat model picker. Writes both
/// `discovered_models` and `available_models` directly — the web shell has no
/// connected-CLI mask (the daemon owns provider configuration), so the
/// desktop's connected-providers filter does not apply. The previously
/// selected model is preserved by identity when it survives, else selection
/// falls back to the first entry.
pub(crate) fn apply_models(state: &mut EditorState, ids: &[String]) {
let entries: Vec<ModelEntry> = ids
.iter()
.map(|id| ModelEntry::new(provider_for_model_id(id), id.clone(), id.clone()))
.collect();
let prev = state
.chat
.available_models
.get(state.chat.selected_model)
.cloned();
state.chat.discovered_models = entries.clone();
state.chat.available_models = entries;
state.chat.selected_model = prev
.and_then(|p| {
state
.chat
.available_models
.iter()
.position(|m| m.provider == p.provider && m.value == p.value)
})
.unwrap_or(0);
}
#[cfg(test)]
mod tests {
use super::*;
fn state_with_queued_send(text: &str) -> EditorState {
let mut state = EditorState::new();
state.chat.set_input_text(text);
assert!(state.chat.begin_send());
state
}
// Returns the whole `ChatSessions` (the `.chat` field is the multi-tab
// container since the multi-chat-tabs change); callers drive it through its
// `DerefMut<Target = ChatState>` so `&mut chat` still coerces to the
// `&mut ChatState` that `apply_event_to_chat` expects.
fn chat_with_queued_send(text: &str) -> op_editor_core::ChatSessions {
state_with_queued_send(text).chat
}
#[test]
fn prepare_turn_carries_model_and_message() {
let mut state = state_with_queued_send("design a login page");
state.chat.available_models = vec![ModelEntry::new(
AgentProvider::ClaudeCode,
"claude-sonnet-4-5",
"Claude Sonnet 4.5",
)];
state.chat.selected_model = 0;
state.chat.agent_team_size = 3;
let prepared = prepare_turn(&mut state).expect("send was pending");
assert_eq!(prepared.endpoint, "/api/ai/standard");
let body: serde_json::Value =
serde_json::from_str(&prepared.body_json).expect("body is JSON");
assert_eq!(body["model"], "claude-sonnet-4-5");
assert_eq!(body["user"], "design a login page");
assert_eq!(body["max_output_tokens"], 4096);
assert_eq!(body["agent_team_size"], 3);
assert!(body["document"].is_object());
assert!(body["skills"].as_array().is_some_and(Vec::is_empty));
// The drain consumed the flag — a second drain is idle.
assert!(prepare_turn(&mut state).is_none());
}
#[test]
fn prepare_turn_carries_prior_history_without_current_turn() {
let mut state = EditorState::new();
state
.chat
.messages
.push(op_editor_core::ChatMessage::user("previous request"));
state
.chat
.messages
.push(op_editor_core::ChatMessage::assistant("previous answer"));
state.chat.set_input_text("current request");
assert!(state.chat.begin_send());
let prepared = prepare_turn(&mut state).expect("send was pending");
let body: serde_json::Value =
serde_json::from_str(&prepared.body_json).expect("body is JSON");
let history = body["history"].as_array().expect("history array");
assert_eq!(history.len(), 2, "{history:?}");
assert_eq!(history[0]["role"], "user");
assert_eq!(history[0]["content"], "previous request");
assert_eq!(history[1]["role"], "assistant");
assert_eq!(history[1]["content"], "previous answer");
assert!(
!history
.iter()
.any(|item| item["content"] == "current request"),
"current user message rides `user`, not history: {history:?}"
);
}
#[test]
fn prepare_turn_defaults_model_when_catalog_empty() {
let mut state = state_with_queued_send("hi");
let thinking = state.chat.thinking_mode;
let effort = state.chat.effort_level;
let prepared = prepare_turn(&mut state).expect("send was pending");
let body: serde_json::Value =
serde_json::from_str(&prepared.body_json).expect("body is JSON");
assert_eq!(body["model"], "default");
assert_eq!(body["thinking"], thinking.as_str());
assert_eq!(body["effort"], effort.as_str());
}
#[test]
fn prepare_turn_clears_staged_attachments() {
let mut state = EditorState::new();
state.chat.set_input_text("look at this");
state
.chat
.pending_attachments
.push(op_editor_core::chat::ChatAttachment {
name: "a.png".into(),
media_type: "image/png".into(),
data: vec![1, 2, 3],
});
assert!(state.chat.begin_send());
let _ = prepare_turn(&mut state).expect("send was pending");
assert!(state.chat.pending_attachments.is_empty());
}
#[test]
fn prepare_turn_carries_staged_attachments_on_the_wire() {
let mut state = EditorState::new();
state.chat.set_input_text("look at this");
state
.chat
.pending_attachments
.push(op_editor_core::chat::ChatAttachment {
name: "a.png".into(),
media_type: "image/png".into(),
data: vec![1, 2, 3],
});
assert!(state.chat.begin_send());
let prepared = prepare_turn(&mut state).expect("send was pending");
let body: serde_json::Value =
serde_json::from_str(&prepared.body_json).expect("body is JSON");
let attachments = body["attachments"].as_array().expect("attachments array");
assert_eq!(attachments.len(), 1, "{attachments:?}");
assert_eq!(attachments[0]["name"], "a.png");
assert_eq!(attachments[0]["media_type"], "image/png");
assert_eq!(attachments[0]["data_base64"], "AQID");
assert!(state.chat.pending_attachments.is_empty());
}
#[test]
fn streamed_events_fold_into_streaming_bubble() {
let mut chat = chat_with_queued_send("hello");
assert!(!apply_event_to_chat(
&mut chat,
&AiEvent::Delta("Hi ".into())
));
assert!(!apply_event_to_chat(
&mut chat,
&AiEvent::Thinking("consider…".into())
));
assert!(!apply_event_to_chat(
&mut chat,
&AiEvent::Delta("there".into())
));
assert!(apply_event_to_chat(&mut chat, &AiEvent::Done));
let msg = chat.messages.last().expect("assistant bubble");
assert_eq!(msg.content, "Hi there");
assert_eq!(msg.thinking, "consider…");
assert!(!msg.streaming, "Done clears the streaming flag");
}
#[test]
fn error_replaces_bubble_body_and_ends_turn() {
let mut chat = chat_with_queued_send("hello");
let _ = apply_event_to_chat(&mut chat, &AiEvent::Delta("partial".into()));
assert!(apply_event_to_chat(
&mut chat,
&AiEvent::Error("no model configured".into())
));
let msg = chat.messages.last().expect("assistant bubble");
assert_eq!(msg.content, "error: no model configured");
assert!(!msg.streaming);
}
#[test]
fn late_events_without_streaming_bubble_are_dropped() {
let mut chat = chat_with_queued_send("hello");
assert!(chat.stop_streaming());
let before = chat.messages.clone();
assert!(!apply_event_to_chat(
&mut chat,
&AiEvent::Delta("late".into())
));
assert!(apply_event_to_chat(&mut chat, &AiEvent::Done));
assert_eq!(chat.messages, before, "stopped transcript is untouched");
}
#[test]
fn parse_models_json_reads_string_arrays_leniently() {
assert_eq!(
parse_models_json(r#"["claude-sonnet-4-5","gpt-5.5"]"#),
vec!["claude-sonnet-4-5".to_string(), "gpt-5.5".to_string()]
);
assert_eq!(
parse_models_json(r#"["", " ", 3, "gemini-2.5-pro"]"#),
vec!["gemini-2.5-pro".to_string()]
);
assert!(parse_models_json("{}").is_empty());
assert!(parse_models_json("not json").is_empty());
}
#[test]
fn apply_models_populates_picker_and_preserves_selection() {
let mut state = EditorState::new();
let ids = vec![
"claude-sonnet-4-5".to_string(),
"gemini-2.5-pro".to_string(),
];
apply_models(&mut state, &ids);
assert_eq!(state.chat.available_models.len(), 2);
assert_eq!(state.chat.discovered_models.len(), 2);
assert_eq!(state.chat.selected_model, 0);
assert_eq!(
state.chat.available_models[1].provider,
AgentProvider::GeminiCli
);
// Select the second model, then re-apply a re-ordered catalog — the
// selection follows the entry by identity.
state.chat.selected_model = 1;
let ids2 = vec![
"gemini-2.5-pro".to_string(),
"claude-sonnet-4-5".to_string(),
];
apply_models(&mut state, &ids2);
assert_eq!(state.chat.selected_model, 0);
assert_eq!(
state.chat.selected_model_entry().map(|m| m.value.as_str()),
Some("gemini-2.5-pro")
);
}
#[test]
fn provider_heuristic_groups_known_id_shapes() {
assert_eq!(
provider_for_model_id("claude-sonnet-4-5"),
AgentProvider::ClaudeCode
);
assert_eq!(provider_for_model_id("gpt-5.5"), AgentProvider::CodexCli);
assert_eq!(
provider_for_model_id("gemini-2.5-pro"),
AgentProvider::GeminiCli
);
assert_eq!(
provider_for_model_id("copilot-fast"),
AgentProvider::GithubCopilot
);
assert_eq!(
provider_for_model_id("minimax-m2"),
AgentProvider::ClaudeCode
);
}
}