Run every model at the 1M context window

The window is a beta header, so nothing on disk records it. Observing it on
the wire and replaying it on resume had to survive a session rename, a hot
reload, a WebSearch sub-request and a process restart — every gap fell back
to the short window.

Force it instead: Models::arg is the single decision point, and every spawn
and resume goes through it. A model is still only suffixed when the installed
claude ships that variant, so haiku stays haiku.
This commit is contained in:
Jonas H
2026-08-27 13:06:21 +02:00
parent a5a6092579
commit 73871cb1dc
5 changed files with 291 additions and 294 deletions

View File

@@ -122,7 +122,8 @@ src/sessions.rs on-disk session history (main chain *and* subagents):
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
mtime change — one pass yields the label *and* the session's
last main-chain model, which `App::resume_model` turns into the
`--model` a resume spawns with); load_view/load_history rebuild a feed Session
`--model` a resume spawns with — always at `[1m]`);
load_view/load_history rebuild a feed Session
from a JSONL transcript (lazily, on first view) and give it the
same `<task-notification>` treatment as the live path — notes
lifted into Kind::TaskNote, the `<result>` moved onto the `Agent`
@@ -145,8 +146,9 @@ src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
`x-claude-cloak-pane`), the correlation handle the proxy uses to
recognise the pane's own traffic (see the embed-identity invariant).
`cc_default_model` reads Claude Code's *own* configured default
model out of its settings — the only persisted record of a
`[1m]` pick (see the 1M-context invariant). `EmbeddedTerm::adopt`
model out of its settings, and `spawn_model_discovery` scans the
`claude` binary for `App::models` (the aliases, and which of them
ship a `[1m]` variant — see the 1M invariant). `EmbeddedTerm::adopt`
rebuilds a pane around an inherited pty fd + pid after a hot
reload (`AdoptedMaster` / `PidKiller` stand in for the
portable-pty handles, which do not survive an exec)
@@ -276,9 +278,7 @@ UI thread redraws on its own tick (no channel; just the mutex).
is labelled `web_search · "<query>"`, finished in `Tap::drop` (one request =
one turn), emits no system/tools/side-request lines, and is readable only in
the `A` popup — never in the main feed. Treating it as a turn start used to
push a fake user prompt, clobber the main lane's system/tools signatures, and
**silently downgrade a `[1m]` session to the short window** on the next
resume.
push a fake user prompt and clobber the main lane's system/tools signatures.
- **The trailing user run skips `role:"system"` messages.** Claude Code ≥2.1.247
sends beta `mid-conversation-system-2026-04-07` and appends the agent-type
listing as a `role:"system"` message *after* the prompt, so turn 1 of every
@@ -433,31 +433,34 @@ agentId: <hex>`), and the real completion is injected into the parent's next
last main-chain assistant message (`DiskSession::model`, filled by the
scanner's `read_meta` — subagent `isSidechain` records run their own model
and `<synthetic>` error records carry no model, so both are skipped) and
`app::model_arg_for_id` maps that id to a `--model` argument: a known alias
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
`Models::base_for_id` maps that id to a base model name: a known alias
(`sonnet`, `opus`, … from `Models::aliases`) wins over the dated snapshot
id, so a retired snapshot can't pin the pane; an id with no alias inside is
passed through verbatim (`--model` takes full names too). The transcript is
authoritative, so a mid-session `/model` switch is honoured.
- **The 1M context window is a header, and a resume must keep it.**
`--model opus[1m]` differs from `opus` only by `anthropic-beta:
…,context-1m-…` — same body `model`, same transcript record — so no amount of
transcript reading can tell them apart. The proxy is the only place that
sees it: `proxy.rs` reads `BETA_HEADER` on **main-chain turn requests only**
— gated on `ReqKind::Turn && MAIN_LANE`, because a side/title call runs haiku
without the flag, a subagent runs its own model, and a nested server-tool call
never carries the flag at all — and `app::record_long_context` stores it as
`Session::long_context`.
`App::resume_arg` then picks the window: the wire observation wins, else the
`[1m]` in `Session::spawn_model` (our own spawn, while it still names the
same model), and with **neither** — a session that predates this process —
it falls back to `term::cc_default_model()`, Claude Code's configured default
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`), which is the
one place a `[1m]` pick is persisted (`/model` writes it there). When that
default names the same base model the resume passes **no `--model` at all**
and inherits it whole, window included; any explicit knowledge overrides it,
including "this session ran the *short* window", which is why an observed
non-1m session is resumed with an explicit `--model opus`. The suffix is only
ever added for an alias that `model_choices` says has a `[1m]` variant.
authoritative, so a mid-session `/model` switch is honoured. Only the
*model* is decided there — the window is not a question (see below).
- **Every model runs the 1M context window, always.** `Models::arg` is the one
place that decides and it answers `<base>[1m]` whenever the installed
`claude` ships that variant (`Models::long`, read out of the binary — the
suffix is never assumed, so `haiku` stays `haiku`). Every spawn goes through
it: `App::spawn_arg` for a fresh pane and the `a` picker, `App::resume_arg`
for a resume. So a resume is `--model <base>[1m]` and nothing infers a
window any more.
The window is a *header*, not a model: `--model opus[1m]` differs from
`opus` only by `anthropic-beta: …,context-1m-…`, and the body `model` and
the transcript record read identically either way. That is why the old
"observe it on the wire, replay it on resume" design existed — and why it
kept losing the window: the observation had to survive a rename, a reload,
a `WebSearch` sub-request and a process restart, and any gap fell back to
the short window. Forcing it removes the failure mode instead of patching
it. `Session::long_context`, `record_long_context` and the proxy's
`anthropic-beta` read are gone with it.
A model still has to be *named* for the suffix to attach, so a session we
know nothing about (no transcript model, no spawn argument) borrows the
model — never the window — from `term::cc_default_model()`
(`ANTHROPIC_MODEL`, then local/project/user `settings.json`). With no
default configured either, the spawn passes no `--model` at all and
`claude` picks both.
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
real user prompt, abandoned rewind branches indented `⑂` under their fork
@@ -593,19 +596,23 @@ agentId: <hex>`), and the real completion is injected into the parent's next
enter/leave the turn tree, a opens the model picker popup and spawns a
brand-new `claude --session-id … [--model …]` (kills any current pane —
`show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker
list comes from `App::model_choices`: seeded with `default_model_choices`,
then replaced by `term::spawn_model_discovery` — a background scan that
reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`)
straight out of the installed `claude` ELF (single self-contained binary
with the JS bundle embedded). No API call, never runs claude — just resolves
`claude` on PATH and greps its bytes for the longest lowercase-token array
anchored by `opus`+`sonnet`. The list then gets the **1M-context variants**
appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long
context window): `term::long_context_tokens` collects every quoted
`"<token>[1m]"` literal in the same byte scan, and only aliases that really
have one are offered (today `opus`/`sonnet`/`fable`*not* `haiku` or
`mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting:
the pane spawns via `CommandBuilder` argv, not a shell.
list is `Models::choices()` — one row per model, at the window
`Models::arg` gives it (`sonnet (1M context)``sonnet[1m]`, `haiku`
`haiku`), plus a `default` row that `App::spawn_arg` resolves to Claude
Code's own configured model. There is deliberately **no short-window row**:
every model runs 1M (see the invariant), so offering one would be a lie.
`App::models` is seeded by `Models::seed` and replaced by
`term::spawn_model_discovery` — a background scan that reads the live
model-alias array (`["sonnet","opus","haiku","fable",…]`) straight out of
the installed `claude` ELF (single self-contained binary with the JS bundle
embedded). No API call, never runs claude — just resolves `claude` on PATH
and greps its bytes for the longest lowercase-token array anchored by
`opus`+`sonnet`. The same pass collects every quoted `"<token>[1m]"` literal
(`term::long_context_tokens`) and keeps the base names as `Models::long`, so
only models that really have the variant get the suffix — today
`opus`/`sonnet`/`fable` and a set of full ids, *not* `haiku` or `mythos`.
`[1m]` needs no shell quoting: the pane spawns via `CommandBuilder` argv,
not a shell.
Tab/BackTab cycle sessions (`p` no longer
mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree →
quit). n/N jump the feed scroll to the next/previous user prompt

View File

@@ -16,55 +16,6 @@ pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> {
pub const FILTER_LABELS: [&str; 7] =
["user", "thinking", "text", "tools", "meta", "errors", "system"];
/// Fallback model picker entries used until (or unless) `term::discover_models`
/// reads the live alias set out of the installed `claude` binary. Each entry is
/// `(label, --model arg)`; an empty arg means no `--model` flag (Claude Code's
/// configured default).
///
/// The `<alias>[1m]` entries are Claude Code's spelling for the 1M-context
/// variant of a model (only some aliases have one — `haiku` does not). The live
/// set is verified against the binary by `term::long_context_tokens`; these are
/// just the seeds.
pub fn default_model_choices() -> Vec<(String, String)> {
[
("default", ""),
("opus", "opus"),
("sonnet", "sonnet"),
("haiku", "haiku"),
("fable", "fable"),
("opus[1m] (1M context)", "opus[1m]"),
("sonnet[1m] (1M context)", "sonnet[1m]"),
("fable[1m] (1M context)", "fable[1m]"),
]
.iter()
.map(|(l, a)| (l.to_string(), a.to_string()))
.collect()
}
/// Map an API model id (as recorded in a transcript, e.g.
/// `claude-sonnet-4-5-20250929`) to a `--model` argument for `claude`.
///
/// A known alias (`sonnet`, `opus`, … — the same list the `a` picker uses)
/// wins over the dated snapshot id: it keeps the intent ("this session ran on
/// sonnet") without pinning a snapshot that may later be retired. An id with
/// no alias inside is passed through verbatim (`--model` accepts full model
/// names too). Placeholders Claude Code writes for its own records
/// (`<synthetic>`) and our `(resumed)` filler yield None.
pub fn model_arg_for_id(id: &str, aliases: &[String]) -> Option<String> {
let id = id.trim();
if id.is_empty() || id.starts_with('<') || id.starts_with('(') {
return None;
}
// Longest match wins, so a short alias can never shadow a longer one.
let mut hit: Option<&String> = None;
for a in aliases.iter().filter(|a| !a.is_empty() && !a.contains('[')) {
if id.contains(a.as_str()) && hit.is_none_or(|h| a.len() > h.len()) {
hit = Some(a);
}
}
Some(hit.map_or_else(|| id.to_string(), String::clone))
}
/// Claude Code's suffix for the 1M-context variant of a model (`sonnet[1m]`).
pub const LONG_SUFFIX: &str = "[1m]";
@@ -73,6 +24,92 @@ pub fn base_model(arg: &str) -> &str {
arg.split_once('[').map_or(arg, |(b, _)| b)
}
/// The models the installed `claude` accepts — facts, not picker rows.
/// Seeded by `Models::seed` and replaced by `term::spawn_model_discovery`,
/// which reads both lists out of the `claude` binary.
///
/// **Every model runs the 1M context window.** `Models::arg` is the single
/// place that decides, and it always answers `<base>[1m]` when the binary
/// ships that variant. Nothing infers the window any more: the wire
/// observation, the spawn argument and Claude Code's configured default only
/// ever named a *model*, never a window we had to keep.
pub struct Models {
/// Plain aliases in the order `claude` lists them (`opus`, `sonnet`, …).
pub aliases: Vec<String>,
/// Base names that really ship a `[1m]` variant (`haiku` does not), so the
/// suffix is never assumed. Aliases *and* full model ids, because a
/// transcript can name a model no alias covers.
pub long: Vec<String>,
}
impl Models {
/// Fallback catalog used until (or unless) the binary scan succeeds.
pub fn seed() -> Self {
let v = |xs: &[&str]| xs.iter().map(|s| (*s).to_string()).collect();
Self {
aliases: v(&["opus", "sonnet", "haiku", "fable"]),
long: v(&["opus", "sonnet", "fable"]),
}
}
/// The `--model` argument for `base`: its 1M variant when one exists,
/// else `base` unchanged. Any `[1m]` already on `base` is re-derived, so
/// passing an argument back through is a no-op. An empty `base` stays
/// empty (= pass no `--model` flag).
pub fn arg(&self, base: &str) -> String {
let base = base_model(base.trim());
if base.is_empty() {
return String::new();
}
if self.long.iter().any(|l| l == base) {
format!("{base}{LONG_SUFFIX}")
} else {
base.to_string()
}
}
/// `a` picker rows `(label, --model arg)`, one per model: `default` (no
/// flag — resolved to Claude Code's own default by `App::spawn_arg`) then
/// every alias at the window `arg` gives it. There is no short-window row;
/// offering one would contradict the policy above.
pub fn choices(&self) -> Vec<(String, String)> {
let mut rows = vec![("default".to_string(), String::new())];
rows.extend(self.aliases.iter().map(|a| {
let arg = self.arg(a);
// The label says which window this row really spawns, so a model
// without a `[1m]` variant can't look like it has one.
let label =
if arg == *a { a.clone() } else { format!("{a} (1M context)") };
(label, arg)
}));
rows
}
/// Map an API model id (as recorded in a transcript, e.g.
/// `claude-sonnet-4-5-20250929`) to a base model name.
///
/// A known alias (`sonnet`, `opus`, …) wins over the dated snapshot id: it
/// keeps the intent ("this session ran on sonnet") without pinning a
/// snapshot that may later be retired. An id with no alias inside is passed
/// through verbatim (`--model` accepts full model names too). Placeholders
/// Claude Code writes for its own records (`<synthetic>`) and our
/// `(resumed)` filler yield None.
pub fn base_for_id(&self, id: &str) -> Option<String> {
let id = id.trim();
if id.is_empty() || id.starts_with('<') || id.starts_with('(') {
return None;
}
// Longest match wins, so a short alias can never shadow a longer one.
let mut hit: Option<&String> = None;
for a in self.aliases.iter().filter(|a| !a.is_empty()) {
if id.contains(a.as_str()) && hit.is_none_or(|h| a.len() > h.len()) {
hit = Some(a);
}
}
Some(hit.map_or_else(|| id.to_string(), String::clone))
}
}
pub struct App {
pub sessions: Vec<Session>,
pub selected: usize,
@@ -84,12 +121,12 @@ pub struct App {
/// `Some(selected_row)` while the filter popup is open.
pub filter_popup: Option<usize>,
/// `Some(selected_row)` while the `n` model-picker popup is open; picking a
/// model spawns a fresh embedded session (indexes `model_choices`).
/// model spawns a fresh embedded session (indexes `Models::choices`).
pub model_popup: Option<usize>,
/// Model picker entries `(label, --model arg)`. Seeded with
/// `default_model_choices`, then replaced by `term::discover_models` once a
/// background scan reads the live alias set from the `claude` binary.
pub model_choices: Vec<(String, String)>,
/// The models the installed `claude` accepts. Seeded with `Models::seed`,
/// then replaced by `term::spawn_model_discovery` once a background scan
/// reads the live sets from the `claude` binary.
pub models: Models,
/// Whether the session list panel is expanded.
pub show_sessions: bool,
/// Past sessions on disk for this directory (newest first), maintained by
@@ -246,7 +283,7 @@ impl App {
filters: [true; FILTER_LABELS.len()],
filter_popup: None,
model_popup: None,
model_choices: default_model_choices(),
models: Models::seed(),
show_sessions: false,
disk_sessions: Vec::new(),
history: HashMap::new(),
@@ -288,8 +325,8 @@ impl App {
}
/// The `--model` argument a resume of `key` should spawn with, so the pane
/// continues on the model *and context window* that session last used
/// instead of the CLI default. Empty string = pass no `--model` flag.
/// continues on the model that session last used — at the 1M window, like
/// every other spawn. Empty string = pass no `--model` flag.
///
/// Reads `term::cc_default_model()` (Claude Code's own configured default);
/// `resume_arg` holds the decision itself.
@@ -300,51 +337,42 @@ impl App {
/// The resume decision, with Claude Code's configured default passed in
/// (so it is testable without touching settings files).
///
/// *Which model*: the on-disk transcript wins — Claude Code records the
/// Only the **model** is decided here; `Models::arg` picks the window and
/// always picks 1M. The on-disk transcript wins — Claude Code records the
/// model of every assistant message, so a mid-session `/model` switch is
/// visible there. The pane's own spawn argument covers a session that never
/// answered.
///
/// *Which window*: `Session::long_context`, observed on the wire, is the
/// only exact answer (nothing on disk distinguishes `opus` from
/// `opus[1m]`); the spawn argument answers for a pane we started ourselves.
/// With neither — a session from before this process — Claude Code's
/// configured default is the one other place a `[1m]` choice is written
/// down, so when it names this same model we pass **no flag at all** and
/// let the child apply that default, window and all. Any explicit answer
/// overrides it, including "this session ran the *short* window".
/// answered, and Claude Code's configured default covers a session we know
/// nothing about: naming it explicitly is what lets us add the `[1m]`,
/// which passing no flag at all never could.
fn resume_arg(&self, key: &str, cc_default: Option<&str>) -> String {
let aliases: Vec<String> =
self.model_choices.iter().map(|(_, a)| a.clone()).collect();
let sess = self.sessions.iter().find(|s| s.key == key);
let spawned = sess
let spawned = self
.sessions
.iter()
.find(|s| s.key == key)
.and_then(|s| s.spawn_model.clone())
.filter(|m| !m.is_empty());
let Some(base) = self
let base = self
.disk_sessions
.iter()
.find(|d| d.uuid == key)
.and_then(|d| model_arg_for_id(&d.model, &aliases))
.and_then(|d| self.models.base_for_id(&d.model))
.or_else(|| spawned.as_deref().map(|s| base_model(s).to_string()))
else {
return String::new();
};
let long = sess.and_then(|s| s.long_context).or_else(|| {
spawned
.as_deref()
.filter(|s| base_model(s) == base)
.map(|s| s.ends_with(LONG_SUFFIX))
});
match long {
Some(true) => {
let long_arg = format!("{base}{LONG_SUFFIX}");
// Only offer the suffix for a model that really has one.
if aliases.contains(&long_arg) { long_arg } else { base }
}
Some(false) => base,
None if cc_default.is_some_and(|d| base_model(d) == base) => String::new(),
None => base,
.or_else(|| cc_default.map(|d| base_model(d).to_string()));
base.map(|b| self.models.arg(&b)).unwrap_or_default()
}
/// The `--model` argument a *fresh* pane spawns with. `pick` is the picker
/// row's argument, already at the 1M window (`Models::choices`); the empty
/// `default` row resolves to Claude Code's own configured model so the
/// suffix has something to attach to. Empty only when nothing configures a
/// default — then `claude` picks the model, and the window with it.
pub fn spawn_arg(&self, pick: &str) -> String {
if !pick.is_empty() {
return self.models.arg(pick);
}
crate::term::cc_default_model()
.map(|d| self.models.arg(&d))
.unwrap_or_default()
}
/// Remember the `--model` argument the pane for `key` was spawned with
@@ -851,9 +879,7 @@ pub const MAIN_LANE: LaneId = 0;
/// request used to look exactly like a turn start (a non-empty `tools` array),
/// which pushed its `Perform a web search for the query: …` message into the
/// main feed as a real prompt, clobbered the lane's system/tools signatures,
/// and — worst — ran `record_long_context` with a header set that never carries
/// the `context-1m` beta, silently downgrading a session that really ran
/// `opus[1m]` to the short window on the next resume.
/// and pushed a fake user prompt into the main chain.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum ReqKind {
/// A real turn: at least one *client* tool definition is on offer.
@@ -1156,17 +1182,9 @@ pub struct Session {
/// The `--model` argument the embedded pane for this session was spawned
/// with (`None` for external sessions, `Some("")` for the CLI default).
/// In-process memory only; it travels with the row when the tap renames
/// the key. `resume_model` uses it to keep a `[1m]` pick across a resume.
/// the key. `resume_model` reads it for the *model* of a session that has
/// no transcript yet — never for the window, which is always 1M.
pub spawn_model: Option<String>,
/// Whether the main chain runs with the **1M-context window**, observed on
/// the wire (`anthropic-beta: …context-1m…`, what a `<alias>[1m]` model
/// sends). `None` until a main-chain turn request passed through us.
///
/// The window is a beta header, not a model id: the body model and the
/// transcript record read exactly the same for `opus` and `opus[1m]`, so
/// this observation is the only fact that lets a resume keep — or
/// deliberately drop — the long window (`resume_model`).
pub long_context: Option<bool>,
}
impl Default for Session {
@@ -1188,7 +1206,6 @@ impl Session {
last_activity: Instant::now(),
tool_ids: HashMap::new(),
spawn_model: None,
long_context: None,
}
}
@@ -1991,16 +2008,6 @@ pub(crate) fn strip_injected(t: &str) -> String {
.to_string()
}
/// Record the main chain's context window for `key`, learned from a request's
/// `anthropic-beta` header (see `proxy::LONG_CONTEXT_BETA`). Called per
/// main-chain turn request, so a mid-session window switch is picked up.
pub fn record_long_context(app: &SharedApp, key: &str, long: bool) {
let mut a = lock_app(app);
if let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) {
s.long_context = Some(long);
}
}
/// Display name of a hosted tool: its `name` (`web_search`) when the request
/// gives one, else its versioned `type` with the trailing `_<date>` trimmed
/// (`web_search_20250305` → `web_search`), so the label never carries a version
@@ -2914,10 +2921,6 @@ mod tests {
use serde_json::json;
use std::time::Duration;
fn aliases() -> Vec<String> {
default_model_choices().into_iter().map(|(_, a)| a).collect()
}
/// A disk stub with a recorded model, as the scanner produces it.
fn stub(uuid: &str, model: &str) -> crate::sessions::DiskSession {
crate::sessions::DiskSession {
@@ -2931,84 +2934,83 @@ mod tests {
#[test]
fn model_id_maps_to_its_alias() {
let al = aliases();
let arg = |id: &str| model_arg_for_id(id, &al);
assert_eq!(arg("claude-sonnet-4-5-20250929").as_deref(), Some("sonnet"));
assert_eq!(arg("claude-opus-4-5-20251101").as_deref(), Some("opus"));
assert_eq!(arg("claude-opus-5").as_deref(), Some("opus"));
assert_eq!(arg("claude-3-5-haiku-20241022").as_deref(), Some("haiku"));
let m = Models::seed();
let base = |id: &str| m.base_for_id(id);
assert_eq!(base("claude-sonnet-4-5-20250929").as_deref(), Some("sonnet"));
assert_eq!(base("claude-opus-4-5-20251101").as_deref(), Some("opus"));
assert_eq!(base("claude-opus-5").as_deref(), Some("opus"));
assert_eq!(base("claude-3-5-haiku-20241022").as_deref(), Some("haiku"));
// No known alias inside → the full id (accepted by `--model` too).
assert_eq!(arg("claude-mythos-1-20260101").as_deref(), Some("claude-mythos-1-20260101"));
assert_eq!(
base("claude-mythos-1-20260101").as_deref(),
Some("claude-mythos-1-20260101")
);
// Placeholders yield nothing: no `--model` flag at all.
assert_eq!(arg("<synthetic>"), None);
assert_eq!(arg("(resumed)"), None);
assert_eq!(arg(""), None);
assert_eq!(base("<synthetic>"), None);
assert_eq!(base("(resumed)"), None);
assert_eq!(base(""), None);
}
#[test]
fn every_model_argument_carries_the_long_window() {
let m = Models::seed();
assert_eq!(m.arg("opus"), "opus[1m]");
// Already suffixed → unchanged, so an argument can round-trip.
assert_eq!(m.arg("opus[1m]"), "opus[1m]");
// A model with no `[1m]` variant never grows one.
assert_eq!(m.arg("haiku"), "haiku");
// Nor does a full id the binary never listed a variant for.
assert_eq!(m.arg("claude-mythos-1-20260101"), "claude-mythos-1-20260101");
// Empty stays empty: that is "pass no `--model` flag".
assert_eq!(m.arg(""), "");
}
#[test]
fn resume_model_prefers_the_transcript() {
let mut a = App::new();
a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")];
assert_eq!(a.resume_arg("s1", None), "opus");
// Unknown session → no flag.
assert_eq!(a.resume_arg("s1", None), "opus[1m]");
// Unknown session, and no configured default to borrow a model from →
// no flag; claude picks for itself.
assert_eq!(a.resume_arg("nope", None), "");
}
#[test]
fn resume_model_keeps_a_1m_pick_until_the_model_changes() {
fn resume_model_falls_back_to_the_spawn_argument() {
let mut a = App::new();
a.sessions.push(Session::new("s1".into(), "sonnet".into()));
a.set_spawn_model("s1", "sonnet[1m]");
// Transcript agrees on the model → the long-context spelling survives
// (the transcript records the base id for both).
// Transcript agrees on the model.
a.disk_sessions = vec![stub("s1", "claude-sonnet-4-5-20250929")];
assert_eq!(a.resume_arg("s1", None), "sonnet[1m]");
// A mid-session `/model` switch shows up on disk and wins — and takes
// the stale `[1m]` memory with it.
// A mid-session `/model` switch shows up on disk and wins.
a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")];
assert_eq!(a.resume_arg("s1", None), "opus");
// No transcript model yet (spawned, never answered) → the spawn arg.
assert_eq!(a.resume_arg("s1", None), "opus[1m]");
// No transcript model yet (spawned, never answered) → the spawn arg,
// whose window is re-derived rather than remembered.
a.disk_sessions.clear();
a.set_spawn_model("s1", "sonnet");
assert_eq!(a.resume_arg("s1", None), "sonnet[1m]");
// The CLI default is remembered as "no flag", not as a model.
a.set_spawn_model("s1", "");
}
#[test]
fn a_session_we_know_nothing_about_still_gets_the_long_window() {
// Nothing on disk, nothing spawned: name Claude Code's own default
// model explicitly, because only a named model can carry `[1m]`.
let a = App::new();
assert_eq!(a.resume_arg("s1", Some("opus")), "opus[1m]");
assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "opus[1m]");
assert_eq!(a.resume_arg("s1", Some("haiku")), "haiku");
assert_eq!(a.resume_arg("s1", None), "");
}
#[test]
fn resume_model_follows_the_observed_context_window() {
let mut a = App::new();
a.sessions.push(Session::new("s1".into(), "opus".into()));
a.disk_sessions = vec![stub("s1", "claude-opus-5")];
// Wire says 1M → resume on the long-context spelling.
a.sessions[0].long_context = Some(true);
assert_eq!(a.resume_arg("s1", None), "opus[1m]");
// Wire wins over a stale spawn argument, both ways round.
a.set_spawn_model("s1", "opus");
assert_eq!(a.resume_arg("s1", None), "opus[1m]");
a.sessions[0].long_context = Some(false);
a.set_spawn_model("s1", "opus[1m]");
assert_eq!(a.resume_arg("s1", None), "opus");
// …and an explicit "short window" beats a 1M default: we know better.
assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "opus");
// A model with no `[1m]` variant never grows one.
a.disk_sessions = vec![stub("s1", "claude-haiku-4-5-20251001")];
a.sessions[0].long_context = Some(true);
a.sessions[0].spawn_model = None;
assert_eq!(a.resume_arg("s1", None), "haiku");
}
#[test]
fn unobserved_session_inherits_claude_codes_own_default() {
// A session from before this process: nothing knows its window, so the
// same-model default is inherited whole (no `--model` flag) rather than
// pinned to the short window.
let mut a = App::new();
a.disk_sessions = vec![stub("s1", "claude-opus-5")];
assert_eq!(a.resume_arg("s1", Some("opus[1m]")), "");
assert_eq!(a.resume_arg("s1", Some("opus")), "");
// A default for a *different* model can't speak for this session.
assert_eq!(a.resume_arg("s1", Some("sonnet[1m]")), "opus");
fn spawn_argument_resolves_the_default_picker_row() {
let a = App::new();
// An explicit pick is honoured, at the long window.
assert_eq!(a.spawn_arg("sonnet"), "sonnet[1m]");
assert_eq!(a.spawn_arg("sonnet[1m]"), "sonnet[1m]");
assert_eq!(a.spawn_arg("haiku"), "haiku");
}
/// Wire fixture: a subagent's request, identified by Claude Code's own
@@ -3346,8 +3348,8 @@ mod tests {
/// Anthropic's *server-side* web_search tool under the parent's own
/// session id and with no agent-id header. It gets its own lane (readable
/// in the `A` popup, invisible to the main feed) and must not touch a
/// single piece of main-chain state — above all not the observed 1M
/// context window, which it would otherwise clear.
/// single piece of main-chain state — neither the feed nor the lane's
/// system/tools signatures.
#[test]
fn server_tool_request_gets_its_own_lane_and_never_touches_the_main_chain() {
let app: SharedApp = Arc::new(Mutex::new(App::new()));
@@ -3368,11 +3370,10 @@ mod tests {
{"name": "WebSearch", "input_schema": {}},
], "messages": [{"role": "user", "content": "search the web for me"}]}),
);
record_long_context(&app, "s", true);
// The nested hosted-tool request. `classify_request` is what gates
// `record_long_context` / the system+tools lines in the proxy, so the
// classification *is* the fix; the rest of the pipeline runs verbatim.
// The nested hosted-tool request. `classify_request` is what gates the
// system+tools lines in the proxy, so the classification *is* the fix;
// the rest of the pipeline runs verbatim.
let body = json!({
"model": "claude-opus-5",
"stream": true,
@@ -3403,9 +3404,6 @@ mod tests {
let a = app.lock().unwrap();
let s = &a.sessions[0];
// The one that matters: a session that really ran `opus[1m]` must still
// resume with the long window after a web search.
assert_eq!(s.long_context, Some(true), "the observed 1M window survives");
// Main-chain signatures are untouched, so the next real turn does not
// re-emit its system prompt / tool list.
assert_eq!(s.main().last_system_len, Some(10));

View File

@@ -1,7 +1,6 @@
use crate::app::{
AgentTag, ReqKind, SharedApp, Tap, attach_tool_results, classify_request,
label_server_tool_lane, lock_app, next_server_tool_id, record_long_context,
record_user_prompt,
label_server_tool_lane, lock_app, next_server_tool_id, record_user_prompt,
};
use crate::sse::SseParser;
use axum::Router;
@@ -24,14 +23,6 @@ const UPSTREAM: &str = "https://api.anthropic.com";
pub const AGENT_ID_HEADER: &str = "x-claude-code-agent-id";
pub const PARENT_AGENT_ID_HEADER: &str = "x-claude-code-parent-agent-id";
/// Claude Code asks for the **1M-context window** with a beta flag, not a
/// different model: `--model opus[1m]` sends `anthropic-beta: …,context-1m-…`
/// while plain `opus` does not, and both report the same `model` in the body
/// (and in the transcript). So this header is the only place the window is
/// observable — `App::resume_model` needs it to resume a session the way it ran.
pub const BETA_HEADER: &str = "anthropic-beta";
pub const LONG_CONTEXT_BETA: &str = "context-1m";
/// Most of a failed response we keep in order to name the error. An Anthropic
/// error body is a few hundred bytes; the cap exists so a pathological upstream
/// can't make the tee task grow without bound (the relay itself is unaffected
@@ -206,16 +197,6 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
label_server_tool_lane(&ctx.app, &key, t.lane(), &v);
}
record_user_prompt(&ctx.app, &key, t.lane(), &v);
// Context window of the *main chain*, from this request's betas. Only a
// real turn-starting main-chain request counts: a side/title call runs
// haiku without the flag, a subagent runs its own model, and a nested
// server-tool call never carries the flag at all — reading any of them
// would report a window that is not the session's (the server-tool case
// silently downgraded a `[1m]` session to the short window on resume).
if kind == ReqKind::Turn && t.lane() == crate::app::MAIN_LANE {
let long = header(BETA_HEADER).is_some_and(|b| b.contains(LONG_CONTEXT_BETA));
record_long_context(&ctx.app, &key, long);
}
tap = Some(t);
}

View File

@@ -969,10 +969,10 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
}
}
/// Claude Code's own default model, as a `--model` argument (`opus`,
/// `opus[1m]`, …). Its settings files are the one place the **1M-context**
/// choice is written down — `/model` saves the pick there, suffix and all,
/// while a transcript records the same base model id either way.
/// Claude Code's own default model, as it writes it (`opus`, `opus[1m]`, …).
/// Only the *model* is used: `App::spawn_arg` / `App::resume_arg` strip any
/// suffix and re-apply the 1M window themselves, so a `default` pick or a
/// session we know nothing about still names a model we can attach `[1m]` to.
///
/// Resolved the way Claude Code layers it: `ANTHROPIC_MODEL`, then
/// project-local, project, and user settings. `None` when nothing sets one
@@ -1006,21 +1006,21 @@ fn settings_model(path: &std::path::Path) -> Option<String> {
.map(str::to_string)
}
/// Background scan that replaces `App::model_choices` with the live alias set
/// read from the installed `claude` binary (see `discover_model_aliases`).
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
/// Background scan that replaces `App::models` with the live sets read from the
/// installed `claude` binary (see `discover_models`). Runs off the UI thread;
/// on failure the seeded fallback catalog stays in place.
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
std::thread::spawn(move || {
if let Some(choices) = discover_model_choices() {
crate::app::lock_app(&app).model_choices = choices;
if let Some(models) = discover_models() {
crate::app::lock_app(&app).models = models;
}
});
}
/// Best-effort discovery of the models the installed `claude` accepts (aliases
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
/// long-context variants), so the `a` picker tracks new models without us
/// hardcoding a list that drifts.
/// like `opus`, `sonnet`, `haiku`, `fable`, and which of them ship a
/// `<alias>[1m]` long-context variant), so the `a` picker and the always-1M
/// policy track new models without us hardcoding a list that drifts.
///
/// Claude Code ships as one self-contained executable with its (minified) JS
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
@@ -1028,28 +1028,25 @@ pub fn spawn_model_discovery(app: crate::app::SharedApp) {
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
/// read it once. This issues **no API request** (the project's core constraint)
/// and never executes claude. Returns None if the binary can't be found/read or
/// nothing matches — the caller keeps its built-in fallback list.
fn discover_model_choices() -> Option<Vec<(String, String)>> {
/// nothing matches — the caller keeps its built-in fallback catalog.
fn discover_models() -> Option<crate::app::Models> {
let bytes = std::fs::read(claude_binary_path()?).ok()?;
let aliases = longest_alias_array(&bytes)?;
Some(model_choices_from(&bytes, &aliases))
Some(models_from(&bytes, aliases))
}
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
/// the binary actually ships (see `long_context_tokens`).
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
let long = long_context_tokens(bytes);
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
choices.extend(
aliases
.iter()
.map(|a| format!("{a}[1m]"))
.filter(|v| long.contains(v))
.map(|v| (format!("{v} (1M context)"), v)),
);
choices
/// Build the catalog: every alias, plus every model the binary really ships a
/// `[1m]` variant for — never assumed. `long` keeps the *base* name of each
/// `"<name>[1m]"` literal (see `long_context_tokens`), so it covers full model
/// ids as well as aliases: a session whose transcript names a model with no
/// alias still resumes at the long window.
fn models_from(bytes: &[u8], aliases: Vec<String>) -> crate::app::Models {
let mut long: Vec<String> = long_context_tokens(bytes)
.iter()
.map(|t| crate::app::base_model(t).to_string())
.collect();
long.sort();
crate::app::Models { aliases, long }
}
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
@@ -1181,14 +1178,20 @@ mod tests {
}
#[test]
fn appends_1m_choices_for_aliases_that_have_them() {
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
fn catalog_marks_only_aliases_with_a_real_1m_variant() {
let bytes =
br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]" "claude-mythos-1[1m]""#;
let aliases = longest_alias_array(bytes).unwrap();
let got = model_choices_from(bytes, &aliases);
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
// default (no flag), the plain aliases, then only the real 1M variants.
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
let models = models_from(bytes, aliases);
assert_eq!(models.aliases, ["sonnet", "opus", "haiku"]);
// Full ids count too: a transcript can name a model no alias covers.
assert_eq!(models.long, ["claude-mythos-1", "opus", "sonnet"]);
assert_eq!(models.arg("claude-mythos-1"), "claude-mythos-1[1m]");
assert_eq!(models.arg("haiku"), "haiku");
// Picker rows: every model at the window `Models::arg` gives it —
// 1M where the binary ships one, plain where it does not.
let args: Vec<String> = models.choices().into_iter().map(|c| c.1).collect();
assert_eq!(args, ["", "sonnet[1m]", "opus[1m]", "haiku"]);
}
/// Build a `rows` fixture (visible text per screen row) from string slices.

View File

@@ -690,9 +690,11 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
kill_current_embed(eui, app);
}
if eui.term.is_none() {
// Real dimensions are applied on the first draw via resize().
match EmbeddedTerm::spawn(eui.port, 20, 80, "") {
Ok(t) => bind_fresh_pane(eui, app, t, ""),
// Real dimensions are applied on the first draw via resize(): the
// model is Claude Code's own default, at the 1M window (`spawn_arg`).
let model = app.lock().unwrap().spawn_arg("");
match EmbeddedTerm::spawn(eui.port, 20, 80, &model) {
Ok(t) => bind_fresh_pane(eui, app, t, &model),
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
@@ -714,10 +716,14 @@ fn show_embed_pane(eui: &mut EmbedUi, app: &SharedApp) {
/// (optionally `--model <model>`), killing any current pane first. Unlike
/// `show_embed_pane` this never reuses an existing child — the point is to
/// start a brand-new session without resume + /clear.
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
///
/// `pick` is the picker row's argument; `App::spawn_arg` resolves the empty
/// `default` row and guarantees the 1M window either way.
fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, pick: &str) {
kill_current_embed(eui, app);
match EmbeddedTerm::spawn(eui.port, 20, 80, model) {
Ok(t) => bind_fresh_pane(eui, app, t, model),
let model = app.lock().unwrap().spawn_arg(pick);
match EmbeddedTerm::spawn(eui.port, 20, 80, &model) {
Ok(t) => bind_fresh_pane(eui, app, t, &model),
Err(e) => {
app.lock().unwrap().status = format!("claude spawn failed: {e}");
return;
@@ -732,7 +738,8 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
/// only expensive path, and it only runs from an explicit ctrl-↓ / `c`.
///
/// The resume carries the session's own model forward (`App::resume_model`,
/// read from its transcript) instead of falling back to the CLI default.
/// read from its transcript) instead of falling back to the CLI default, and
/// always at the 1M context window.
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
kill_current_embed(eui, app);
let model = app.lock().unwrap().resume_model(session_id);
@@ -1027,14 +1034,15 @@ fn event_loop(
// Model picker (opened with `a`): j/k move, Enter spawns a fresh
// session with the chosen model, esc/a/q cancels.
if let Some(msel) = a.model_popup {
let n = a.model_choices.len().max(1);
let choices = a.models.choices();
let n = choices.len().max(1);
match k.code {
KeyCode::Up | KeyCode::Char('k') => a.model_popup = Some((msel + n - 1) % n),
KeyCode::Down | KeyCode::Char('j') => a.model_popup = Some((msel + 1) % n),
KeyCode::Esc | KeyCode::Char('a') | KeyCode::Char('q') => a.model_popup = None,
KeyCode::Enter => {
a.model_popup = None;
let model = a.model_choices.get(msel).map(|c| c.1.clone());
let model = choices.get(msel).map(|c| c.1.clone());
drop(a);
if let Some(model) = model {
show_embed_new(eui, &app, &model);
@@ -1711,8 +1719,9 @@ fn draw(
// Model picker popup (n → choose a model → fresh session).
if let Some(msel) = a.model_popup {
let choices = a.models.choices();
let w = 30u16.min(main.width);
let h = (a.model_choices.len() as u16 + 2).min(main.height);
let h = (choices.len() as u16 + 2).min(main.height);
let area = Rect {
x: main.x + (main.width.saturating_sub(w)) / 2,
y: main.y + (main.height.saturating_sub(h)) / 2,
@@ -1720,8 +1729,7 @@ fn draw(
height: h,
};
f.render_widget(Clear, area);
let items: Vec<ListItem> = a
.model_choices
let items: Vec<ListItem> = choices
.iter()
.map(|(label, _)| ListItem::new(format!(" {label}")))
.collect();