After `/model`, the header kept the previous model's window ("15K /
1.0M" on a 262K model) until the next turn or a restart. The pager
caches the window inside its context snapshot (streaming `totalTokens`
updates refresh it from the catalog) and nothing refreshed the cache
when the session's model changed. Comparing cached numbers against
catalog windows cannot tell a catalog-derived total from a shell
effective limit (KIGI_DEBUG_CONTEXT_WINDOW, header upgrades), so the
shell, which installs the effective window, now reports it:
- kigi-shell: `handle_set_session_model` returns the installed window;
`model_switch::apply` stamps it as `totalContextTokens` in the
`session/set_model` response meta and carries it on the
`ModelChanged` broadcast (`context_window`, serde-default: old pagers
ignore it, old leaders omit it). The response meta previously
serialized the actor's inner `Result` and reported success on an
actor failure; that error now propagates.
- kigi-tui: `Effect::SwitchModel` reads the meta; `SwitchModelComplete`
carries `Ok(Option<u64>)`; the completion handler and the follower
`ModelChanged` arm call `AgentView::apply_context_window`, which
re-derives the snapshot (pct, free tokens) and reports whether it
changed so a follower redraws on a window-only change.
- Tests: the bare `/model <name>` dispatch path asserts the snapshot and
the drawn header; follower with/without the field; same-model new
window redraw; actor returns the window; serde round trip.
Claude-Session: https://claude.ai/code/session_01RUqV8HVGXcAETyYbZ2tPQ4
506 lines
19 KiB
Rust
506 lines
19 KiB
Rust
#![cfg_attr(rustfmt, rustfmt::skip)]
|
|
use super::*;
|
|
|
|
/// Regression: a machine-wide `kigi/models/update` broadcast
|
|
/// carries each model's static catalog-default effort (`high`), not the
|
|
/// session's chosen `xhigh`, and must not clobber the per-session choice.
|
|
#[test]
|
|
fn models_update_preserves_user_reasoning_effort() {
|
|
use kigi_shell::sampling::types::ReasoningEffort;
|
|
let mut app = make_app_with_agent("sess-1");
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let id = acp::ModelId::new(std::sync::Arc::from("reason-model"));
|
|
let mut info = make_model_info("reason-model");
|
|
info.meta = serde_json::json!({
|
|
"supportsReasoningEffort": true,
|
|
"reasoningEffort": "high",
|
|
})
|
|
.as_object()
|
|
.cloned();
|
|
agent.session.models.available.insert(id.clone(), info);
|
|
agent
|
|
.session
|
|
.models
|
|
.set_current(id, Some(ReasoningEffort::Xhigh));
|
|
assert_eq!(
|
|
agent.session.models.reasoning_effort,
|
|
Some(ReasoningEffort::Xhigh)
|
|
);
|
|
|
|
let notif = make_reasoning_models_update_notif("reason-model", "high");
|
|
assert!(handle_models_update(¬if, &mut app));
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent.session.models.reasoning_effort,
|
|
Some(ReasoningEffort::Xhigh),
|
|
"models/update broadcast must not clobber a user-set per-session effort"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_update_preserves_active_agent_model() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3"));
|
|
agent
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(id_3.clone(), make_model_info("kigi-3"));
|
|
agent.session.models.current = Some(id_3);
|
|
|
|
let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]);
|
|
handle_models_update(¬if, &mut app);
|
|
|
|
assert_eq!(
|
|
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"app.models.current must preserve active agent's model, not remote settings default"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"agent's per-session model must be preserved"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_update_uses_shell_default_when_agent_model_removed() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3"));
|
|
agent
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(id_3.clone(), make_model_info("kigi-3"));
|
|
agent.session.models.current = Some(id_3);
|
|
|
|
// kigi-3 removed from catalog.
|
|
let notif = make_models_update_notif("kigi-4.3", &["kigi-4.3", "kigi-4.5"]);
|
|
handle_models_update(¬if, &mut app);
|
|
|
|
assert_eq!(
|
|
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
|
Some("kigi-4.3"),
|
|
"app.models.current must use shell default when agent model removed"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-4.3"),
|
|
"agent must fall back to shell default when its model is removed"
|
|
);
|
|
}
|
|
|
|
fn seed_windowed_models(agent: &mut AgentView) {
|
|
for (slug, window) in [("wide", 1_000_000u64), ("narrow", 262_144)] {
|
|
let mut info = make_model_info(slug);
|
|
info.meta = serde_json::json!({ "totalContextTokens": window })
|
|
.as_object()
|
|
.cloned();
|
|
agent
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(acp::ModelId::new(std::sync::Arc::from(slug)), info);
|
|
}
|
|
agent
|
|
.session
|
|
.models
|
|
.set_current(acp::ModelId::new(std::sync::Arc::from("wide")), None);
|
|
agent.apply_context_used(15_000, 1_000_000);
|
|
}
|
|
|
|
/// A follower mirrors a remote switch and installs the effective window
|
|
/// the broadcast carries, like the invoking client does.
|
|
#[test]
|
|
fn model_changed_broadcast_installs_the_reported_context_window() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
|
|
|
let notif = model_changed_ext("sess-1", "narrow", None, Some(262_144));
|
|
assert!(handle_ext_notification(¬if, &mut app));
|
|
|
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
|
assert_eq!((snap.used, snap.total, snap.usage_pct), (15_000, 262_144, 6));
|
|
}
|
|
|
|
/// Same model, same effort, new effective window (the catalog was
|
|
/// reloaded and re-applied): the follower must still request a redraw.
|
|
#[test]
|
|
fn model_changed_same_model_new_window_requests_redraw() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
|
|
|
let notif = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
|
|
assert!(handle_ext_notification(¬if, &mut app));
|
|
|
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
|
assert_eq!((snap.used, snap.total), (15_000, 2_000_000));
|
|
|
|
let again = model_changed_ext("sess-1", "wide", None, Some(2_000_000));
|
|
assert!(!handle_ext_notification(&again, &mut app));
|
|
}
|
|
|
|
/// A broadcast from a leader that predates the field carries no window;
|
|
/// the snapshot is left for the next streaming update to refresh.
|
|
#[test]
|
|
fn model_changed_broadcast_without_window_keeps_the_snapshot() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
seed_windowed_models(app.agents.get_mut(&AgentId(0)).unwrap());
|
|
|
|
let notif = model_changed_ext("sess-1", "narrow", None, None);
|
|
assert!(handle_ext_notification(¬if, &mut app));
|
|
|
|
let snap = app.agents[&AgentId(0)].context_state.as_ref().unwrap();
|
|
assert_eq!((snap.used, snap.total), (15_000, 1_000_000));
|
|
}
|
|
|
|
#[test]
|
|
fn models_update_without_active_agent_uses_shell_default() {
|
|
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
|
|
let mut app = AppView::new(tx, ModelState::default(), Vec::new());
|
|
|
|
let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]);
|
|
handle_models_update(¬if, &mut app);
|
|
|
|
assert_eq!(
|
|
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
|
Some("kigi-4"),
|
|
"without an active agent, shell default must be used"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_update_noop_when_agent_matches_shell_default() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let id_4 = acp::ModelId::new(std::sync::Arc::from("kigi-4"));
|
|
agent
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(id_4.clone(), make_model_info("kigi-4"));
|
|
agent.session.models.current = Some(id_4);
|
|
|
|
let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]);
|
|
handle_models_update(¬if, &mut app);
|
|
|
|
assert_eq!(
|
|
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
|
Some("kigi-4"),
|
|
"app.models.current must be kigi-4 when agent and shell agree"
|
|
);
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-4"),
|
|
"agent model must remain kigi-4"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn models_update_non_active_agent_uses_shell_fallback_not_active_model() {
|
|
let mut app = make_app_with_agent("sess-A");
|
|
insert_agent(&mut app, AgentId(1), Some("sess-B"));
|
|
|
|
{
|
|
let agent_a = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
let id_3 = acp::ModelId::new(std::sync::Arc::from("kigi-3"));
|
|
agent_a
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(id_3.clone(), make_model_info("kigi-3"));
|
|
agent_a.session.models.current = Some(id_3);
|
|
}
|
|
|
|
{
|
|
let agent_b = app.agents.get_mut(&AgentId(1)).unwrap();
|
|
let id_5 = acp::ModelId::new(std::sync::Arc::from("kigi-4.5"));
|
|
agent_b
|
|
.session
|
|
.models
|
|
.available
|
|
.insert(id_5.clone(), make_model_info("kigi-4.5"));
|
|
agent_b.session.models.current = Some(id_5);
|
|
}
|
|
|
|
// kigi-4.5 removed from catalog.
|
|
let notif = make_models_update_notif("kigi-4", &["kigi-3", "kigi-4"]);
|
|
handle_models_update(¬if, &mut app);
|
|
|
|
assert_eq!(
|
|
app.models.current.as_ref().map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
);
|
|
let agent_a = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent_a
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"agent A's model must be preserved"
|
|
);
|
|
|
|
// B's kigi-4.5 was removed — must fall back to shell's kigi-4, not A's kigi-3.
|
|
let agent_b = app.agents.get(&AgentId(1)).unwrap();
|
|
assert_eq!(
|
|
agent_b
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-4"),
|
|
"inactive agent must fall back to shell default, not active agent's model"
|
|
);
|
|
}
|
|
|
|
/// A follower client (no in-flight switch of its own) receives the
|
|
/// leader's `ModelChanged` broadcast and silently mirrors the new model
|
|
/// into its local state — no scrollback entry, no toast, just enough
|
|
/// state for the status bar / `/model` dropdown to render correctly.
|
|
#[test]
|
|
fn model_changed_updates_state_silently_on_follower() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
|
let scrollback_before = agent.scrollback.len();
|
|
assert!(!agent.session.model_switch_pending);
|
|
|
|
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
|
|
let changed = handle_ext_notification(¬if, &mut app);
|
|
assert!(
|
|
changed,
|
|
"follower's state changed → handler must request a redraw"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-4"),
|
|
"follower must mirror the remote switch into its local model state",
|
|
);
|
|
assert_eq!(
|
|
agent.scrollback.len(),
|
|
scrollback_before,
|
|
"follower must NOT push a 'Switched to' scrollback entry — that is \
|
|
the invoking client's job (SwitchModelComplete owns the system message)"
|
|
);
|
|
assert!(
|
|
!agent.session.model_switch_pending,
|
|
"follower's pending flag must stay false (no local switch was issued)"
|
|
);
|
|
}
|
|
|
|
/// A live remote `ModelChanged` (leader-mode fan-out from another client)
|
|
/// must apply even when this client already has a local
|
|
/// `user_model_preference` — otherwise the status bar desyncs from the
|
|
/// gateway session. Preference is updated to track the new live model.
|
|
/// (History-replay silent-revert is suppressed on the shell side via
|
|
/// `ReconnectState::user_selected_model`, not by permanently blocking
|
|
/// remote ModelChanged here.)
|
|
#[test]
|
|
fn model_changed_applies_and_updates_user_model_preference() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "heavy", &["auto", "heavy"]);
|
|
agent.session.user_model_preference =
|
|
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
|
|
assert!(!agent.session.model_switch_pending);
|
|
|
|
let notif = model_changed_ext("sess-1", "auto", None, None);
|
|
let changed = handle_ext_notification(¬if, &mut app);
|
|
assert!(
|
|
changed,
|
|
"remote live ModelChanged must apply despite prior local preference"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("auto"),
|
|
"selector must mirror the remote switch"
|
|
);
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.user_model_preference
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("auto"),
|
|
"preference must track the applied remote switch"
|
|
);
|
|
}
|
|
|
|
/// The invoking client is also a subscriber to its own session and so
|
|
/// receives the broadcast it triggered. Its in-flight
|
|
/// `SetSessionModelResponse` is the authority for its local state +
|
|
/// the single "Switched to X" scrollback entry, so the broadcast handler
|
|
/// must be a no-op here — gated on `model_switch_pending == true`.
|
|
///
|
|
/// Concretely we verify the broadcast does NOT touch
|
|
/// `models.current` (preserving the pre-response snapshot) — that
|
|
/// snapshot is what `SwitchModelComplete`'s `unchanged` check compares
|
|
/// against to decide whether to render the "Switched to X" message. If
|
|
/// the broadcast optimistically updated state here, the response
|
|
/// handler would see `prev == new`, mark it unchanged, and suppress the
|
|
/// user-facing message entirely.
|
|
#[test]
|
|
fn model_changed_skipped_when_local_switch_in_flight() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
|
agent.session.model_switch_pending = true;
|
|
let scrollback_before = agent.scrollback.len();
|
|
|
|
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
|
|
let changed = handle_ext_notification(¬if, &mut app);
|
|
assert!(
|
|
!changed,
|
|
"broadcast must be a no-op while local switch is pending"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"models.current must stay at the pre-response snapshot — \
|
|
SwitchModelComplete owns the final apply + system message"
|
|
);
|
|
assert_eq!(
|
|
agent.scrollback.len(),
|
|
scrollback_before,
|
|
"broadcast must not push any scrollback entry on the invoker"
|
|
);
|
|
assert!(
|
|
agent.session.model_switch_pending,
|
|
"pending flag must remain set until SwitchModelComplete arrives"
|
|
);
|
|
}
|
|
|
|
/// A `ModelChanged` broadcast carrying a model id the local catalog
|
|
/// doesn't know about must be dropped — applying it would render an
|
|
/// unresolvable id in the status bar and desync the `/model` dropdown.
|
|
/// This can happen when leader and a follower client briefly disagree
|
|
/// on the model catalog (etag drift, custom-model config skew).
|
|
#[test]
|
|
fn model_changed_dropped_when_model_unknown_to_catalog() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
|
|
|
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None);
|
|
let changed = handle_ext_notification(¬if, &mut app);
|
|
assert!(
|
|
!changed,
|
|
"unknown model must NOT trigger a redraw — no state changed"
|
|
);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"models.current must stay on the previously-known model"
|
|
);
|
|
}
|
|
|
|
/// `reasoning_effort` round-trips through the broadcast: the follower
|
|
/// applies it alongside the model id so the prompt header / status bar
|
|
/// show the right effort without waiting for a subsequent
|
|
/// `kigi/models/update`.
|
|
#[test]
|
|
fn model_changed_applies_reasoning_effort_on_follower() {
|
|
use kigi_shell::sampling::types::ReasoningEffort;
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
|
|
|
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None);
|
|
assert!(handle_ext_notification(¬if, &mut app));
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent.session.models.reasoning_effort,
|
|
Some(ReasoningEffort::High),
|
|
"follower must mirror the broadcast's reasoning_effort"
|
|
);
|
|
}
|
|
|
|
/// `ModelChanged` for a session this client doesn't own / hasn't loaded
|
|
/// must be dropped — `find_session_match` returns `None`. The bug-flavored
|
|
/// version of this would be: leader-mode A switches model on session X
|
|
/// (which this client never opened) and we accidentally apply the change
|
|
/// to the active agent.
|
|
#[test]
|
|
fn model_changed_dropped_for_unknown_session_id() {
|
|
let mut app = make_app_with_agent("sess-1");
|
|
let agent = app.agents.get_mut(&AgentId(0)).unwrap();
|
|
seed_models(agent, "kigi-3", &["kigi-3", "kigi-4"]);
|
|
|
|
let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None);
|
|
let changed = handle_ext_notification(¬if, &mut app);
|
|
assert!(!changed);
|
|
|
|
let agent = app.agents.get(&AgentId(0)).unwrap();
|
|
assert_eq!(
|
|
agent
|
|
.session
|
|
.models
|
|
.current
|
|
.as_ref()
|
|
.map(|id| id.0.as_ref()),
|
|
Some("kigi-3"),
|
|
"unrelated-session broadcast must not touch this agent's model"
|
|
);
|
|
}
|
|
|