// crates/atomcode-tuix/src/event_loop/monitor.rs
//
// CodingPlan model-list drift monitor. Watches for two silent-failure
// modes:
//
// 1. The active provider's `model` has disappeared from the server's
// current CodingPlan model list. Next turn will 404; we want to
// warn BEFORE the user tries to send.
// 2. The local `AtomGit*` provider list has drifted from the server
// AND the user hasn't re-run `/codingplan` in > 24h. Soft hint —
// the current model still works, but there may be new models.
//
// The detection logic is split into `decide_warning` (pure function,
// fully unit-testable) and `spawn_check` (async runner that performs
// the HTTP call, applies the decision, and writes into the shared slot
// while sending a wake pulse). Non-CodingPlan providers never enter
// this path — `is_codingplan_provider` gates every trigger.
use std::time::Duration;
use atomcode_core::config::Config;
/// Warnings the monitor can raise, displayed right-aligned on the
/// status row below the input box.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodingPlanWarning {
/// The active provider's model no longer appears in the server's
/// model list. Carries the model name for the display string.
/// Renders in Role::Error (red).
ModelMissing(String),
/// Local AtomGit* provider list differs from the server and the
/// last-sync marker is older than 24h (or absent). Renders in
/// Role::Muted (dim).
StaleList,
}
impl CodingPlanWarning {
/// Short text rendered on the status row's right edge. Chinese
/// matches the rest of the user-facing strings in this flow
/// (e.g. `接口暂不可用`, `当前时间窗口用量约 0%`).
pub fn display_text(&self) -> String {
match self {
Self::ModelMissing(name) => format!("⚠ '{}' 已下线 — /login", name),
Self::StaleList => "ⓘ CodingPlan 模型列表更新 — 可执行/login".into(),
}
}
}
/// Rate limit for the background drift check — `spawn_check` won't
/// hit `/coding-plan/models` more than once per this interval within a
/// single TUI session. Doesn't gate warnings: once a check has run and
/// drift is detected, the user is told immediately regardless of when
/// they last ran `/login`. Startup always does one check (the
/// in-session `Instant` resets on restart).
///
/// 1 hour is a balance between:
/// * fast enough that server-side additions get surfaced within a
/// reasonable time on long-running sessions
/// * slow enough that every user message doesn't burn an API round-trip
pub const CHECK_COOLDOWN: Duration = Duration::from_secs(3600);
/// Prefix that marks a provider as CodingPlan-managed (matches the
/// wipe logic in `coding_plan::setup::is_codingplan_provider_name`).
const PROVIDER_PREFIX: &str = "AtomGit";
/// True iff the given provider key is owned by the CodingPlan flow.
/// Matches `AtomGit` (single-model case) or `AtomGit-<anything>`
/// (multi-model case); rejects `AtomGitPlus` / `atomgit` / etc.
pub fn is_codingplan_provider(name: &str) -> bool {
name == PROVIDER_PREFIX || name.starts_with(&format!("{}-", PROVIDER_PREFIX))
}
/// Collect the model names from every `AtomGit*` provider in the config.
/// Order follows HashMap iteration (unstable), so the caller should
/// never compare lists positionally — `decide_warning` sorts both
/// sides.
pub fn local_atomgit_models(config: &Config) -> Vec<String> {
config
.providers
.iter()
.filter(|(k, _)| is_codingplan_provider(k))
.map(|(_, p)| p.model.clone())
.collect()
}
/// Pure decision function. Given the active model, the server's model
/// list, and the local AtomGit* model list, returns the warning to
/// display (or `None` if everything is fine).
///
/// Priority: `ModelMissing` always wins over `StaleList` when both
/// conditions could fire — surfacing a model that's about to break is
/// more urgent than informing about silent drift.
///
/// No "recently synced" gate: once `spawn_check` has paid the HTTP
/// round-trip and the response shows drift, the information is
/// authoritative and the user should be told immediately — regardless
/// of how recently they ran `/codingplan`. The original 24h gate was
/// a solution to a non-problem: right after a successful `/codingplan`,
/// `sorted_eq(server, local)` is true by construction (setup wipes
/// AtomGit* entries and re-populates from the same server response),
/// so no `StaleList` fires anyway. Rate-limiting the HTTP call itself
/// is `CHECK_COOLDOWN`'s job.
pub fn decide_warning(
default_model: &str,
server_models: &[String],
local_models: &[String],
) -> Option<CodingPlanWarning> {
// Priority 1: active model no longer in server list.
if !server_models.iter().any(|m| m == default_model) {
return Some(CodingPlanWarning::ModelMissing(default_model.to_string()));
}
// Priority 2: any drift at all — the server list differs from what
// we have in config.
if !sorted_eq(server_models, local_models) {
return Some(CodingPlanWarning::StaleList);
}
None
}
/// Fire a background drift check. Reads the current config snapshot
/// into the tokio task (so it can run while the event loop continues),
/// hits `/coding-plan/models` via the blocking client on a spawn-blocking
/// thread, applies `decide_warning`, and writes the result into the
/// shared slot. Sends a wake pulse so the event loop repaints the
/// footer without waiting for a keystroke.
///
/// Silent on failure — if the HTTP call errors (no auth, 404, network),
/// the slot is left alone. Prior warning (if any) stays visible until
/// the next successful check corrects it.
///
/// Caller is responsible for gating: this function does NOT check
/// `is_codingplan_provider(default_provider)`; do that up front so
/// non-CodingPlan users never trigger any network I/O.
pub fn spawn_check(
config_snapshot: atomcode_core::config::Config,
default_model: String,
slot: std::sync::Arc<std::sync::Mutex<Option<CodingPlanWarning>>>,
wake_tx: tokio::sync::mpsc::Sender<()>,
) {
tokio::spawn(async move {
// Blocking HTTP client lives in a spawn_blocking thread so we
// don't stall the tokio runtime's worker pool.
let fetch: Result<Vec<String>, ()> = tokio::task::spawn_blocking(move || {
let client =
atomcode_core::coding_plan::client::Client::from_stored_auth().map_err(|_| ())?;
// Query with the user's ACTUAL tier, not `Max`. `plan_available` is
// computed relative to the requested `plan_type` (see `ModelEntry`
// docs): a Lite user querying `Max` gets higher-tier models (e.g.
// GLM-5.1) marked `plan_available=true` even though their config —
// built by setup with their real tier — correctly omits them. That
// mismatch fired a permanent "CodingPlan 模型列表更新" false positive.
//
// Derive the tier from `/status`'s plan_name; skip silently (no
// warning) if it can't be determined, rather than guessing `Max`
// and re-introducing the bug. This also self-corrects after a plan
// upgrade — once status reports the new tier, drift surfaces against
// the new model set and prompts a `/login` refresh.
let plan_type = client
.status_v2()
.ok()
.and_then(|s| s.codingplan_free)
.and_then(|p| atomcode_core::coding_plan::PlanType::from_plan_name(&p.plan_name))
.ok_or(())?;
let models = client.list_models_v2(plan_type).map_err(|_| ())?;
Ok(models
.into_iter()
.filter(|m| m.plan_available)
.map(|m| m.display_model_name)
.collect())
})
.await
.unwrap_or(Err(()));
let server_models = match fetch {
Ok(v) => v,
Err(_) => return, // silent
};
let local_models = local_atomgit_models(&config_snapshot);
let warning = decide_warning(&default_model, &server_models, &local_models);
if let Ok(mut g) = slot.lock() {
*g = warning;
}
// Best-effort wake — try_send so a full channel doesn't block us.
let _ = wake_tx.try_send(());
});
}
/// Order-independent equality for two model lists. Clones then sorts
/// — fine at these sizes (expected <= ~10 models).
fn sorted_eq(a: &[String], b: &[String]) -> bool {
if a.len() != b.len() {
return false;
}
let mut a: Vec<&str> = a.iter().map(|s| s.as_str()).collect();
let mut b: Vec<&str> = b.iter().map(|s| s.as_str()).collect();
a.sort_unstable();
b.sort_unstable();
a == b
}
#[cfg(test)]
mod tests {
use super::*;
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
#[test]
fn is_codingplan_provider_matches_prefix_and_exact() {
assert!(is_codingplan_provider("AtomGit"));
assert!(is_codingplan_provider("AtomGit-moonshotai-Kimi"));
assert!(!is_codingplan_provider("AtomGitPlus"));
assert!(!is_codingplan_provider("atomgit"));
assert!(!is_codingplan_provider("claude"));
}
#[test]
fn sorted_eq_ignores_order() {
assert!(sorted_eq(&s(&["a", "b"]), &s(&["b", "a"])));
assert!(!sorted_eq(&s(&["a", "b"]), &s(&["a", "c"])));
assert!(!sorted_eq(&s(&["a", "b"]), &s(&["a"])));
assert!(sorted_eq(&s(&[]), &s(&[])));
}
/// Active model in server list, lists match → no warning.
#[test]
fn decide_no_warning_when_in_list_and_match() {
let server = s(&["m1", "m2"]);
let local = s(&["m1", "m2"]);
assert_eq!(decide_warning("m1", &server, &local), None);
}
/// Lists match (order differs) → still no warning — `sorted_eq`
/// does the order-independent comparison.
#[test]
fn decide_no_warning_when_lists_match_out_of_order() {
let server = s(&["m1", "m2"]);
let local = s(&["m2", "m1"]);
assert_eq!(decide_warning("m1", &server, &local), None);
}
/// Lists differ → StaleList. No "recently synced" escape — we've
/// already done the HTTP round-trip and the drift is real.
/// Regression for the bug where a user who ran `/codingplan` 3 min
/// before restart got no hint even though the server had added a
/// new model during those 3 min.
#[test]
fn decide_stale_warning_whenever_lists_differ() {
let server = s(&["m1", "m2"]);
let local = s(&["m1"]); // missing m2
assert_eq!(
decide_warning("m1", &server, &local),
Some(CodingPlanWarning::StaleList)
);
}
/// Active model not in server list → ModelMissing.
#[test]
fn decide_model_missing_when_active_model_gone() {
let server = s(&["m2", "m3"]);
let local = s(&["m1", "m2", "m3"]);
assert_eq!(
decide_warning("m1", &server, &local),
Some(CodingPlanWarning::ModelMissing("m1".into()))
);
}
/// Priority: ModelMissing wins over StaleList when both could fire.
#[test]
fn decide_model_missing_wins_over_stale() {
let server = s(&["m2"]);
let local = s(&["m1"]);
assert_eq!(
decide_warning("m1", &server, &local),
Some(CodingPlanWarning::ModelMissing("m1".into()))
);
}
#[test]
fn display_text_format() {
assert_eq!(
CodingPlanWarning::ModelMissing("Kimi-K2".into()).display_text(),
"⚠ 'Kimi-K2' 已下线 — /login"
);
assert_eq!(
CodingPlanWarning::StaleList.display_text(),
"ⓘ CodingPlan 模型列表更新 — 可执行/login"
);
}
}