fix(tui): repaint the header context window on a /model switch

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
This commit is contained in:
雷电芽衣 committed 2026-09-13 00:15:14 -04:00
1 parent ff92a00fea
commit b957e12370
16 files changed
+288 -27

No files matched your search

@@ -110,6 +110,70 @@
);
}
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(&notif, &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(&notif, &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(&notif, &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();
@@ -232,7 +296,7 @@
let scrollback_before = agent.scrollback.len();
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -278,7 +342,7 @@
Some(acp::ModelId::new(std::sync::Arc::from("heavy")));
assert!(!agent.session.model_switch_pending);
let notif = model_changed_ext("sess-1", "auto", None);
let notif = model_changed_ext("sess-1", "auto", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
changed,
@@ -328,7 +392,7 @@
agent.session.model_switch_pending = true;
let scrollback_before = agent.scrollback.len();
let notif = model_changed_ext("sess-1", "kigi-4", None);
let notif = model_changed_ext("sess-1", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -369,7 +433,7 @@
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);
let notif = model_changed_ext("sess-1", "kigi-99-unknown", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(
!changed,
@@ -400,7 +464,7 @@
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"));
let notif = model_changed_ext("sess-1", "kigi-4", Some("high"), None);
assert!(handle_ext_notification(&notif, &mut app));
let agent = app.agents.get(&AgentId(0)).unwrap();
@@ -422,7 +486,7 @@
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);
let notif = model_changed_ext("sess-OTHER", "kigi-4", None, None);
let changed = handle_ext_notification(&notif, &mut app);
assert!(!changed);