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

@@ -210,9 +210,9 @@ pub(crate) async fn apply(
auto_compact_threshold_percent: new_threshold,
responds_to: tx,
});
let updated_model = rx
let (updated_model, context_window) = rx
.await
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))?;
.map_err(|_| acp::Error::internal_error().data("failed to set session model"))??;
if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) {
handle.model_id = model_id.clone();
handle.reasoning_effort = applied_effort;
@@ -224,6 +224,7 @@ pub(crate) async fn apply(
&session_id,
model_id.0.as_ref(),
applied_effort.map(|eff| eff.to_string()),
context_window.get(),
);
if agent.cfg.borrow().mode != config::AgentMode::Leader {
agent.models_manager.set_current_model_id(model_id);
@@ -232,9 +233,12 @@ pub(crate) async fn apply(
.set_current_reasoning_effort(applied_effort);
}
Ok(acp::SetSessionModelResponse::new().meta(
serde_json::json!({ "model" : updated_model, })
.as_object()
.cloned(),
serde_json::json!({
"model": updated_model,
"totalContextTokens": context_window.get(),
})
.as_object()
.cloned(),
))
}
/// Broadcast a `ModelChanged` to every client subscribed to this session so
@@ -245,12 +249,14 @@ fn broadcast_model_changed(
session_id: &acp::SessionId,
model_id: &str,
reasoning_effort: Option<String>,
context_window: u64,
) {
let notification = crate::extensions::notification::SessionNotification {
session_id: session_id.clone(),
update: crate::extensions::notification::SessionUpdate::ModelChanged {
model_id: model_id.to_owned(),
reasoning_effort,
context_window: Some(context_window),
},
meta: None,
};
@@ -691,6 +691,11 @@ pub enum SessionUpdate {
/// does not support reasoning effort or no effort override was applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
/// The session's effective context window after the switch, so a
/// follower repaints its header without waiting for the next turn.
/// `None` from an agent that predates the field.
#[serde(default, skip_serializing_if = "Option::is_none")]
context_window: Option<u64>,
},
/// Streaming chunk of a tool call's arguments.
///
@@ -1970,15 +1975,18 @@ mod tests {
let with_effort = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("high".into()),
context_window: Some(262_144),
};
let json = serde_json::to_value(&with_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
assert_eq!(json["model_id"], "kigi-4");
assert_eq!(json["reasoning_effort"], "high");
assert_eq!(json["context_window"], 262_144);
let without_effort = SessionUpdate::ModelChanged {
model_id: "kigi-3".into(),
reasoning_effort: None,
context_window: None,
};
let json = serde_json::to_value(&without_effort).unwrap();
assert_eq!(json["sessionUpdate"], "model_changed");
@@ -1988,6 +1996,7 @@ mod tests {
"reasoning_effort: None must be skipped on the wire so old pagers \
and third-party ACP clients see a smaller, no-extra-keys payload"
);
assert!(json.get("context_window").is_none());
}
/// `ModelChanged` round-trips through JSON: a follower client deserializes
@@ -2000,6 +2009,7 @@ mod tests {
let original = SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: Some("medium".into()),
context_window: Some(1_000_000),
};
let json_str = serde_json::to_string(&original).unwrap();
let parsed: SessionUpdate = serde_json::from_str(&json_str).unwrap();
@@ -2021,6 +2031,7 @@ mod tests {
update: SessionUpdate::ModelChanged {
model_id: "kigi-4".into(),
reasoning_effort: None,
context_window: None,
},
meta: None,
};
@@ -1518,6 +1518,9 @@ mod load_user_prompts_tests;
#[path = "acp_session_tests/memory_config_tests.rs"]
mod memory_config_tests;
#[cfg(test)]
#[path = "acp_session_tests/model_switch_window_tests.rs"]
mod model_switch_window_tests;
#[cfg(test)]
#[path = "acp_session_tests/parallel_dispatch_tests.rs"]
mod parallel_dispatch_tests;
#[cfg(test)]
@@ -10,7 +10,7 @@ impl SessionActor {
apply_prompt_override: bool,
skip_prompt_rewrite: bool,
auto_compact_threshold_percent: u8,
) -> Result<acp::ModelId, acp::Error> {
) -> Result<(acp::ModelId, std::num::NonZeroU64), acp::Error> {
let model_id = acp::ModelId::new(sampling_config.model.clone());
// H4: record the picker's catalog KEY as this SESSION's own selection.
// `sampling_config.model` is the ambiguous bare slug; the key is what
@@ -120,7 +120,7 @@ impl SessionActor {
agent_name: Some(agent_name),
reasoning_effort: Some(sampling_config.reasoning_effort),
});
Ok(model_id)
Ok((model_id, new_context_window))
}
/// Handle [`SessionCommand::RebuildAgentForDefinition`].
///
@@ -0,0 +1,24 @@
//! `handle_set_session_model` reports the session's effective context
//! window so clients repaint their header without waiting for a turn.
use super::support::*;
#[tokio::test(flavor = "current_thread")]
async fn set_session_model_returns_the_effective_context_window() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let (actor, _rx) = build_actor().await;
let cfg = kigi_sampler::SamplerConfig {
model: "narrow".to_string(),
context_window: 128_000,
..Default::default()
};
let (model_id, window) = actor
.handle_set_session_model(cfg, None, false, false, true, 85)
.await
.expect("model switch");
assert_eq!(model_id.0.as_ref(), "narrow");
assert_eq!(window.get(), 128_000);
})
.await;
}
@@ -173,7 +173,9 @@ pub enum SessionCommand {
/// `compaction.threshold_percent` (which is `Cell<u8>` so it can
/// update without `&mut self`).
auto_compact_threshold_percent: u8,
responds_to: oneshot::Sender<Result<acp::ModelId, acp::Error>>,
/// Ok: the applied model id and the session's effective context
/// window, which clients repaint from without waiting for a turn.
responds_to: oneshot::Sender<Result<(acp::ModelId, std::num::NonZeroU64), acp::Error>>,
},
/// Zero-turn harness rebuild: build a brand-new `Agent` from the
/// session's `AgentRebuildSpec` and the new `AgentDefinition`,