diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 75b67390a1..cb23939976 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,6 +11,7 @@ ## Checklist +- [ ] This PR adds a new layer/module/abstraction — it names or deletes the layer it replaces - [ ] Updated docs or comments as needed - [ ] Added or updated tests where relevant - [ ] Verified TUI behavior manually if UI changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5af11297e9..a49d0ddb1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: ;; esac case "${path}" in - crates/workflow/*|workflows/rlm_cache_change.star|.github/workflows/ci.yml) + crates/workflow/*|.github/workflows/ci.yml) workflow=true ;; esac @@ -484,8 +484,8 @@ jobs: with: cache-bin: false save-if: ${{ github.ref == 'refs/heads/main' }} - - name: Run RLM cache workflow mock/replay tests - run: cargo test -p codewhale-workflow --locked rlm_cache_change + - name: Run workflow crate tests + run: cargo test -p codewhale-workflow --locked test: name: Test diff --git a/.gitignore b/.gitignore index 7fa418c260..89872540c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ # Build artifacts /target +/target-* +.entire/ +.cursor/ +.plans/ +.codewhale-worktrees/ +docs/superpowers/ +codewhale-inference/ /extensions/vscode/out/ *.pdb *.exe diff --git a/AGENTS.md b/AGENTS.md index fa9674e83a..059299ffcf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,10 @@ instructions or memory. The nearest scoped `AGENTS.md` adds path-specific rules. - Inspect status and existing consumers before editing. Preserve unrelated, dirty, and untracked work. +- Before adding a module named `model_*`, `*_config`, `provider_*`, or + anything that "bridges", "mirrors", or "stages" an existing thing, grep + for the existing thing and edit it. A new layer must name the predecessor + it replaces in the module doc; otherwise edit the original. - Prefer the simplest implementation that preserves observable contracts. A rewrite is acceptable when justified by product intent and observed behavior, not as a shortcut around understanding existing code. diff --git a/CHANGELOG.md b/CHANGELOG.md index 34072a5618..b5c38c17d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class + picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling + of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 + per 1M (durable; the 50% promo through 2026-09-09 is not the catalog row). +- Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates + (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` + variants. `/provider` fills URL, model, and env from one catalog row. +- MCP manager copy now names the server, the failure, and one recovery + command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). +- Settings Advanced exposes clickable MCP Connect, Reconnect, and Diagnose + actions, while Extensions and Problems route recovery through the existing + `/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands + (#5643, #5655). + - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the @@ -72,6 +86,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Idle session metrics omit zero facts (`0 turns`, `LLM 0s`) until the + runtime has evidence. Working chrome says `in the current` instead of a + generic `working`. +- Deleted nine uncompiled `runtime_contract/` staging files. Live contracts + remain `model.rs` and `termination.rs`. + +- The first #5587 dead-code sweep converts audited test-only helpers to + `#[cfg(test)]`, keeping production builds free of test-only APIs without + changing runtime behavior. - `/plugin reload` is now discoverable when on-disk plugin bundles change: the next send and `/plugin list` nudge once with `Run /plugin reload to apply` instead of silently keeping the stale catalog (#5579). Trust is unchanged; diff --git a/config.example.toml b/config.example.toml index 71fcc40a5f..736ea9bbf2 100644 --- a/config.example.toml +++ b/config.example.toml @@ -46,12 +46,14 @@ base_url = "https://api.deepseek.com/beta" # z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2 # z-ai/glm-5.3 — OpenRouter Z.AI GLM 5.3 (live on Z.ai since 2026-08-13; # metadata inherited from 5.2, unpriced) -# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (scout fast sibling) +# z-ai/glm-5.3-flash — OpenRouter Z.AI GLM 5.3 Flash (1M multimodal; $0.15/$0.50 list) +# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (scout fast sibling of 5.2) # GLM-5.3 — default direct Z.AI Coding Plan model (live since 2026-08-13; # metadata inherited from 5.2, unpriced) +# GLM-5.3-Flash — direct Z.AI GLM 5.3 Flash (faster/explore sibling of 5.3) # GLM-5.2 — direct Z.AI GLM 5.2 (previous default; explicit selections keep it) # GLM-5.1 — direct Z.AI smaller model -# GLM-5-Turbo — direct Z.AI fast model (scout fast sibling) +# GLM-5-Turbo — direct Z.AI fast model (scout fast sibling of 5.2) # step-3.7-flash — default direct StepFun / StepFlash model ID # kimi-k3 — direct Moonshot K3 model ID (1M context) # kimi-k2.7-code — default direct Moonshot/Kimi K2.7 model ID @@ -674,12 +676,13 @@ max_subagents = 10 # optional (default 64, clamped to 1-128) # base_url = "https://api.z.ai/api/coding/paas/v4" # # General API endpoint, if you are not using the Coding Plan: # # base_url = "https://api.z.ai/api/paas/v4" -# model = "GLM-5.3" # default; GLM-5.2 is the previous default, GLM-5.1 the smaller model, GLM-5-Turbo the fast sub-agent sibling +# model = "GLM-5.3" # default; GLM-5.3-Flash is the fast sibling, GLM-5.2 the previous default, GLM-5.1 the smaller model, GLM-5-Turbo the 5.2 fast sibling # # GLM-5.3 is live on the Z.ai Coding Plan (2026-08-13). Its catalog metadata # # (limits, reasoning options) is inherited from GLM-5.2 until Z.ai publishes -# # distinct 5.3 numbers, and it carries no price. An explicit model = "GLM-5.2" -# # keeps sending GLM-5.2; only the default moved. Accounts not provisioned for -# # 5.3 can still see a 429 with entitlement code 1311. +# # distinct 5.3 numbers, and it carries no price. GLM-5.3-Flash (2026-08-26) +# # is the 1M multimodal picker row (`model = "GLM-5.3-Flash"`). An explicit +# # model = "GLM-5.2" keeps sending GLM-5.2; only the default moved. Accounts +# # not provisioned for 5.3 can still see a 429 with entitlement code 1311. # StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.ai) [providers.stepfun] diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 0a48ee360b..19c4bdf272 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -516,6 +516,13 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "z-ai/glm-5.3-flash".to_string(), + provider: ProviderKind::Openrouter, + aliases: vec!["glm-5.3-flash".to_string(), "zai-glm-5.3-flash".to_string()], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "z-ai/glm-5-turbo".to_string(), provider: ProviderKind::Openrouter, @@ -535,6 +542,18 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "GLM-5.3-Flash".to_string(), + provider: ProviderKind::Zai, + aliases: vec![ + "glm-5.3-flash".to_string(), + "glm-5-3-flash".to_string(), + "zai-glm-5.3-flash".to_string(), + "zai-glm-5-3-flash".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, // The first Z.ai row is the provider default. Keep this ordering // aligned with `DEFAULT_ZAI_MODEL` in codewhale-config. ModelInfo { @@ -2321,6 +2340,10 @@ mod tests { ("glm-5.3", "GLM-5.3"), ("glm-5-3", "GLM-5.3"), ("zai-glm-5-3", "GLM-5.3"), + ("GLM-5.3-Flash", "GLM-5.3-Flash"), + ("glm-5.3-flash", "GLM-5.3-Flash"), + ("glm-5-3-flash", "GLM-5.3-Flash"), + ("zai-glm-5.3-flash", "GLM-5.3-Flash"), ("GLM-5-Turbo", "GLM-5-Turbo"), ("glm-5-turbo", "GLM-5-Turbo"), ("zai-glm-5-turbo", "GLM-5-Turbo"), @@ -2555,6 +2578,7 @@ mod tests { ("glm-5.1", "z-ai/glm-5.1"), ("glm-5.2", "z-ai/glm-5.2"), ("glm-5.3", "z-ai/glm-5.3"), + ("glm-5.3-flash", "z-ai/glm-5.3-flash"), ("minimax-m3", "minimax/minimax-m3"), ("minimax-2.7", "minimax/minimax-m2.7"), ("openrouter-mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro"), diff --git a/crates/config/assets/models_dev.bundled.json b/crates/config/assets/models_dev.bundled.json index 0dc907dd68..799eb6a2b7 100644 --- a/crates/config/assets/models_dev.bundled.json +++ b/crates/config/assets/models_dev.bundled.json @@ -9,8 +9,9 @@ "curated": "qwen3.8-max (GA) is curated ahead of upstream Models.dev, which as of 2026-08-03 lists only qwen3.8-max-preview; facts verified against the owner's Token Plan console (2026-08-03): ~1M context, 128K output, image understanding, always-on reasoning. deepseek-v4-flash-0731 keeps the console/in-repo wire id for the row upstream serves as deepseek-v4-flash. Coding Plan rows for qwen3.8-max-preview, deepseek-v4-pro, deepseek-v4-flash-0731, and glm-5.2 are curated from the Token Plan upstream entries (upstream alibaba-coding-plan does not list them yet); the in-repo route layer already offers the same model set on both plans. Upstream provider ids alibaba-token-plan(-cn) / alibaba-coding-plan(-cn) were merged onto the CodeWhale provider ids (live refresh normalizes them via ProviderKind aliases; the -cn regional variants stay upstream-id browse rows until Codewhale ships China endpoints).", "qwen_3_8_flash_2026_08_26": "OpenRouter qwen/qwen3.8-flash recorded 2026-08-26 against https://models.dev/api.json: release_date 2026-08-26, 1,000,000 context / 131,072 output, text+image+video input / text output, reasoning true, tool_call true, family qwen. Durable list prices (no promo annotation): input 0.16, output 0.47, cache_read 0.016, cache_write 0.20 per 1M. Alibaba first-party lists only qwen3.8-max (already curated, unpriced on Token Plan); this seed curates the OpenRouter row only and does not invent a first-party flash id. Live refresh supersedes on (provider, wire_model_id) identity. The flash suffix is not a GA family default, so DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL stays qwen3.8-max and this row is not default: true.", "pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.", + "glm_5_3_flash_2026_08_26": "GLM-5.3-Flash recorded 2026-08-26 against https://docs.z.ai/guides/overview/pricing: natively multimodal (text/image/video), 1,000,000 context / 131,072 output, reasoning + tools. First-party wire id GLM-5.3-Flash; OpenRouter mirror z-ai/glm-5.3-flash. Durable list prices (not the 50% promo ending 2026-09-09 UTC+8): input 0.15, output 0.50, cache_read 0.03 per 1M. Flash is a picker row and the faster/explore sibling of GLM-5.3; DEFAULT_ZAI_MODEL stays GLM-5.3.", "currency_sweep_2026_08_17": "Rows re-verified against official pages on 2026-08-17 (#5470 follow-up): gpt-5.6-terra 2.00/12.00 (cache read 0.20, write 2.50) and gpt-5.6-luna 0.20/1.20 (0.02/0.25) per developers.openai.com model pages; claude-sonnet-5 2.00/10.00 (0.20/2.50) is now Anthropic's standard price (the 2026-09-01 increase was cancelled) and claude-opus-5 5.00/25.00 (0.50/6.25) was added; kimi-k3 3.00/15.00 (0.30) and kimi-k2.7-code-highspeed 1.90/8.00 (0.38) per platform.kimi.ai; MiniMax-M2.7-highspeed 0.60/2.40 (0.06/0.375) per platform.minimax.io; grok-4.5 (500K) and grok-4.3 (1M) carry limits only because xAI doubles their rates past 200K (same rule as grok-4.6); OpenRouter dots-studio/dots-3-note-preview:free carries limits only (its single free endpoint publishes $0, which this seed does not restate as a price).", - "coverage": "20 providers, 90 model rows (offline seed only)." + "coverage": "20 providers, 92 model rows (offline seed only)." }, "models": { "deepseek-v4-pro": { @@ -104,6 +105,18 @@ "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 1000000, "output": 131072 } }, + "GLM-5.3-Flash": { + "id": "GLM-5.3-Flash", + "name": "GLM-5.3-Flash", + "family": "glm", + "attachment": true, + "reasoning": true, + "reasoning_options": [{ "type": "effort", "values": ["high", "max"] }], + "tool_call": true, + "modalities": { "input": ["text", "image", "video"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 131072 }, + "cost": { "input": 0.15, "output": 0.50, "cache_read": 0.03 } + }, "glm-5.1": { "id": "glm-5.1", "name": "GLM-5.1", @@ -1099,6 +1112,17 @@ "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 1000000, "output": 131072 } }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash (OpenRouter)", + "family": "glm", + "attachment": true, + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text", "image", "video"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 131072 }, + "cost": { "input": 0.15, "output": 0.50, "cache_read": 0.03 } + }, "dots-studio/dots-3-note-preview:free": { "id": "dots-studio/dots-3-note-preview:free", "name": "Dots Studio Dots3-Note Preview (OpenRouter, free endpoint)", diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 9d2246203e..fbf0eafc00 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -32,6 +32,7 @@ //! [`ProviderCatalogCache`] tests). use std::collections::BTreeMap; +use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; @@ -186,16 +187,30 @@ impl CatalogOffering { /// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero). pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json"); +/// Parse-once cache for the committed bundled Models.dev snapshot. +/// +/// The bundled asset is compile-time constant (`include_str!`), so its parsed +/// form is immutable and safe to share process-wide. Before this cache, every +/// call site parsed the full snapshot independently — the client route path, +/// pickers, provider lake, and fleet identity each paid a full serde parse of +/// ~50KB on their own first use (perf-attributed during the 0.9.x perf +/// gauntlet: `ModelsDevCost` serde frames in startup profiles). +static BUNDLED_MODELS_DEV_CATALOG: OnceLock = OnceLock::new(); + /// Parse the committed bundled Models.dev snapshot. /// +/// The first call parses; later calls return the shared parsed catalog. +/// /// # Panics /// Panics only if the committed asset is not valid Models.dev JSON. The /// `tests::bundled_asset_parses` guard makes that a build-time failure, so this /// never panics in shipped builds. #[must_use] -pub fn bundled_models_dev_catalog() -> ModelsDevCatalog { - ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) - .expect("committed bundled Models.dev asset must be valid JSON") +pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog { + BUNDLED_MODELS_DEV_CATALOG.get_or_init(|| { + ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON) + .expect("committed bundled Models.dev asset must be valid JSON") + }) } /// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188). @@ -205,7 +220,7 @@ pub fn bundled_models_dev_catalog() -> ModelsDevCatalog { /// rows override these on `(provider, wire_model_id)` when available. #[must_use] pub fn bundled_catalog_offerings() -> Vec { - bundled_offerings_from_models_dev(&bundled_models_dev_catalog()) + bundled_offerings_from_models_dev(bundled_models_dev_catalog()) } /// Hydrate bundled [`CatalogOffering`] rows from a parsed Models.dev catalog. diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 7574504da6..23cdb16106 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -648,7 +648,7 @@ fn bundled_asset_parses() { "bundled asset must carry provider rows" ); // The helper returns the same parsed catalog. - assert_eq!(bundled_models_dev_catalog(), catalog); + assert_eq!(*bundled_models_dev_catalog(), catalog); } #[test] @@ -851,9 +851,11 @@ fn bundled_asset_pricing_is_honest() { // GLM-5.3 is live on the Coding Plan, but Z.ai has published no USD PAYG // rate for it. Coding Plan credit multipliers are not USD, so every - // glm-5.3 row stays unpriced rather than inheriting glm-5.2's rates. + // glm-5.3 row *except Flash* stays unpriced rather than inheriting + // glm-5.2's rates. GLM-5.3-Flash has a published list (2026-08-26). for row in &rows { - if row.wire_model_id.to_ascii_lowercase().contains("glm-5.3") { + let wire = row.wire_model_id.to_ascii_lowercase(); + if wire.contains("glm-5.3") && !wire.contains("flash") { assert!( row.cost.is_none(), "{}/{}: glm-5.3 must stay unpriced until Z.ai publishes rates", @@ -863,6 +865,23 @@ fn bundled_asset_pricing_is_honest() { } } + let glm53_flash = find(&rows, "zai", "GLM-5.3-Flash"); + let cost = glm53_flash + .cost + .as_ref() + .expect("GLM-5.3-Flash must ship priced at durable list rates"); + assert_eq!(cost.input, Some(0.15)); + assert_eq!(cost.output, Some(0.50)); + assert_eq!(cost.cache_read, Some(0.03)); + assert_eq!( + glm53_flash.limit.as_ref().and_then(|l| l.context), + Some(1_000_000) + ); + assert!( + !glm53_flash.default_for_provider, + "GLM-5.3-Flash is a picker row, not the Z.ai default" + ); + // OpenRouter qwen3.8-flash lists durable (non-promo) rates on models.dev // as of 2026-08-26. Unlike GLM-5.3-Flash's explicit 50% promo, this row // must ship priced. It is not a family default. diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4ffd897539..5916cbd57b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -30,9 +30,13 @@ pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase}; pub(crate) use provider_defaults::*; pub use provider_kind::ProviderKind; pub use provider_templates::{ - AGNES_TEMPLATE_ID, ProviderSetupApply, ProviderSetupTemplate, SENSENOVA_API_KEY_ENV, - SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, SENSENOVA_MODELS, SENSENOVA_TEMPLATE_ID, - compatible_provider_setup_templates, provider_setup_template, provider_setup_templates, + AGNES_TEMPLATE_ID, BASETEN_API_KEY_ENV, BASETEN_BASE_URL, BASETEN_DEFAULT_MODEL, + BASETEN_MODELS, BASETEN_TEMPLATE_ID, CEREBRAS_API_KEY_ENV, CEREBRAS_BASE_URL, + CEREBRAS_DEFAULT_MODEL, CEREBRAS_MODELS, CEREBRAS_TEMPLATE_ID, GROQ_API_KEY_ENV, GROQ_BASE_URL, + GROQ_DEFAULT_MODEL, GROQ_MODELS, GROQ_TEMPLATE_ID, ProviderSetupApply, ProviderSetupTemplate, + SENSENOVA_API_KEY_ENV, SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, SENSENOVA_MODELS, + SENSENOVA_TEMPLATE_ID, compatible_provider_setup_templates, provider_setup_template, + provider_setup_templates, }; pub use setup_state::{ ConstitutionAuthoring, ConstitutionChoice, ConstitutionSource, ConstitutionValidity, @@ -4360,6 +4364,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> { // moving the default (now GLM-5.3) must not silently re-point an // explicit GLM-5.2 route. "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL), + "glm-5.3-flash" | "glm-5-3-flash" | "zai-glm-5.3-flash" | "zai-glm-5-3-flash" => { + Some(ZAI_GLM_5_3_FLASH_MODEL) + } "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL), "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL), _ => None, @@ -4387,6 +4394,11 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => { Some(OPENROUTER_GLM_5_2_MODEL) } + OPENROUTER_GLM_5_3_FLASH_MODEL + | "glm-5.3-flash" + | "glm-5-3-flash" + | "zai-glm-5.3-flash" + | "zai-glm-5-3-flash" => Some(OPENROUTER_GLM_5_3_FLASH_MODEL), OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => { Some(OPENROUTER_GLM_5_3_MODEL) } diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 0349d5fa69..6e8c065c9f 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -49,6 +49,10 @@ pub(crate) const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2"; // resolves to OpenRouter rather than another vendor. See // models_dev.bundled.json `_meta.pending_release_metadata`. pub(crate) const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3"; +// GLM-5.3-Flash (2026-08-26): first natively multimodal GLM-5, 1M context, +// published USD list $0.15/$0.50 (50% promo until 2026-09-09 UTC+8 is not +// the durable row). OpenRouter mirror is z-ai/glm-5.3-flash. +pub(crate) const OPENROUTER_GLM_5_3_FLASH_MODEL: &str = "z-ai/glm-5.3-flash"; pub(crate) const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code"; pub(crate) const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6"; pub(crate) const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3"; @@ -128,6 +132,7 @@ pub(crate) const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com/v1"; // own id: only the default moved. pub(crate) const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL; pub(crate) const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3"; +pub(crate) const ZAI_GLM_5_3_FLASH_MODEL: &str = "GLM-5.3-Flash"; pub(crate) const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2"; pub(crate) const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1"; pub(crate) const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo"; diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 0fd97e47e5..430dc94a93 100644 --- a/crates/config/src/provider_templates.rs +++ b/crates/config/src/provider_templates.rs @@ -6,7 +6,10 @@ //! - first-class gateways users still treat as "paste a Base URL" //! (OpenCode Zen / Go), and //! - named OpenAI-compatible custom routes that are not `ProviderKind` -//! variants (SenseNova). +//! variants (SenseNova, Baseten, Groq, Cerebras). Hosted Chat Completions +//! backends are data rows here — not new enum variants. Distinct *wires* +//! (Anthropic Messages, Codex Responses, Google thought signatures) stay +//! on `ProviderKind`. //! //! Values here are limited to hosts, models, and env names already //! documented in this repository. Agnes is catalogued as unpublished so @@ -31,6 +34,34 @@ pub const SENSENOVA_MODELS: &[&str] = &[SENSENOVA_DEFAULT_MODEL]; /// host in this repository. pub const AGNES_TEMPLATE_ID: &str = "agnes"; +/// Baseten Model APIs — OpenAI Chat Completions, discovered at `/v1/models`. +pub const BASETEN_TEMPLATE_ID: &str = "baseten"; +pub const BASETEN_BASE_URL: &str = "https://inference.baseten.co/v1"; +pub const BASETEN_DEFAULT_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; +pub const BASETEN_API_KEY_ENV: &str = "BASETEN_API_KEY"; +pub const BASETEN_MODELS: &[&str] = &[ + BASETEN_DEFAULT_MODEL, + "deepseek-ai/DeepSeek-V4-Flash-0731", + "deepseek-ai/DeepSeek-V4-Pro-0813", + "zai-org/GLM-5.2", + "zai-org/GLM-5.3-Flash", + "moonshotai/Kimi-K2.7-Code", +]; + +/// Groq — OpenAI Chat Completions hosted inference. +pub const GROQ_TEMPLATE_ID: &str = "groq"; +pub const GROQ_BASE_URL: &str = "https://api.groq.com/openai/v1"; +pub const GROQ_DEFAULT_MODEL: &str = "llama-3.3-70b-versatile"; +pub const GROQ_API_KEY_ENV: &str = "GROQ_API_KEY"; +pub const GROQ_MODELS: &[&str] = &[GROQ_DEFAULT_MODEL, "openai/gpt-oss-120b"]; + +/// Cerebras — OpenAI Chat Completions hosted inference. +pub const CEREBRAS_TEMPLATE_ID: &str = "cerebras"; +pub const CEREBRAS_BASE_URL: &str = "https://api.cerebras.ai/v1"; +pub const CEREBRAS_DEFAULT_MODEL: &str = "llama-3.3-70b"; +pub const CEREBRAS_API_KEY_ENV: &str = "CEREBRAS_API_KEY"; +pub const CEREBRAS_MODELS: &[&str] = &[CEREBRAS_DEFAULT_MODEL]; + /// How a beginner template is applied. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProviderSetupApply { @@ -223,6 +254,42 @@ const TEMPLATES: &[ProviderSetupTemplate] = &[ credential_url: None, guidance: "OpenAI-compatible SenseTime SenseNova host. Store an env var name, not a raw key.", }, + ProviderSetupTemplate { + id: BASETEN_TEMPLATE_ID, + display_name: "Baseten", + apply: ProviderSetupApply::Compatible, + base_url: Some(BASETEN_BASE_URL), + default_model: Some(BASETEN_DEFAULT_MODEL), + models: BASETEN_MODELS, + api_key_env: Some(BASETEN_API_KEY_ENV), + docs_url: Some("https://docs.baseten.co/inference/model-apis/overview"), + credential_url: Some("https://app.baseten.co/settings/api_keys"), + guidance: "Baseten Model APIs. OpenAI Chat Completions at inference.baseten.co. Store BASETEN_API_KEY, not a raw key.", + }, + ProviderSetupTemplate { + id: GROQ_TEMPLATE_ID, + display_name: "Groq", + apply: ProviderSetupApply::Compatible, + base_url: Some(GROQ_BASE_URL), + default_model: Some(GROQ_DEFAULT_MODEL), + models: GROQ_MODELS, + api_key_env: Some(GROQ_API_KEY_ENV), + docs_url: Some("https://console.groq.com/docs/quickstart"), + credential_url: Some("https://console.groq.com/keys"), + guidance: "Groq hosted inference. OpenAI Chat Completions. Store GROQ_API_KEY, not a raw key.", + }, + ProviderSetupTemplate { + id: CEREBRAS_TEMPLATE_ID, + display_name: "Cerebras", + apply: ProviderSetupApply::Compatible, + base_url: Some(CEREBRAS_BASE_URL), + default_model: Some(CEREBRAS_DEFAULT_MODEL), + models: CEREBRAS_MODELS, + api_key_env: Some(CEREBRAS_API_KEY_ENV), + docs_url: Some("https://inference-docs.cerebras.ai/quickstart"), + credential_url: Some("https://cloud.cerebras.ai"), + guidance: "Cerebras hosted inference. OpenAI Chat Completions. Store CEREBRAS_API_KEY, not a raw key.", + }, ProviderSetupTemplate { id: AGNES_TEMPLATE_ID, display_name: "Agnes", @@ -266,6 +333,7 @@ pub fn provider_setup_template(id: &str) -> Option<&'static ProviderSetupTemplat "sense-nova" | "meituan-sensenova" | "meituan-sensenova-cn" => { template.id == SENSENOVA_TEMPLATE_ID } + "base-ten" | "base_ten" => template.id == BASETEN_TEMPLATE_ID, _ => false, }) }) @@ -387,7 +455,7 @@ mod tests { fn settings_value_names_fillable_then_unpublished() { assert_eq!( ProviderSetupTemplate::settings_value(), - "opencode-zen, opencode-go, sensenova; agnes unpublished" + "opencode-zen, opencode-go, sensenova, baseten, groq, cerebras; agnes unpublished" ); } @@ -402,4 +470,39 @@ mod tests { Some("opencode-zen") ); } + + #[test] + fn hosted_openai_compat_hosts_are_templates_not_enum_variants() { + for (alias, id, url, env) in [ + ( + "baseten", + BASETEN_TEMPLATE_ID, + BASETEN_BASE_URL, + BASETEN_API_KEY_ENV, + ), + ( + "base-ten", + BASETEN_TEMPLATE_ID, + BASETEN_BASE_URL, + BASETEN_API_KEY_ENV, + ), + ("groq", GROQ_TEMPLATE_ID, GROQ_BASE_URL, GROQ_API_KEY_ENV), + ( + "cerebras", + CEREBRAS_TEMPLATE_ID, + CEREBRAS_BASE_URL, + CEREBRAS_API_KEY_ENV, + ), + ] { + let template = provider_setup_template(alias).unwrap_or_else(|| panic!("{alias}")); + assert_eq!(template.id, id); + assert!(template.is_compatible(), "{alias}"); + assert_eq!(template.base_url(), Some(url)); + assert_eq!(template.api_key_env(), Some(env)); + assert!( + ProviderKind::parse(id).is_none(), + "{id} must not be a ProviderKind" + ); + } + } } diff --git a/crates/config/src/tests.rs b/crates/config/src/tests.rs index 71913c6701..700ae8a956 100644 --- a/crates/config/src/tests.rs +++ b/crates/config/src/tests.rs @@ -5339,6 +5339,23 @@ fn zai_aliases_resolve_to_canonical_models() { "{alias} must not resolve to the Z.ai default" ); } + for alias in [ + "glm-5.3-flash", + "glm-5-3-flash", + "zai-glm-5.3-flash", + "GLM-5.3-Flash", + ] { + assert_eq!( + normalize_model_for_provider(ProviderKind::Zai, alias), + ZAI_GLM_5_3_FLASH_MODEL, + "{alias} must canonicalize to GLM-5.3-Flash" + ); + assert_ne!( + normalize_model_for_provider(ProviderKind::Zai, alias), + ZAI_GLM_5_3_MODEL, + "{alias} must not collapse onto GLM-5.3" + ); + } assert_eq!( normalize_model_for_provider(ProviderKind::Zai, "glm-5-turbo"), ZAI_GLM_5_TURBO_MODEL @@ -7107,6 +7124,7 @@ fn openrouter_provider_normalizes_recent_large_model_aliases() { ("glm-5.1", OPENROUTER_GLM_5_1_MODEL), ("glm-5.2", OPENROUTER_GLM_5_2_MODEL), ("glm-5.3", OPENROUTER_GLM_5_3_MODEL), + ("glm-5.3-flash", OPENROUTER_GLM_5_3_FLASH_MODEL), ] { let cli = CliRuntimeOverrides { provider: Some(ProviderKind::Openrouter), diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index f7ec4934c2..131b300715 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class + picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling + of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 + per 1M (durable; the 50% promo through 2026-09-09 is not the catalog row). +- Baseten, Groq, and Cerebras are bundled OpenAI-compatible setup templates + (`[providers.] kind = "openai-compatible"`), not new `ProviderKind` + variants. `/provider` fills URL, model, and env from one catalog row. +- MCP manager copy now names the server, the failure, and one recovery + command (`The X MCP server requires OAuth reauthentication. Run /mcp login X`). +- Settings Advanced exposes clickable MCP Connect, Reconnect, and Diagnose + actions, while Extensions and Problems route recovery through the existing + `/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands + (#5643, #5655). + - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the @@ -72,6 +86,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Idle session metrics omit zero facts (`0 turns`, `LLM 0s`) until the + runtime has evidence. Working chrome says `in the current` instead of a + generic `working`. +- Deleted nine uncompiled `runtime_contract/` staging files. Live contracts + remain `model.rs` and `termination.rs`. + +- The first #5587 dead-code sweep converts audited test-only helpers to + `#[cfg(test)]`, keeping production builds free of test-only APIs without + changing runtime behavior. - `/plugin reload` is now discoverable when on-disk plugin bundles change: the next send and `/plugin list` nudge once with `Run /plugin reload to apply` instead of silently keeping the stale catalog (#5579). Trust is unchanged; diff --git a/crates/tui/assets/model_catalog.bundled.json b/crates/tui/assets/model_catalog.bundled.json index 818775e365..1da883fc43 100644 --- a/crates/tui/assets/model_catalog.bundled.json +++ b/crates/tui/assets/model_catalog.bundled.json @@ -170,6 +170,17 @@ "supported_parameters": [], "provenance": "bundled" }, + "z-ai/glm-5.3-flash": { + "id": "z-ai/glm-5.3-flash", + "context_window": 1000000, + "max_output": 131072, + "supports_reasoning": true, + "input_usd_per_million": 0.15, + "output_usd_per_million": 0.5, + "modalities": ["text", "image", "video"], + "supported_parameters": ["reasoning"], + "provenance": "bundled" + }, "glm-5.2": { "id": "glm-5.2", "context_window": 1000000, @@ -190,6 +201,17 @@ "supported_parameters": [], "provenance": "bundled" }, + "glm-5.3-flash": { + "id": "glm-5.3-flash", + "context_window": 1000000, + "max_output": 131072, + "supports_reasoning": true, + "input_usd_per_million": 0.15, + "output_usd_per_million": 0.5, + "modalities": ["text", "image", "video"], + "supported_parameters": ["reasoning"], + "provenance": "bundled" + }, "minimax/minimax-m3": { "id": "minimax/minimax-m3", "context_window": 1000000, diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 19f1f8a751..c7c151305e 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1205,11 +1205,11 @@ "LaunchNoSavedSessions": "No saved sessions for this workspace.", "PhaseIdle": "idle", "PhaseDraft": "draft", - "PhaseWorking": "working", + "PhaseWorking": "in the current", "PhaseReasoning": "reasoning", "PhaseReading": "reading", "PhaseUsingTool": "using tool", - "PhaseSubagents": "working on subagents", + "PhaseSubagents": "pod underway", "PhaseVerifying": "verifying", "PhaseWaitingOnYou": "waiting on you", "PhaseDone": "done", diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index c415991040..7796f0f842 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -5994,11 +5994,12 @@ mod alias_thinking_detection_tests { #[test] fn zai_tiered_effort_applies_to_glm_5_2_and_glm_5_3_but_not_5_1() { let zai = crate::config::DEFAULT_ZAI_BASE_URL; - // GLM-5.3 inherits GLM-5.2's reasoning_options (effort high/max), so it - // must take the same tiered wire path — not the generic toggle. + // GLM-5.3 and GLM-5.3-Flash inherit GLM-5.2's reasoning_options + // (effort high/max), so they must take the same tiered wire path. for model in [ crate::config::ZAI_GLM_5_2_MODEL, crate::config::ZAI_GLM_5_3_MODEL, + crate::config::ZAI_GLM_5_3_FLASH_MODEL, ] { let mut body = json!({}); apply_route_reasoning_controls(&mut body, ApiProvider::Zai, zai, model, Some("max")); diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 9a68310e4a..4d456e13f9 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -283,7 +283,7 @@ pub(crate) fn is_compaction_checkpoint_message(message: &Message) -> bool { user_text_of(message).is_some_and(|text| is_compaction_summary_text(&text)) } -fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { +pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: bool) -> usize { message .content .iter() @@ -340,14 +340,14 @@ pub fn estimate_tokens(messages: &[Message]) -> usize { .sum() } -fn message_has_tool_use(message: &Message) -> bool { +pub(crate) fn message_has_tool_use(message: &Message) -> bool { message .content .iter() .any(|block| matches!(block, ContentBlock::ToolUse { .. })) } -pub fn estimate_text_tokens_conservative(text: &str) -> usize { +pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } @@ -455,10 +455,18 @@ pub fn compaction_pressure_reached_with_billed( if !config.enabled { return false; } - let estimated = estimate_input_tokens_for_pressure(messages, system_prompt); let billed = billed_input_tokens .and_then(|tokens| usize::try_from(tokens).ok()) .unwrap_or(0); + // Billing alone proving pressure short-circuits the walk (#perf-r5): + // `estimated.max(billed) >= threshold` is unconditionally true when + // `billed >= threshold`, so estimating cannot change the answer and the + // O(transcript) pass is skipped. Over-pressure sessions pay this check + // multiple times per step (pressure gate + decision re-check). + if billed >= config.token_threshold { + return true; + } + let estimated = estimate_input_tokens_for_pressure(messages, system_prompt); estimated.max(billed) >= config.token_threshold } @@ -525,14 +533,26 @@ pub fn compaction_decision_with_billed( if !config.enabled { return CompactionDecision::NotNeeded; } - if !compaction_pressure_reached_with_billed( - messages, - system_prompt, - config, - billed_input_tokens, - ) { - return CompactionDecision::NotNeeded; - } + // Pressure gate + prune projection share one estimate (#perf-r5): both + // consume `estimate_input_tokens_for_pressure` over the same + // `(messages, system_prompt)`, a pure function, so it is computed at + // most once. `billed >= threshold` proves pressure without estimating + // (max is unconditionally >= threshold then); the estimate is deferred + // until something actually needs it — the prune projection below — so + // the billed-corner still reaches the TooFew and RetainedFloor guards + // unchanged, and skips the walk entirely when no prune candidates exist. + let billed = billed_input_tokens + .and_then(|tokens| usize::try_from(tokens).ok()) + .unwrap_or(0); + let estimated: Option = if billed < config.token_threshold { + let estimate = estimate_input_tokens_for_pressure(messages, system_prompt); + if estimate.max(billed) < config.token_threshold { + return CompactionDecision::NotNeeded; + } + Some(estimate) + } else { + None + }; // The execution path mechanically prunes old verbose tool results before // asking the model for a summary. Local pruning alone may be enough to @@ -542,9 +562,12 @@ pub fn compaction_decision_with_billed( // without cloning a multi-megabyte transcript on every step. let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES); if !prune_plan.is_empty() { + let estimate = match estimated { + Some(value) => value, + None => estimate_input_tokens_for_pressure(messages, system_prompt), + }; let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum(); - let projected = estimate_input_tokens_for_pressure(messages, system_prompt) - .saturating_sub(reclaimed_tokens); + let projected = estimate.saturating_sub(reclaimed_tokens); if projected < config.token_threshold { return CompactionDecision::Compact; } diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 0984d2e8b0..bb109cdc27 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1022,6 +1022,11 @@ fn canonical_openrouter_recent_model_id(model: &str) -> Option<&'static str> { OPENROUTER_GLM_5_2_MODEL | "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => { Some(OPENROUTER_GLM_5_2_MODEL) } + OPENROUTER_GLM_5_3_FLASH_MODEL + | "glm-5.3-flash" + | "glm-5-3-flash" + | "zai-glm-5.3-flash" + | "zai-glm-5-3-flash" => Some(OPENROUTER_GLM_5_3_FLASH_MODEL), OPENROUTER_GLM_5_3_MODEL | "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => { Some(OPENROUTER_GLM_5_3_MODEL) } @@ -1204,6 +1209,9 @@ fn canonical_zai_model_id(model: &str) -> Option<&'static str> { // `DEFAULT_ZAI_MODEL`: moving the default (now GLM-5.3) must not // silently re-point an explicit GLM-5.2 request. "glm-5.2" | "glm-5-2" | "zai-glm-5.2" | "zai-glm-5-2" => Some(ZAI_GLM_5_2_MODEL), + "glm-5.3-flash" | "glm-5-3-flash" | "zai-glm-5.3-flash" | "zai-glm-5-3-flash" => { + Some(ZAI_GLM_5_3_FLASH_MODEL) + } "glm-5.3" | "glm-5-3" | "zai-glm-5.3" | "zai-glm-5-3" => Some(ZAI_GLM_5_3_MODEL), "glm-5-turbo" | "glm-5turbo" | "zai-glm-5-turbo" => Some(ZAI_GLM_5_TURBO_MODEL), _ => None, @@ -1521,6 +1529,7 @@ pub fn model_completion_names_for_provider(provider: ApiProvider) -> Vec<&'stati ApiProvider::Openmodel => vec![DEFAULT_OPENMODEL_MODEL], ApiProvider::Zai => vec![ DEFAULT_ZAI_MODEL, + ZAI_GLM_5_3_FLASH_MODEL, ZAI_GLM_5_2_MODEL, ZAI_GLM_5_1_MODEL, ZAI_GLM_5_TURBO_MODEL, @@ -9600,10 +9609,10 @@ pub(crate) fn is_exact_zai_chat_route(provider: ApiProvider, base_url: &str) -> /// reasoning effort (`reasoning_effort: high | max`) rather than only the /// generic thinking toggle. /// -/// GLM-5.2 is the verified member. GLM-5.3 inherits it because its catalog row -/// inherits GLM-5.2's `reasoning_options` wholesale — see the -/// `INHERITED FROM glm-5.2` marker in `config/models.rs`. If Z.ai publishes -/// different reasoning controls for 5.3, this predicate is where they split. +/// GLM-5.2 is the verified member. GLM-5.3 and GLM-5.3-Flash inherit it +/// because their catalog rows inherit GLM-5.2's `reasoning_options` +/// wholesale. If Z.ai publishes different reasoning controls, this +/// predicate is where they split. #[must_use] pub(crate) fn is_exact_zai_tiered_effort_route( provider: ApiProvider, @@ -9612,7 +9621,8 @@ pub(crate) fn is_exact_zai_tiered_effort_route( ) -> bool { is_exact_zai_chat_route(provider, base_url) && (model.trim().eq_ignore_ascii_case(ZAI_GLM_5_2_MODEL) - || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_MODEL)) + || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_MODEL) + || model.trim().eq_ignore_ascii_case(ZAI_GLM_5_3_FLASH_MODEL)) } /// Whether a route is exactly first-party Z.ai GLM-5-Turbo. @@ -9627,8 +9637,8 @@ pub(crate) fn is_exact_zai_glm_5_turbo_route( } /// Whether a route is an exact first-party Z.ai model with a verified -/// reasoning control. GLM-5.2 and GLM-5.3 have tiered effort; GLM-5.1 and -/// GLM-5-Turbo only expose the generic thinking toggle. +/// reasoning control. GLM-5.2, GLM-5.3, and GLM-5.3-Flash have tiered +/// effort; GLM-5.1 and GLM-5-Turbo only expose the generic thinking toggle. #[must_use] pub(crate) fn is_exact_known_zai_reasoning_route( provider: ApiProvider, diff --git a/crates/tui/src/config/models.rs b/crates/tui/src/config/models.rs index fdb9e9efb6..d583573694 100644 --- a/crates/tui/src/config/models.rs +++ b/crates/tui/src/config/models.rs @@ -32,6 +32,7 @@ pub const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it"; pub const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1"; pub const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2"; pub const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3"; +pub const OPENROUTER_GLM_5_3_FLASH_MODEL: &str = "z-ai/glm-5.3-flash"; pub const OPENROUTER_GLM_5_TURBO_MODEL: &str = "z-ai/glm-5-turbo"; pub const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code"; pub const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6"; @@ -71,6 +72,7 @@ pub const RECENT_OPENROUTER_LARGE_MODELS: &[&str] = &[ OPENROUTER_GLM_5_1_MODEL, OPENROUTER_GLM_5_2_MODEL, OPENROUTER_GLM_5_3_MODEL, + OPENROUTER_GLM_5_3_FLASH_MODEL, OPENROUTER_TENCENT_HY3_PREVIEW_MODEL, OPENROUTER_GEMMA_4_31B_MODEL, OPENROUTER_GEMMA_4_26B_A4B_MODEL, @@ -196,6 +198,7 @@ pub const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL; pub const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1"; pub const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2"; pub const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3"; +pub const ZAI_GLM_5_3_FLASH_MODEL: &str = "GLM-5.3-Flash"; pub const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo"; pub const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4"; pub const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash"; diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 04e39413b9..08f699d74e 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -6688,6 +6688,7 @@ fn model_completion_names_for_zai_lists_default_5_1_and_turbo() { assert_eq!(models.first().copied(), Some(DEFAULT_ZAI_MODEL)); assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_3_MODEL); assert!(models.contains(&ZAI_GLM_5_1_MODEL)); + assert!(models.contains(&ZAI_GLM_5_3_FLASH_MODEL)); assert!(models.contains(&ZAI_GLM_5_TURBO_MODEL)); // GLM-5.2 is still offered alongside the others but no longer takes the // default slot; explicit 5.2 routes are untouched. @@ -6711,6 +6712,9 @@ fn normalize_model_name_for_zai_canonicalizes_current_glm_models() { ("glm-5.3", DEFAULT_ZAI_MODEL), ("glm-5-3", ZAI_GLM_5_3_MODEL), ("zai-glm-5-3", ZAI_GLM_5_3_MODEL), + ("glm-5.3-flash", ZAI_GLM_5_3_FLASH_MODEL), + ("glm-5-3-flash", ZAI_GLM_5_3_FLASH_MODEL), + ("zai-glm-5.3-flash", ZAI_GLM_5_3_FLASH_MODEL), ("glm-5-turbo", ZAI_GLM_5_TURBO_MODEL), ("zai-glm-5-turbo", ZAI_GLM_5_TURBO_MODEL), ] { @@ -11050,7 +11054,14 @@ fn provider_capability_zai_defaults_to_5_3_and_tracks_5_2_5_1_and_turbo() { assert_eq!(v51.max_output, Some(131_072)); assert!(v51.thinking_supported); - // GLM-5-Turbo is the faster sub-agent sibling. + // GLM-5.3-Flash is the published 1M multimodal sibling. + let flash = provider_capability(ApiProvider::Zai, ZAI_GLM_5_3_FLASH_MODEL); + assert_eq!(flash.resolved_model, ZAI_GLM_5_3_FLASH_MODEL); + assert_eq!(flash.context_window, 1_000_000); + assert_eq!(flash.max_output, Some(131_072)); + assert!(flash.thinking_supported); + + // GLM-5-Turbo is the faster sub-agent sibling of GLM-5.2. let turbo = provider_capability(ApiProvider::Zai, ZAI_GLM_5_TURBO_MODEL); assert_eq!(turbo.resolved_model, ZAI_GLM_5_TURBO_MODEL); } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2852bf055f..a2987d8786 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2841,6 +2841,14 @@ impl Engine { .push(crate::compaction::compaction_checkpoint_message(checkpoint)); } self.session.messages = restored_messages.into(); + // Direct field assignment bypasses `add_message` / + // `replace_messages`, which own the messages-revision + // bump the token-estimate cache keys on (#perf-r5). + // Without this bump the first estimate after a + // session restore is computed against whatever + // history revision was current before the sync — a + // stale number can flow into capacity checkpoints. + self.session.bump_messages_revision(); self.session.compaction_summary_prompt = compaction_checkpoint; self.session.system_prompt = crate::compaction::strip_compaction_summaries(system_prompt.as_ref()); @@ -3134,17 +3142,37 @@ impl Engine { current_text: &str, system_prompt: Option<&SystemPrompt>, ) -> usize { - let mut messages: Vec = self.session.messages.clone().into(); - if !current_text.trim().is_empty() { - messages.push(Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: current_text.to_string(), - cache_control: None, - }], - }); + // Estimate the installed history IN PLACE — no full-transcript clone + // per `` build (#perf-r5). `&AppendLog` deref-coerces to + // `&[Message]` exactly like the cache call site. + let base = estimate_input_tokens_conservative(&self.session.messages, system_prompt); + if current_text.trim().is_empty() { + return base; + } + // Arithmetic equivalent of pushing one more user message: `own` + // un-inflated tokens (Text block rule, `len()/4` — same as the + // estimator's per-message byte sum S) plus one framing increment. + // The estimator inflates S by ceil(3/2) as a WHOLE, so + // ceil((S+own)*3/2) − ceil(S*3/2) = floor(own*3/2) + 1 exactly when + // S is even and own is odd; pinned exhaustively (80k pairs) and per + // case by `context_pressure_delta_matches_clone_and_push_reference`. + let sum: usize = self + .session + .messages + .iter() + .map(|m| { + crate::compaction::estimate_tokens_for_message( + m, + crate::compaction::message_has_tool_use(m), + ) + }) + .sum(); + let own = current_text.len() / 4; + let mut inflated_delta = own * 3 / 2; + if sum.is_multiple_of(2) && own % 2 == 1 { + inflated_delta += 1; } - estimate_input_tokens_conservative(&messages, system_prompt) + base.saturating_add(inflated_delta).saturating_add(12) } fn append_resource_metadata_lines( diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 2533a5dd38..3d61944b9d 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -333,6 +333,214 @@ fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() { assert!(!metadata.contains("4096 tokens"), "{metadata}"); } +/// #perf-r5: the pressure-line helper must estimate the history IN PLACE and +/// add the composer text arithmetically. Guards two things at once: +/// +/// 1. Equivalence — the arithmetic form must equal the naive +/// "clone + push + estimate" reference for non-trivial inputs (Unicode +/// multi-byte content included, since Text blocks count *chars* for the +/// conservative estimator but the delta path counts... the same rule as +/// `estimate_tokens_for_message`: bytes/4). +/// 2. The contract that empty/no-op composer text costs nothing extra. +#[test] +fn context_pressure_delta_matches_clone_and_push_reference() { + let config = deepseek_config(); + let (mut engine, _handle, _tmp) = preview_engine(&config); + engine.api_provider = ApiProvider::Deepseek; + let installed_limits = codewhale_config::route::RouteLimits { + context_tokens: Some(64_000), + input_tokens: None, + output_tokens: Some(512), + }; + engine.active_route_limits = Some(installed_limits); + // Multi-byte content on purpose: chars().count() != len() here, so an + // arity mistake between the byte rule (estimator) would surface. + engine.session.messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "héllo wörld — ünïcode ✓ ".repeat(500), + cache_control: None, + }], + }); + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: "step".repeat(100), + signature: None, + state: None, + }], + }); + // Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message + // carrying BOTH thinking and a tool call keeps its reasoning content in + // every subsequent request — the estimator counts those bytes, and this + // was the exact arm the delta helper originally missed. Both parity + // variants of the thinking byte-count are exercised below. + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "replayed".repeat(300), // 8 bytes per unit -> even count + signature: None, + state: None, + }, + ContentBlock::ToolUse { + id: "call_1".to_string(), + name: "bash".to_string(), + input: json!({"command": "echo hello"}), + caller: None, + thought_signature: None, + }, + ], + }); + engine.session.messages.push(Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "odd replay".to_string(), // 11 bytes / 4 = 2 (even)... use odd total + signature: None, + state: None, + }, + ContentBlock::ToolUse { + id: "call_2".to_string(), + name: "read".to_string(), + input: json!({"path": "x"}), // 13-byte JSON -> 3 + caller: None, + thought_signature: None, + }, + ], + }); + let _prompt_context = NextTurnPromptContext::for_planned_turn( + ApiProvider::Deepseek, + "deepseek-v4-flash".to_string(), + Some(installed_limits), + AppMode::Agent, + None, + GoalStatus::Active, + None, + false, + None, + ); + let _ = &_prompt_context; + + // Naive reference implementation: clone the transcript, push a + // hypothetical user message, run the full conservative estimator. + let reference = |engine: &Engine, text: &str| -> usize { + let mut messages: Vec = + crate::prompt_zones::AppendLog::clone(&engine.session.messages).into(); + if !text.trim().is_empty() { + messages.push(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + }); + } + crate::compaction::estimate_input_tokens_conservative(&messages, None) + }; + + for text in [ + "", + " ", + "short", + "a much longer composer draft with punctuation…", + ] { + let via_pressure_line_input = engine.active_input_tokens_with_current_text(text, None); + assert_eq!( + via_pressure_line_input, + reference(&engine, text), + "delta arithmetic diverged from clone+push+estimate for {text:?}" + ); + } +} + +/// #perf-r5 guard: billed input above the threshold must report pressure with +/// a provably-empty history — proving the short-circuit answers from billing +/// alone without consulting message contents. +#[test] +fn billed_pressure_above_threshold_answers_from_billing_alone() { + let config = CompactionConfig { + enabled: true, + token_threshold: 1_000, + ..Default::default() + }; + let pressure = crate::compaction::compaction_pressure_reached_with_billed( + &[], // empty history: only billing can prove pressure + None, + &config, + Some(2_000), + ); + assert!(pressure, "billed 2000 >= threshold 1000 must be pressure"); +} + +/// #perf-r5 guard: under-threshold billing keeps the old max() semantics — +/// an estimate above the trigger still fires even when billing is quiet. +#[test] +fn billed_below_threshold_still_fires_on_estimate() { + let config = CompactionConfig { + enabled: true, + token_threshold: 100, + ..Default::default() + }; + let big = Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "x".repeat(4 * 200), + cache_control: None, + }], + }; + let pressure = crate::compaction::compaction_pressure_reached_with_billed( + std::slice::from_ref(&big), + None, + &config, + Some(10), // below threshold; must not short-circuit to false either + ); + assert!(pressure, "estimate 200 (+1.0 framing) >= 100 must fire"); +} + +/// #perf-r5 guard: a direct `session.messages` overwrite (the SyncSession +/// restore path) must advance `messages_revision` so the token-estimate +/// cache invalidates instead of serving the pre-sync value. +#[test] +fn sync_restore_bumps_messages_revision_for_estimate_cache() { + use crate::core::engine::token_estimate_cache::TokenEstimateCache; + + let config = deepseek_config(); + let (mut engine, _handle, _tmp) = preview_engine(&config); + engine.session.add_message(Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "before restore".to_string(), + cache_control: None, + }], + }); + let revision_before = engine.session.messages_revision; + let mut cache = TokenEstimateCache::new(); + let stale = cache.lookup_or_compute( + revision_before, + engine.session.system_prompt.as_ref(), + &engine.session.messages, + ); + + // Simulate the restore's direct field assignment. + engine.session.messages = Vec::new().into(); + engine.session.bump_messages_revision(); + + assert_ne!( + engine.session.messages_revision, revision_before, + "restore must bump the revision the estimate cache keys on" + ); + let fresh = cache.lookup_or_compute( + engine.session.messages_revision, + engine.session.system_prompt.as_ref(), + &engine.session.messages, + ); + assert_ne!( + fresh, stale, + "cache must recompute after a restore-driven revision bump" + ); +} + #[tokio::test] async fn compaction_preview_uses_the_planned_routes_system_prompt() { let config = deepseek_config(); diff --git a/crates/tui/src/core/mod.rs b/crates/tui/src/core/mod.rs index 1b839ecaf4..01ed9027a3 100644 --- a/crates/tui/src/core/mod.rs +++ b/crates/tui/src/core/mod.rs @@ -28,12 +28,9 @@ pub mod events; #[path = "runtime_contract/model.rs"] pub mod model_client; pub mod ops; +pub mod session; #[path = "runtime_contract/termination.rs"] pub mod termination; -// The rest of `runtime_contract/` stays on disk as staged Core-runtime -// scaffolding and remains deliberately uncompiled until it has production -// consumers (TUI-DOG-017). -pub mod session; // Moved to `codewhale_core::tool_parser` (zero crate-internal dependencies); // re-exported so `crate::core::tool_parser` keeps working. pub use codewhale_core::tool_parser; diff --git a/crates/tui/src/core/runtime_contract/budget.rs b/crates/tui/src/core/runtime_contract/budget.rs deleted file mode 100644 index d109f92b99..0000000000 --- a/crates/tui/src/core/runtime_contract/budget.rs +++ /dev/null @@ -1,115 +0,0 @@ -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExecutionBudget { - pub max_steps: u32, - pub max_tool_calls: u32, - pub max_retries: u32, - pub max_wall_time_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct BudgetUsage { - pub steps: u32, - pub tool_calls: u32, - pub retries: u32, - pub elapsed_ms: u64, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BudgetExhaustion { - Steps, - ToolCalls, - Retries, - WallTime, -} - -impl ExecutionBudget { - pub fn validate(self) -> Result<(), String> { - if self.max_steps == 0 { - return Err("execution budget max_steps must be greater than zero".to_string()); - } - if self.max_tool_calls == 0 { - return Err("execution budget max_tool_calls must be greater than zero".to_string()); - } - if self.max_wall_time_ms == 0 { - return Err("execution budget max_wall_time_ms must be greater than zero".to_string()); - } - Ok(()) - } - - #[must_use] - pub const fn wall_time(self) -> Duration { - Duration::from_millis(self.max_wall_time_ms) - } - - #[must_use] - pub fn exhausted_by(self, usage: BudgetUsage) -> Option { - if usage.steps >= self.max_steps { - Some(BudgetExhaustion::Steps) - } else if usage.tool_calls >= self.max_tool_calls { - Some(BudgetExhaustion::ToolCalls) - } else if usage.retries > self.max_retries { - Some(BudgetExhaustion::Retries) - } else if usage.elapsed_ms >= self.max_wall_time_ms { - Some(BudgetExhaustion::WallTime) - } else { - None - } - } - - /// Child work may narrow a parent budget but can never widen it. - #[must_use] - pub fn child_budget(self, requested: Self) -> Self { - Self { - max_steps: self.max_steps.min(requested.max_steps), - max_tool_calls: self.max_tool_calls.min(requested.max_tool_calls), - max_retries: self.max_retries.min(requested.max_retries), - max_wall_time_ms: self.max_wall_time_ms.min(requested.max_wall_time_ms), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn child_cannot_widen_parent_budget() { - let parent = ExecutionBudget { - max_steps: 32, - max_tool_calls: 64, - max_retries: 3, - max_wall_time_ms: 60_000, - }; - let child = parent.child_budget(ExecutionBudget { - max_steps: u32::MAX, - max_tool_calls: u32::MAX, - max_retries: u32::MAX, - max_wall_time_ms: u64::MAX, - }); - assert_eq!(child, parent); - } - - #[test] - fn wall_time_has_a_distinct_exhaustion_reason() { - let budget = ExecutionBudget { - max_steps: 100, - max_tool_calls: 100, - max_retries: 3, - max_wall_time_ms: 10, - }; - assert_eq!( - budget.exhausted_by(BudgetUsage { - steps: 1, - tool_calls: 1, - retries: 0, - elapsed_ms: 10, - }), - Some(BudgetExhaustion::WallTime) - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/context.rs b/crates/tui/src/core/runtime_contract/context.rs deleted file mode 100644 index 4b096f2483..0000000000 --- a/crates/tui/src/core/runtime_contract/context.rs +++ /dev/null @@ -1,168 +0,0 @@ -use std::collections::BTreeSet; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextSourceKind { - Constitution, - RepositoryLaw, - ScopedRepositoryLaw, - Instruction, - Skill, - Hook, - Mcp, - Memory, - ModelProfile, - CapabilityProfile, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ContextPriority { - Required, - High, - Normal, - Optional, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContextSourceReceipt { - pub id: String, - pub kind: ContextSourceKind, - pub priority: ContextPriority, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - pub bytes: u64, - pub estimated_tokens: u64, - pub content_hash: String, - pub included: bool, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResourcesLoadedEvent { - pub event_id: String, - pub sources: Vec, - pub assembly_ms: u64, - pub prompt_tokens: u64, - pub schema_tokens: u64, -} - -impl ResourcesLoadedEvent { - #[must_use] - pub fn included_tokens(&self) -> u64 { - self.sources - .iter() - .filter(|source| source.included) - .map(|source| source.estimated_tokens) - .sum() - } - - pub fn validate_required_sources(&self) -> Result<(), Vec> { - let missing = self - .sources - .iter() - .filter(|source| source.priority == ContextPriority::Required && !source.included) - .map(|source| source.id.clone()) - .collect::>(); - if missing.is_empty() { - Ok(()) - } else { - Err(missing) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ContinuitySet { - #[serde(default)] - pub constraints: BTreeSet, - #[serde(default)] - pub approvals: BTreeSet, - #[serde(default)] - pub failed_checks: BTreeSet, - #[serde(default)] - pub edited_paths: BTreeSet, - #[serde(default)] - pub pending_work: BTreeSet, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CondensationEvent { - pub event_id: String, - pub source_start_event_id: String, - pub source_end_event_id: String, - pub summary_hash: String, - pub provider: String, - pub model: String, - pub reason: String, - pub continuity: ContinuitySet, - pub input_tokens: u64, - pub output_tokens: u64, -} - -impl CondensationEvent { - pub fn validate(&self) -> Result<(), String> { - for (label, value) in [ - ("event_id", self.event_id.as_str()), - ("source_start_event_id", self.source_start_event_id.as_str()), - ("source_end_event_id", self.source_end_event_id.as_str()), - ("summary_hash", self.summary_hash.as_str()), - ("provider", self.provider.as_str()), - ("model", self.model.as_str()), - ("reason", self.reason.as_str()), - ] { - if value.trim().is_empty() { - return Err(format!("condensation {label} cannot be empty")); - } - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn required_law_cannot_be_silently_dropped() { - let event = ResourcesLoadedEvent { - event_id: "resources-1".to_string(), - sources: vec![ContextSourceReceipt { - id: "AGENTS.md".to_string(), - kind: ContextSourceKind::RepositoryLaw, - priority: ContextPriority::Required, - path: Some(PathBuf::from("AGENTS.md")), - bytes: 100, - estimated_tokens: 25, - content_hash: "hash".to_string(), - included: false, - reason: "budget".to_string(), - }], - assembly_ms: 1, - prompt_tokens: 0, - schema_tokens: 0, - }; - assert_eq!( - event.validate_required_sources().unwrap_err(), - vec!["AGENTS.md"] - ); - } - - #[test] - fn condensation_preserves_distinct_continuity_domains() { - let continuity = ContinuitySet { - constraints: BTreeSet::from(["do not deploy".to_string()]), - approvals: BTreeSet::from(["edit src only".to_string()]), - failed_checks: BTreeSet::from(["cargo test".to_string()]), - edited_paths: BTreeSet::from([PathBuf::from("src/lib.rs")]), - pending_work: BTreeSet::from(["rerun test".to_string()]), - }; - let json = serde_json::to_value(&continuity).unwrap(); - assert_eq!(json["constraints"][0], "do not deploy"); - assert_eq!(json["failed_checks"][0], "cargo test"); - assert_eq!(json["pending_work"][0], "rerun test"); - } -} diff --git a/crates/tui/src/core/runtime_contract/ledger.rs b/crates/tui/src/core/runtime_contract/ledger.rs deleted file mode 100644 index 70e5782cfc..0000000000 --- a/crates/tui/src/core/runtime_contract/ledger.rs +++ /dev/null @@ -1,200 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; - -use super::RUNTIME_CONTRACT_SCHEMA_VERSION; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RuntimeEventKind { - UserMessage, - AssistantMessage, - ToolStarted, - ToolCompleted, - ApprovalRequested, - ApprovalResolved, - SteeringQueued, - SteeringDelivered, - ResourcesLoaded, - Condensation, - Work, - Child, - Usage, - Retry, - Termination, -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RuntimeEventEnvelope { - pub schema_version: u32, - pub sequence: u64, - pub event_id: String, - pub kind: RuntimeEventKind, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_event_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub causal_event_id: Option, - pub recorded_at_ms: u64, - pub payload: Value, - pub checksum: String, -} - -impl RuntimeEventEnvelope { - #[must_use] - pub fn new( - sequence: u64, - event_id: impl Into, - kind: RuntimeEventKind, - recorded_at_ms: u64, - payload: Value, - ) -> Self { - let mut event = Self { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - sequence, - event_id: event_id.into(), - kind, - parent_event_id: None, - causal_event_id: None, - recorded_at_ms, - payload, - checksum: String::new(), - }; - event.checksum = event.expected_checksum(); - event - } - - #[must_use] - pub fn expected_checksum(&self) -> String { - let canonical = serde_json::json!({ - "schema_version": self.schema_version, - "sequence": self.sequence, - "event_id": self.event_id, - "kind": self.kind, - "parent_event_id": self.parent_event_id, - "causal_event_id": self.causal_event_id, - "recorded_at_ms": self.recorded_at_ms, - "payload": self.payload, - }); - let bytes = serde_json::to_vec(&canonical).expect("runtime event JSON is serializable"); - let digest = Sha256::digest(bytes); - digest.iter().map(|byte| format!("{byte:02x}")).collect() - } - - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported runtime event schema {}", - self.schema_version - )); - } - if self.event_id.trim().is_empty() { - return Err("runtime event ID cannot be empty".to_string()); - } - let expected = self.expected_checksum(); - if self.checksum != expected { - return Err(format!("runtime event {} checksum mismatch", self.event_id)); - } - Ok(()) - } -} - -#[derive(Debug, Default, Clone)] -pub struct AppendOnlyRuntimeLedger { - events: Vec, -} - -impl AppendOnlyRuntimeLedger { - pub fn append(&mut self, event: RuntimeEventEnvelope) -> Result<(), String> { - event.validate()?; - let expected_sequence = self.events.last().map_or(0, |last| last.sequence + 1); - if event.sequence != expected_sequence { - return Err(format!( - "runtime event sequence {} does not follow {}", - event.sequence, expected_sequence - )); - } - if self - .events - .iter() - .any(|item| item.event_id == event.event_id) - { - return Err(format!("duplicate runtime event ID `{}`", event.event_id)); - } - self.events.push(event); - Ok(()) - } - - #[must_use] - pub fn events(&self) -> &[RuntimeEventEnvelope] { - &self.events - } - - #[must_use] - pub fn range(&self, start: u64, end_inclusive: u64) -> Vec<&RuntimeEventEnvelope> { - self.events - .iter() - .filter(|event| (start..=end_inclusive).contains(&event.sequence)) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn ledger_rejects_corruption_and_sequence_gaps() { - let mut ledger = AppendOnlyRuntimeLedger::default(); - ledger - .append(RuntimeEventEnvelope::new( - 0, - "event-0", - RuntimeEventKind::UserMessage, - 1, - serde_json::json!({"text": "hello"}), - )) - .unwrap(); - - let gap = RuntimeEventEnvelope::new( - 2, - "event-2", - RuntimeEventKind::Termination, - 2, - serde_json::json!({}), - ); - assert!(ledger.append(gap).unwrap_err().contains("does not follow")); - - let mut corrupt = RuntimeEventEnvelope::new( - 1, - "event-1", - RuntimeEventKind::ToolCompleted, - 2, - serde_json::json!({"ok": true}), - ); - corrupt.payload = serde_json::json!({"ok": false}); - assert!( - corrupt - .validate() - .unwrap_err() - .contains("checksum mismatch") - ); - } - - #[test] - fn derived_range_does_not_remove_original_events() { - let mut ledger = AppendOnlyRuntimeLedger::default(); - for sequence in 0..3 { - ledger - .append(RuntimeEventEnvelope::new( - sequence, - format!("event-{sequence}"), - RuntimeEventKind::AssistantMessage, - sequence, - serde_json::json!({"sequence": sequence}), - )) - .unwrap(); - } - assert_eq!(ledger.range(1, 2).len(), 2); - assert_eq!(ledger.events().len(), 3); - } -} diff --git a/crates/tui/src/core/runtime_contract/manifest.rs b/crates/tui/src/core/runtime_contract/manifest.rs deleted file mode 100644 index 13a5e38f16..0000000000 --- a/crates/tui/src/core/runtime_contract/manifest.rs +++ /dev/null @@ -1,246 +0,0 @@ -use std::collections::BTreeMap; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -use super::{RUNTIME_CONTRACT_SCHEMA_VERSION, profile::ToolProfileManifest}; - -/// Reproducible runtime contract captured before an unattended or measured -/// run starts. Secret values never belong in this structure. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunContractManifest { - pub schema_version: u32, - pub binary_version: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_sha: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub dirty_patch_hash: Option, - pub workspace: PathBuf, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub base_revision: Option, - pub provider: String, - pub model: String, - pub route: String, - pub permission_posture: String, - pub sandbox_identity: String, - pub network_policy: String, - pub prompt_hash: String, - pub profile: ToolProfileManifest, - /// Stable hash by tool name. A resume must not silently continue with a - /// different model-visible schema. - pub tool_schema_hashes: BTreeMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ManifestMismatch { - pub field: String, - pub saved: String, - pub current: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ResumeCompatibility { - Compatible, - ExplicitMigrationRequired(Vec), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RunReadiness { - pub ready: bool, - #[serde(default)] - pub blockers: Vec, - #[serde(default)] - pub warnings: Vec, -} - -impl RunContractManifest { - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported run manifest schema {}; expected {}", - self.schema_version, RUNTIME_CONTRACT_SCHEMA_VERSION - )); - } - self.profile.validate()?; - for (label, value) in [ - ("binary_version", self.binary_version.as_str()), - ("provider", self.provider.as_str()), - ("model", self.model.as_str()), - ("route", self.route.as_str()), - ("permission_posture", self.permission_posture.as_str()), - ("sandbox_identity", self.sandbox_identity.as_str()), - ("prompt_hash", self.prompt_hash.as_str()), - ] { - if value.trim().is_empty() { - return Err(format!("run manifest {label} cannot be empty")); - } - } - Ok(()) - } - - #[must_use] - pub fn readiness(&self) -> RunReadiness { - let mut blockers = Vec::new(); - let mut warnings = Vec::new(); - if let Err(error) = self.validate() { - blockers.push(error); - } - if self.source_sha.is_none() { - warnings.push("source SHA is not available".to_string()); - } - if self.dirty_patch_hash.is_none() { - warnings.push("dirty patch hash is not recorded".to_string()); - } - RunReadiness { - ready: blockers.is_empty(), - blockers, - warnings, - } - } - - #[must_use] - pub fn compare_for_resume(&self, current: &Self) -> ResumeCompatibility { - let mut mismatches = Vec::new(); - compare_field( - &mut mismatches, - "schema_version", - self.schema_version, - current.schema_version, - ); - compare_field( - &mut mismatches, - "provider", - &self.provider, - ¤t.provider, - ); - compare_field(&mut mismatches, "model", &self.model, ¤t.model); - compare_field(&mut mismatches, "route", &self.route, ¤t.route); - compare_field( - &mut mismatches, - "permission_posture", - &self.permission_posture, - ¤t.permission_posture, - ); - compare_field( - &mut mismatches, - "sandbox_identity", - &self.sandbox_identity, - ¤t.sandbox_identity, - ); - compare_field( - &mut mismatches, - "prompt_hash", - &self.prompt_hash, - ¤t.prompt_hash, - ); - compare_field( - &mut mismatches, - "profile", - format!("{:?}", self.profile), - format!("{:?}", current.profile), - ); - compare_field( - &mut mismatches, - "tool_schema_hashes", - format!("{:?}", self.tool_schema_hashes), - format!("{:?}", current.tool_schema_hashes), - ); - if mismatches.is_empty() { - ResumeCompatibility::Compatible - } else { - ResumeCompatibility::ExplicitMigrationRequired(mismatches) - } - } -} - -fn compare_field( - mismatches: &mut Vec, - field: &str, - saved: impl ToString, - current: impl ToString, -) { - let saved = saved.to_string(); - let current = current.to_string(); - if saved != current { - mismatches.push(ManifestMismatch { - field: field.to_string(), - saved, - current, - }); - } -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use super::*; - use crate::core::runtime_contract::{ - profile::{AgentProfileCandidate, SemanticCapability, ToolActivationPolicy}, - terminal::TerminalProcessPolicy, - }; - - fn manifest() -> RunContractManifest { - let mut capabilities = BTreeSet::new(); - capabilities.insert(SemanticCapability::TypedTermination); - RunContractManifest { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - binary_version: "0.8.68".to_string(), - source_sha: Some("abc".to_string()), - dirty_patch_hash: Some("patch".to_string()), - workspace: PathBuf::from("/workspace"), - base_revision: Some("base".to_string()), - provider: "example".to_string(), - model: "model".to_string(), - route: "api".to_string(), - permission_posture: "ask".to_string(), - sandbox_identity: "workspace_write".to_string(), - network_policy: "ask".to_string(), - prompt_hash: "prompt".to_string(), - profile: ToolProfileManifest { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - candidate: AgentProfileCandidate::AdaptiveCore, - activation_policy: ToolActivationPolicy::DeferredSearch, - terminal_policy: TerminalProcessPolicy::Hybrid, - capabilities, - active_tools: BTreeSet::from(["tool_search".to_string()]), - deferred_tools: BTreeSet::from(["run_verifiers".to_string()]), - max_steps: 64, - max_wall_time_seconds: Some(900), - }, - tool_schema_hashes: BTreeMap::from([( - "tool_search".to_string(), - "schema-a".to_string(), - )]), - } - } - - #[test] - fn resume_fails_closed_on_tool_schema_drift() { - let saved = manifest(); - let mut current = saved.clone(); - current - .tool_schema_hashes - .insert("tool_search".to_string(), "schema-b".to_string()); - let ResumeCompatibility::ExplicitMigrationRequired(mismatches) = - saved.compare_for_resume(¤t) - else { - panic!("schema drift must require migration"); - }; - assert!( - mismatches - .iter() - .any(|mismatch| mismatch.field == "tool_schema_hashes") - ); - } - - #[test] - fn readiness_warns_without_source_identity_but_does_not_block() { - let mut value = manifest(); - value.source_sha = None; - let readiness = value.readiness(); - assert!(readiness.ready); - assert_eq!(readiness.warnings, vec!["source SHA is not available"]); - } -} diff --git a/crates/tui/src/core/runtime_contract/mod.rs b/crates/tui/src/core/runtime_contract/mod.rs deleted file mode 100644 index e4cf9e6d66..0000000000 --- a/crates/tui/src/core/runtime_contract/mod.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Versioned contracts shared by interactive, headless, Fleet, and evaluation -//! adapters. -//! -//! This module is intentionally independent of rendering and provider clients. -//! It gives the Core-profile experiments a typed place to converge before any -//! candidate becomes the product default. - -pub mod budget; -pub mod context; -pub mod ledger; -pub mod manifest; -pub mod model; -pub mod profile; -pub mod progress; -pub mod retry; -pub mod terminal; -pub mod termination; -pub mod work; - -/// Schema shared by the initial Core runtime contracts. -pub const RUNTIME_CONTRACT_SCHEMA_VERSION: u32 = 1; diff --git a/crates/tui/src/core/runtime_contract/profile.rs b/crates/tui/src/core/runtime_contract/profile.rs deleted file mode 100644 index 7420d84708..0000000000 --- a/crates/tui/src/core/runtime_contract/profile.rs +++ /dev/null @@ -1,174 +0,0 @@ -use std::collections::BTreeSet; - -use serde::{Deserialize, Serialize}; - -use super::{RUNTIME_CONTRACT_SCHEMA_VERSION, terminal::TerminalProcessPolicy}; - -/// Candidate profiles are implementation experiments, not public Codewhale -/// modes. Plan/Act/Operate and permission posture remain independent axes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AgentProfileCandidate { - CurrentFull, - ConsolidatedCore, - SpecializedCore, - AdaptiveCore, -} - -/// Semantic abilities a profile promises regardless of model-facing tool -/// names. This lets paired trials compare combined and specialized schemas -/// without changing the task contract. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SemanticCapability { - CommandExecution, - FileRead, - FileSearch, - FileEdit, - ActiveChecklist, - TypedTermination, - Verification, - Delegation, - Network, - Mcp, - Knowledge, - Media, - Release, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ToolActivationPolicy { - Static, - DeferredSearch, - ExplicitCapability, - Adaptive, -} - -/// Exact tool/profile manifest supplied to a model for one run. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ToolProfileManifest { - pub schema_version: u32, - pub candidate: AgentProfileCandidate, - pub activation_policy: ToolActivationPolicy, - pub terminal_policy: TerminalProcessPolicy, - pub capabilities: BTreeSet, - pub active_tools: BTreeSet, - pub deferred_tools: BTreeSet, - pub max_steps: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub max_wall_time_seconds: Option, -} - -impl ToolProfileManifest { - #[must_use] - pub fn new( - candidate: AgentProfileCandidate, - activation_policy: ToolActivationPolicy, - terminal_policy: TerminalProcessPolicy, - max_steps: u32, - ) -> Self { - Self { - schema_version: RUNTIME_CONTRACT_SCHEMA_VERSION, - candidate, - activation_policy, - terminal_policy, - capabilities: BTreeSet::new(), - active_tools: BTreeSet::new(), - deferred_tools: BTreeSet::new(), - max_steps, - max_wall_time_seconds: None, - } - } - - #[must_use] - pub fn with_capability(mut self, capability: SemanticCapability) -> Self { - self.capabilities.insert(capability); - self - } - - #[must_use] - pub fn with_active_tool(mut self, tool: impl Into) -> Self { - self.active_tools.insert(tool.into()); - self - } - - #[must_use] - pub fn with_deferred_tool(mut self, tool: impl Into) -> Self { - self.deferred_tools.insert(tool.into()); - self - } - - pub fn validate(&self) -> Result<(), String> { - if self.schema_version != RUNTIME_CONTRACT_SCHEMA_VERSION { - return Err(format!( - "unsupported runtime profile schema {}; expected {}", - self.schema_version, RUNTIME_CONTRACT_SCHEMA_VERSION - )); - } - if self.max_steps == 0 { - return Err("runtime profile max_steps must be greater than zero".to_string()); - } - if let Some(overlap) = self.active_tools.intersection(&self.deferred_tools).next() { - return Err(format!( - "tool `{overlap}` cannot be both active and deferred" - )); - } - if !self - .capabilities - .contains(&SemanticCapability::TypedTermination) - { - return Err("runtime profile must promise typed termination".to_string()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn candidate_profile_keeps_public_modes_out_of_the_contract() { - let json = serde_json::to_string(&AgentProfileCandidate::AdaptiveCore).unwrap(); - assert_eq!(json, "\"adaptive_core\""); - assert!(!json.contains("plan")); - assert!(!json.contains("operate")); - } - - #[test] - fn manifest_rejects_active_deferred_overlap() { - let manifest = ToolProfileManifest::new( - AgentProfileCandidate::AdaptiveCore, - ToolActivationPolicy::DeferredSearch, - TerminalProcessPolicy::Hybrid, - 64, - ) - .with_capability(SemanticCapability::TypedTermination) - .with_active_tool("tool_search") - .with_deferred_tool("tool_search"); - - assert!( - manifest - .validate() - .unwrap_err() - .contains("both active and deferred") - ); - } - - #[test] - fn manifest_requires_typed_termination() { - let manifest = ToolProfileManifest::new( - AgentProfileCandidate::SpecializedCore, - ToolActivationPolicy::Static, - TerminalProcessPolicy::Isolated, - 32, - ); - assert!( - manifest - .validate() - .unwrap_err() - .contains("typed termination") - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/progress.rs b/crates/tui/src/core/runtime_contract/progress.rs deleted file mode 100644 index 82df57925e..0000000000 --- a/crates/tui/src/core/runtime_contract/progress.rs +++ /dev/null @@ -1,232 +0,0 @@ -use std::collections::VecDeque; - -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ActionFingerprint { - pub operation: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub target: Option, - pub input_digest: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub outcome_digest: Option, - pub succeeded: bool, - pub changed_state: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProgressGuardConfig { - pub identical_action_limit: usize, - pub alternating_cycle_limit: usize, - pub no_progress_limit: usize, - pub history_limit: usize, -} - -impl Default for ProgressGuardConfig { - fn default() -> Self { - Self { - identical_action_limit: 3, - alternating_cycle_limit: 3, - no_progress_limit: 6, - history_limit: 16, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum GuardDecision { - Continue, - Warn { reason: String }, - Stop { reason: String }, -} - -/// Structural no-progress detector. It consumes normalized fingerprints, not -/// provider prose, so the same policy can run in TUI, headless, and tests. -#[derive(Debug, Clone)] -pub struct ProgressGuard { - config: ProgressGuardConfig, - history: VecDeque, - warning_issued: bool, -} - -impl ProgressGuard { - #[must_use] - pub fn new(config: ProgressGuardConfig) -> Self { - Self { - config, - history: VecDeque::with_capacity(config.history_limit), - warning_issued: false, - } - } - - pub fn observe(&mut self, action: ActionFingerprint) -> GuardDecision { - if action.changed_state { - self.warning_issued = false; - } - self.history.push_back(action); - while self.history.len() > self.config.history_limit { - self.history.pop_front(); - } - - let reason = self - .identical_loop_reason() - .or_else(|| self.alternating_loop_reason()) - .or_else(|| self.no_progress_reason()); - let Some(reason) = reason else { - return GuardDecision::Continue; - }; - if self.warning_issued { - GuardDecision::Stop { reason } - } else { - self.warning_issued = true; - GuardDecision::Warn { reason } - } - } - - fn identical_loop_reason(&self) -> Option { - let limit = self.config.identical_action_limit; - if limit < 2 || self.history.len() < limit { - return None; - } - let recent = self.history.iter().rev().take(limit).collect::>(); - let first = recent.first()?; - recent - .iter() - .all(|action| same_action(first, action)) - .then(|| { - format!( - "repeated identical `{}` action without progress", - first.operation - ) - }) - } - - fn alternating_loop_reason(&self) -> Option { - let cycles = self.config.alternating_cycle_limit; - let needed = cycles.saturating_mul(2); - if cycles < 2 || self.history.len() < needed { - return None; - } - let recent = self.history.iter().rev().take(needed).collect::>(); - let a = recent.first()?; - let b = recent.get(1)?; - if same_action(a, b) { - return None; - } - recent - .iter() - .enumerate() - .all(|(index, action)| same_action(if index % 2 == 0 { a } else { b }, action)) - .then(|| { - format!( - "alternating `{}`/`{}` actions are cycling without progress", - a.operation, b.operation - ) - }) - } - - fn no_progress_reason(&self) -> Option { - let limit = self.config.no_progress_limit; - if limit == 0 || self.history.len() < limit { - return None; - } - self.history - .iter() - .rev() - .take(limit) - .all(|action| !action.changed_state) - .then(|| format!("{limit} consecutive actions produced no observable state change")) - } -} - -fn same_action(left: &ActionFingerprint, right: &ActionFingerprint) -> bool { - left.operation == right.operation - && left.target == right.target - && left.input_digest == right.input_digest - && left.outcome_digest == right.outcome_digest - && left.succeeded == right.succeeded - && left.changed_state == right.changed_state -} - -#[cfg(test)] -mod tests { - use super::*; - - fn action(operation: &str, changed_state: bool) -> ActionFingerprint { - ActionFingerprint { - operation: operation.to_string(), - target: Some("src/lib.rs".to_string()), - input_digest: operation.to_string(), - outcome_digest: Some("same".to_string()), - succeeded: false, - changed_state, - } - } - - #[test] - fn repeated_action_warns_then_stops() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 3, - alternating_cycle_limit: 99, - no_progress_limit: 99, - history_limit: 16, - }); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Warn { .. } - )); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Stop { .. } - )); - } - - #[test] - fn real_progress_clears_warning_latch() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 2, - alternating_cycle_limit: 99, - no_progress_limit: 99, - history_limit: 16, - }); - guard.observe(action("search", false)); - assert!(matches!( - guard.observe(action("search", false)), - GuardDecision::Warn { .. } - )); - assert_eq!(guard.observe(action("edit", true)), GuardDecision::Continue); - assert_eq!( - guard.observe(action("search", false)), - GuardDecision::Continue - ); - } - - #[test] - fn alternating_cycle_is_detected() { - let mut guard = ProgressGuard::new(ProgressGuardConfig { - identical_action_limit: 99, - alternating_cycle_limit: 3, - no_progress_limit: 99, - history_limit: 16, - }); - for operation in ["search", "read", "search", "read", "search"] { - assert_eq!( - guard.observe(action(operation, false)), - GuardDecision::Continue - ); - } - assert!(matches!( - guard.observe(action("read", false)), - GuardDecision::Warn { .. } - )); - } -} diff --git a/crates/tui/src/core/runtime_contract/retry.rs b/crates/tui/src/core/runtime_contract/retry.rs deleted file mode 100644 index 430ebd1880..0000000000 --- a/crates/tui/src/core/runtime_contract/retry.rs +++ /dev/null @@ -1,109 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OperationClass { - ModelRequest, - ModelStream, - ToolTransport, - ToolExecution, - ContextCompaction, - Verification, - ChildRun, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Idempotency { - ReadOnly, - IdempotentWrite, - NonIdempotentWrite, - Unknown, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RetryAttempt { - pub operation: OperationClass, - pub idempotency: Idempotency, - pub attempt: u32, - pub max_attempts: u32, - pub content_observed: bool, - pub side_effect_observed: bool, - pub canceled: bool, - pub reason: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RetryDecision { - Retry, - Stop { reason: String }, -} - -#[must_use] -pub fn decide_retry(attempt: &RetryAttempt) -> RetryDecision { - if attempt.canceled { - return RetryDecision::Stop { - reason: "operation was canceled".to_string(), - }; - } - if attempt.attempt >= attempt.max_attempts { - return RetryDecision::Stop { - reason: "retry budget exhausted".to_string(), - }; - } - if attempt.side_effect_observed - && matches!( - attempt.idempotency, - Idempotency::NonIdempotentWrite | Idempotency::Unknown - ) - { - return RetryDecision::Stop { - reason: "uncertain write side effect prevents an automatic retry".to_string(), - }; - } - if attempt.content_observed && attempt.operation == OperationClass::ModelStream { - return RetryDecision::Stop { - reason: "model stream already emitted content".to_string(), - }; - } - RetryDecision::Retry -} - -#[cfg(test)] -mod tests { - use super::*; - - fn attempt() -> RetryAttempt { - RetryAttempt { - operation: OperationClass::ModelStream, - idempotency: Idempotency::ReadOnly, - attempt: 0, - max_attempts: 2, - content_observed: false, - side_effect_observed: false, - canceled: false, - reason: "network".to_string(), - } - } - - #[test] - fn model_stream_retries_only_before_content() { - assert_eq!(decide_retry(&attempt()), RetryDecision::Retry); - let mut after_content = attempt(); - after_content.content_observed = true; - assert!(matches!( - decide_retry(&after_content), - RetryDecision::Stop { .. } - )); - } - - #[test] - fn uncertain_write_never_retries_after_side_effect() { - let mut write = attempt(); - write.operation = OperationClass::ToolExecution; - write.idempotency = Idempotency::Unknown; - write.side_effect_observed = true; - assert!(matches!(decide_retry(&write), RetryDecision::Stop { .. })); - } -} diff --git a/crates/tui/src/core/runtime_contract/terminal.rs b/crates/tui/src/core/runtime_contract/terminal.rs deleted file mode 100644 index b9b0d22aaf..0000000000 --- a/crates/tui/src/core/runtime_contract/terminal.rs +++ /dev/null @@ -1,156 +0,0 @@ -use std::collections::BTreeSet; -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -/// Process-continuity policy selected by a runtime profile. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TerminalProcessPolicy { - /// Every command starts from an explicit cwd/environment. - Isolated, - /// Commands share cwd/environment and may keep one live process. - Stateful, - /// Isolated by default; stateful sessions are explicitly requested. - Hybrid, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalSessionIdentity { - pub session_id: String, - pub host_fingerprint: String, - pub cwd: PathBuf, - /// Environment names only. Values are deliberately excluded from durable - /// metadata so secrets cannot leak into manifests. - pub environment_keys: BTreeSet, - pub shell: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TerminalSessionRecovery { - Reattached, - RestartRequired, - Stale, - Unsupported, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalBackendCapabilities { - pub interactive: bool, - pub background: bool, - pub tty: bool, - pub stateful: bool, - pub restart_reattach: bool, -} - -impl TerminalBackendCapabilities { - pub const LOCAL: Self = Self { - interactive: true, - background: true, - tty: true, - stateful: true, - restart_reattach: false, - }; -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TerminalRequest { - pub policy: TerminalProcessPolicy, - pub interactive: bool, - pub background: bool, - pub tty: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub session_id: Option, -} - -/// Fail before spawning when a backend cannot honor the declared terminal -/// contract. This avoids silently degrading interactive/background work. -pub fn validate_terminal_request( - request: &TerminalRequest, - backend: TerminalBackendCapabilities, -) -> Result<(), String> { - if request.interactive && !backend.interactive { - return Err("terminal backend does not support interactive input".to_string()); - } - if request.background && !backend.background { - return Err("terminal backend does not support background processes".to_string()); - } - if request.tty && !backend.tty { - return Err("terminal backend does not support a TTY".to_string()); - } - if matches!(request.policy, TerminalProcessPolicy::Stateful) && !backend.stateful { - return Err("terminal backend does not support stateful sessions".to_string()); - } - if request.session_id.is_some() && matches!(request.policy, TerminalProcessPolicy::Isolated) { - return Err("isolated terminal requests cannot name a shared session".to_string()); - } - Ok(()) -} - -impl TerminalSessionIdentity { - /// A persisted session is safe to reattach only when both the logical ID - /// and host fingerprint match. PIDs alone are intentionally insufficient. - #[must_use] - pub fn recovery_on_host( - &self, - session_id: &str, - host_fingerprint: &str, - backend: TerminalBackendCapabilities, - ) -> TerminalSessionRecovery { - if !backend.stateful { - return TerminalSessionRecovery::Unsupported; - } - if self.session_id != session_id || self.host_fingerprint != host_fingerprint { - return TerminalSessionRecovery::Stale; - } - if backend.restart_reattach { - TerminalSessionRecovery::Reattached - } else { - TerminalSessionRecovery::RestartRequired - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn external_backend_fails_before_unsupported_interactive_spawn() { - let request = TerminalRequest { - policy: TerminalProcessPolicy::Stateful, - interactive: true, - background: false, - tty: false, - session_id: Some("term-1".to_string()), - }; - let backend = TerminalBackendCapabilities { - interactive: false, - background: false, - tty: false, - stateful: false, - restart_reattach: false, - }; - assert!( - validate_terminal_request(&request, backend) - .unwrap_err() - .contains("interactive") - ); - } - - #[test] - fn host_fingerprint_prevents_pid_style_false_reattach() { - let identity = TerminalSessionIdentity { - session_id: "term-1".to_string(), - host_fingerprint: "host-a".to_string(), - cwd: PathBuf::from("/workspace"), - environment_keys: BTreeSet::new(), - shell: "zsh".to_string(), - }; - assert_eq!( - identity.recovery_on_host("term-1", "host-b", TerminalBackendCapabilities::LOCAL), - TerminalSessionRecovery::Stale - ); - } -} diff --git a/crates/tui/src/core/runtime_contract/work.rs b/crates/tui/src/core/runtime_contract/work.rs deleted file mode 100644 index 98c623e29e..0000000000 --- a/crates/tui/src/core/runtime_contract/work.rs +++ /dev/null @@ -1,85 +0,0 @@ -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkDomain { - /// Durable delegated/background unit. - Task, - /// Active checklist inside the current turn or lane. - Todo, - /// Strategy owned through Plan mode. - Plan, - /// Ordered durable execution. - Workflow, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkLifecycle { - Pending, - Active, - Waiting, - Completed, - Failed, - Canceled, -} - -/// Common envelope only. The payload remains owned by its domain so Tasks, -/// To-do, Plan, and Workflow cannot collapse into a generic tracker. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct WorkEventEnvelope { - pub schema_version: u32, - pub event_id: String, - pub domain: WorkDomain, - pub object_id: String, - pub lifecycle: WorkLifecycle, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub parent_id: Option, - pub payload: Value, -} - -impl WorkEventEnvelope { - pub fn validate(&self) -> Result<(), String> { - if self.schema_version == 0 { - return Err("work event schema version cannot be zero".to_string()); - } - if self.event_id.trim().is_empty() || self.object_id.trim().is_empty() { - return Err("work event and object IDs cannot be empty".to_string()); - } - if !self.payload.is_object() { - return Err("work event payload must remain a typed object".to_string()); - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn domains_share_an_envelope_without_losing_meaning() { - let task = WorkEventEnvelope { - schema_version: 1, - event_id: "event-task".to_string(), - domain: WorkDomain::Task, - object_id: "task-1".to_string(), - lifecycle: WorkLifecycle::Active, - parent_id: None, - payload: serde_json::json!({"worker_id": "worker-1"}), - }; - let todo = WorkEventEnvelope { - domain: WorkDomain::Todo, - event_id: "event-todo".to_string(), - object_id: "todo-1".to_string(), - payload: serde_json::json!({"checked": false}), - ..task.clone() - }; - assert_ne!(task.domain, todo.domain); - assert!(task.payload.get("worker_id").is_some()); - assert!(todo.payload.get("checked").is_some()); - assert!(task.validate().is_ok()); - assert!(todo.validate().is_ok()); - } -} diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 0b597f08cb..9b6cd9c524 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -84,6 +84,32 @@ pub fn probe_executable_with_flag(spec: &str, version_flag: &str) -> bool { matches!(cmd.status(), Ok(status) if status.success()) } +/// Probe a single executable and capture its version banner in one spawn. +/// +/// Same contract as [`probe_executable`] (success = exit 0), but returns the +/// trimmed stdout so callers that want the banner don't need a second process +/// launch. Returns `None` when the probe fails or stdout is not valid UTF-8. +pub fn probe_executable_capturing(spec: &str, version_flag: &str) -> Option { + let mut parts = spec.split_whitespace(); + let program = parts.next()?; + let mut cmd = Command::new(program); + crate::utils::suppress_console_window(&mut cmd); + for arg in parts { + cmd.arg(arg); + } + cmd.arg(version_flag); + cmd.stderr(std::process::Stdio::null()); + + let output = cmd.output().ok()?; + if !output.status.success() { + return None; + } + String::from_utf8(output.stdout) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn executable_path_candidates(program: &str) -> Vec { let program_path = Path::new(program); if program_path.components().count() > 1 { @@ -463,9 +489,14 @@ impl ExternalTool for RustC { static CACHE: OnceLock> = OnceLock::new(); CACHE .get_or_init(|| { + // Probe with capture so the `--version` banner observed during + // resolution is reused by [`rustc_version_banner`] instead of + // paying a second rustc process launch (each launch loads + // libLLVM, which dominated diagnostic-command init profiles). for candidate in Self::candidates() { - if probe_executable(candidate) { + if let Some(banner) = probe_executable_capturing(candidate, "--version") { tracing::info!(target: "tool_dependencies", "Resolved rustc binary"); + let _ = RUSTC_VERSION_BANNER.set(Some(banner)); return Some((*candidate).to_string()); } } @@ -475,6 +506,23 @@ impl ExternalTool for RustC { } } +/// Captured `--version` banner from the [`RustC`] resolution probe. +/// +/// `None` until `RustC::resolve()`/`available()`/`command()` first runs, or +/// when rustc is absent/failing. Reading this after an `available()` check +/// yields the same string the tool would print, without a second process. +static RUSTC_VERSION_BANNER: OnceLock> = OnceLock::new(); + +/// The rustc `--version` banner, if rustc resolved successfully. +/// +/// Populated as a side effect of resolving [`RustC`]; this reads no fresh +/// process state. Callers wanting the value should touch `RustC::available()` +/// first (as the diagnostics path does). +#[must_use] +pub fn rustc_version_banner() -> Option { + RUSTC_VERSION_BANNER.get().cloned().flatten() +} + /// Rust build tool — used by the `run_tests` tool. pub struct Cargo; diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 169dd217b0..259e4fdeae 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1731,7 +1731,7 @@ fn run_async_main( plugin_discovery: Arc, plugin_registry: Arc, ) -> Result<()> { - build_runtime()?.block_on(run_async_main_inner( + build_runtime(command.as_ref())?.block_on(run_async_main_inner( cli, command, plugin_discovery, @@ -1755,16 +1755,72 @@ fn run_async_main( /// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the /// process dies with 134 mid-dispatch. /// +/// Build the runtime that owns every async task in this binary. +/// +/// `command` selects the worker-count policy: read-only diagnostic commands +/// run on a small fixed pool instead of tokio's one-worker-per-CPU default +/// (see [`diagnostic_worker_count`]). Interactive sessions and servers keep +/// the default sizing unchanged. +/// +/// `#[tokio::main]` used to expand here, which left every worker thread on +/// tokio's 2 MiB default while only the `codewhale-main` owner thread above +/// received `CODEWHALE_MAIN_STACK_BYTES`. The engine does not run on that owner +/// thread — `core::engine::spawn_engine` hands `Engine::run` to +/// `utils::spawn_supervised`, a bare `tokio::spawn` — so the explicit stack +/// never applied where the depth actually is. +/// +/// A debug-build `agent` dispatch (turn_loop -> FuturesUnordered -> +/// execute_full_with_context -> AgentTool::execute -> spawn_subagent_from_input) +/// measured a stack high-water mark between 2.25 and 2.5 MiB and aborted the +/// whole process on the guard page. A Rust stack overflow is not a panic: it +/// raises SIGABRT, so `spawn_supervised`'s `catch_unwind` cannot see it and the +/// process dies with 134 mid-dispatch. +/// /// This is behavior-identical to the old `#[tokio::main]` expansion apart from /// the stack size, and it makes the knob greppable. -pub(crate) fn build_runtime() -> Result { - tokio::runtime::Builder::new_multi_thread() - .enable_all() - .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES) +pub(crate) fn build_runtime(command: Option<&Commands>) -> Result { + let mut builder = tokio_runtime_builder(); + if let Some(workers) = diagnostic_worker_count(command) { + builder.worker_threads(workers); + } + builder .build() .context("Failed to build the Codewhale Tokio runtime") } +/// Number of async workers to request from tokio. +/// +/// Unset means tokio's default: one worker per CPU. That default is right for +/// interactive sessions and long-running servers, but short-lived offline +/// commands gain nothing from a full-CPU pool — they pay thread spawn, stack +/// reservation, and teardown futex traffic for capacity they never use (perf +/// attribution: pthread_create under `Builder::build` dominates init samples). +const DIAGNOSTIC_WORKER_CAP: usize = 2; + +fn diagnostic_worker_count(command: Option<&Commands>) -> Option { + let capped = match command { + // Read-only diagnostic surfaces (doctor family). + Some( + Commands::Doctor(_) + | Commands::Eval(_) + | Commands::SessionDiagnostics(_) + | Commands::Sessions { .. }, + ) => true, + // Only the read-only status report; mutating setup keeps defaults. + Some(Commands::Setup(args)) => args.status, + _ => false, + }; + capped.then_some(DIAGNOSTIC_WORKER_CAP) +} + +fn tokio_runtime_builder() -> tokio::runtime::Builder { + let mut builder = tokio::runtime::Builder::new_multi_thread(); + builder + .enable_all() + .thread_stack_size(CODEWHALE_MAIN_STACK_BYTES); + builder +} + /// Which product surface this process is serving. /// /// A function of the parsed subcommand, never of the executable: this one @@ -7634,15 +7690,13 @@ async fn test_api_connectivity(config: &Config) -> Result<()> { } fn rustc_version() -> String { - let Some(mut cmd) = crate::dependencies::RustC::command() else { + // `RustC::available()` resolves the tool once, capturing the `--version` + // banner as a side effect of the probe; reuse it instead of launching a + // second rustc process (each launch loads libLLVM). + if !crate::dependencies::RustC::available() { return "unknown".to_string(); - }; - let Ok(output) = cmd.arg("--version").output() else { - return "unknown".to_string(); - }; - String::from_utf8(output.stdout) - .map(|s| s.trim().to_string()) - .unwrap_or_else(|_| "unknown".to_string()) + } + crate::dependencies::rustc_version_banner().unwrap_or_else(|| "unknown".to_string()) } /// List saved sessions diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index e17f70e16e..9ea94f7de9 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -3665,6 +3665,27 @@ pub fn mcp_recovery_kind( McpRecoveryKind::Reconnect } +/// Session-start / manager line: name the server, name the failure, one command. +/// Matches the Codex shape (`The X MCP server requires OAuth reauthentication. Run …` +/// / `MCP startup incomplete (failed: X)`). +#[must_use] +pub fn mcp_startup_warning(name: &str, kind: McpRecoveryKind, failed: bool) -> String { + let command = kind.slash_command(name); + match kind { + McpRecoveryKind::Reauth => { + format!("The {name} MCP server requires OAuth reauthentication. Run `{command}`.") + } + McpRecoveryKind::Enable => { + format!("The {name} MCP server is disabled. Run `{command}`.") + } + _ if failed => format!("MCP startup incomplete (failed: {name}). Run `{command}`."), + McpRecoveryKind::Connect => { + format!("The {name} MCP server is not connected yet. Run `{command}`.") + } + _ => format!("The {name} MCP server needs attention. Run `{command}`."), + } +} + pub fn load_config(path: &Path) -> Result { validate_mcp_config_path(path)?; let Some(contents) = read_mcp_config_file(path)? else { diff --git a/crates/tui/src/mcp/tests.rs b/crates/tui/src/mcp/tests.rs index 2e7d31f25e..5a7b7fba6d 100644 --- a/crates/tui/src/mcp/tests.rs +++ b/crates/tui/src/mcp/tests.rs @@ -5756,6 +5756,14 @@ fn mcp_recovery_kind_names_real_login_and_reload_commands() { McpRecoveryKind::Reauth.slash_command("github"), "/mcp login github" ); + assert_eq!( + crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Reauth, true), + "The cloudflare-api MCP server requires OAuth reauthentication. Run `/mcp login cloudflare-api`." + ); + assert_eq!( + crate::mcp::mcp_startup_warning("cloudflare-api", McpRecoveryKind::Diagnose, true), + "MCP startup incomplete (failed: cloudflare-api). Run `/mcp validate`." + ); assert_eq!( McpRecoveryKind::Connect.slash_command("github"), "/mcp reload" diff --git a/crates/tui/src/model_registry.rs b/crates/tui/src/model_registry.rs index 9af53f08d0..4e3556a40a 100644 --- a/crates/tui/src/model_registry.rs +++ b/crates/tui/src/model_registry.rs @@ -164,9 +164,11 @@ const SEED_MODEL_IDS: &[(&str, ModelProvider)] = &[ ("z-ai/glm-5.1", ModelProvider::Zai), ("z-ai/glm-5.2", ModelProvider::Zai), ("z-ai/glm-5.3", ModelProvider::Zai), + ("z-ai/glm-5.3-flash", ModelProvider::Zai), ("glm-5.1", ModelProvider::Zai), ("glm-5.2", ModelProvider::Zai), ("glm-5.3", ModelProvider::Zai), + ("glm-5.3-flash", ModelProvider::Zai), // --- MiniMax (config DEFAULT_MINIMAX_MODEL) --- ("minimax/minimax-m3", ModelProvider::Minimax), ("minimax-m3", ModelProvider::Minimax), @@ -312,6 +314,7 @@ mod tests { ("z-ai/glm-5.1", Some(202_752)), ("z-ai/glm-5.2", Some(1_000_000)), ("z-ai/glm-5.3", Some(1_000_000)), + ("z-ai/glm-5.3-flash", Some(1_000_000)), ("minimax/minimax-m3", Some(1_000_000)), ("minimax-m2.7", Some(204_800)), ("qwen/qwen3.6-flash", Some(1_000_000)), diff --git a/crates/tui/src/model_routing.rs b/crates/tui/src/model_routing.rs index 967ce65e2f..45a78dd356 100644 --- a/crates/tui/src/model_routing.rs +++ b/crates/tui/src/model_routing.rs @@ -141,13 +141,12 @@ pub(crate) fn provider_router_candidates( let normalized = crate::config::normalize_model_name_for_provider(provider, current_model) .unwrap_or_else(|| current_model.to_string()); return RouterCandidates { - // GLM-5.3 (the default) and GLM-5.2 route faster/explore children - // to GLM-5-Turbo, the same-family fast sibling. GLM-5.1 and - // GLM-5-Turbo itself have no cheaper tier and keep children on the - // parent model. - cheap: if normalized == crate::config::ZAI_GLM_5_2_MODEL - || normalized == crate::config::ZAI_GLM_5_3_MODEL - { + // GLM-5.3 routes faster/explore children to GLM-5.3-Flash. + // GLM-5.2 still uses GLM-5-Turbo. Flash, Turbo, and 5.1 have no + // cheaper tier and keep children on the parent model. + cheap: if normalized == crate::config::ZAI_GLM_5_3_MODEL { + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL.to_string()) + } else if normalized == crate::config::ZAI_GLM_5_2_MODEL { Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()) } else { None @@ -164,16 +163,17 @@ pub(crate) fn provider_router_candidates( crate::config::OPENROUTER_GLM_5_1_MODEL | crate::config::OPENROUTER_GLM_5_2_MODEL | crate::config::OPENROUTER_GLM_5_3_MODEL + | crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL | crate::config::OPENROUTER_GLM_5_TURBO_MODEL ) { return RouterCandidates { - // z-ai/glm-5.2 and z-ai/glm-5.3 route faster children to - // z-ai/glm-5-turbo; the 5.1 and turbo ids have no cheaper tier and - // keep children on parent. - cheap: if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL - || normalized == crate::config::OPENROUTER_GLM_5_3_MODEL - { + // z-ai/glm-5.3 routes faster children to z-ai/glm-5.3-flash; + // z-ai/glm-5.2 still uses z-ai/glm-5-turbo. Flash, turbo, and 5.1 + // have no cheaper tier and keep children on parent. + cheap: if normalized == crate::config::OPENROUTER_GLM_5_3_MODEL { + Some(crate::config::OPENROUTER_GLM_5_3_FLASH_MODEL.to_string()) + } else if normalized == crate::config::OPENROUTER_GLM_5_2_MODEL { Some(crate::config::OPENROUTER_GLM_5_TURBO_MODEL.to_string()) } else { None @@ -1345,7 +1345,7 @@ mod tests { assert!(!balanced.contains("Cost-saving mode is ON")); assert!( cost_saving.contains( - "For the active provider `zai`, `GLM-5-Turbo` is the fast tier and `GLM-5.3` is the strong tier" + "For the active provider `zai`, `GLM-5.3-Flash` is the fast tier and `GLM-5.3` is the strong tier" ), "cost-saving classifier policy must name the provider-safe pair: {cost_saving}" ); @@ -1605,7 +1605,7 @@ mod tests { .expect("inventory route should resolve with authenticated active provider"); assert_eq!(route.provider, ApiProvider::Zai); - assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL); + assert_eq!(route.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); assert_eq!(route.source, AutoRouteSource::Heuristic); let receipt = route.receipt.expect("Auto route receipt"); assert_eq!(receipt.tier, AutoRouteTier::Fast); @@ -1618,7 +1618,7 @@ mod tests { assert_eq!(receipt.pair.strong, crate::config::DEFAULT_ZAI_MODEL); assert_eq!( receipt.pair.fast.as_deref(), - Some(crate::config::ZAI_GLM_5_TURBO_MODEL) + Some(crate::config::ZAI_GLM_5_3_FLASH_MODEL) ); } @@ -1753,7 +1753,7 @@ mod tests { .await .expect("fast-tier route"); assert_eq!(fast.provider, ApiProvider::Zai); - assert_eq!(fast.model, crate::config::ZAI_GLM_5_TURBO_MODEL); + assert_eq!(fast.model, crate::config::ZAI_GLM_5_3_FLASH_MODEL); assert_eq!( fast.receipt.expect("fast receipt").tier, AutoRouteTier::Fast @@ -1939,7 +1939,7 @@ mod tests { assert_eq!(cost_saving_route.provider, ApiProvider::Zai); assert_eq!( cost_saving_route.model, - crate::config::ZAI_GLM_5_TURBO_MODEL + crate::config::ZAI_GLM_5_3_FLASH_MODEL ); assert_eq!(cost_saving_route.source, AutoRouteSource::Heuristic); assert_eq!( @@ -2123,14 +2123,21 @@ mod tests { assert_eq!(openrouter_glm.big, "z-ai/glm-5.2"); assert_eq!(openrouter_glm.cheap.as_deref(), Some("z-ai/glm-5-turbo")); - // GLM-5.3 inherits the same fast sibling without displacing GLM-5.2's. + // GLM-5.3's fast sibling is Flash; GLM-5.2 still uses Turbo. let zai_53 = provider_router_candidates(ApiProvider::Zai, "GLM-5.3"); assert_eq!(zai_53.big, "GLM-5.3"); - assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5-Turbo")); + assert_eq!(zai_53.cheap.as_deref(), Some("GLM-5.3-Flash")); let openrouter_glm_53 = provider_router_candidates(ApiProvider::Openrouter, "z-ai/glm-5.3"); assert_eq!(openrouter_glm_53.big, "z-ai/glm-5.3"); - assert_eq!(openrouter_glm_53.cheap.as_deref(), Some("z-ai/glm-5-turbo")); + assert_eq!( + openrouter_glm_53.cheap.as_deref(), + Some("z-ai/glm-5.3-flash") + ); + + let zai_flash = provider_router_candidates(ApiProvider::Zai, "GLM-5.3-Flash"); + assert_eq!(zai_flash.big, "GLM-5.3-Flash"); + assert_eq!(zai_flash.cheap, None); // GLM-5.1 has no cheaper tier; faster children stay on the parent. let zai_51 = provider_router_candidates(ApiProvider::Zai, "GLM-5.1"); diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 5fb6364056..7aaaff16a2 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -258,7 +258,9 @@ fn known_context_window_for_model(model_lower: &str) -> Option { "z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752), // GLM-5.3 limits are inherited from GLM-5.2 pending official Z.ai // release metadata (see `INHERITED FROM glm-5.2` in config/models.rs). - "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" => Some(1_000_000), + // GLM-5.3-Flash is the published 1M multimodal sibling (2026-08-26). + "z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" | "z-ai/glm-5.3-flash" + | "glm-5.3-flash" => Some(1_000_000), "minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.8-flash" | "qwen/qwen3.6-flash" | "qwen/qwen3.6-plus" => Some(1_000_000), // Alibaba Cloud Model Studio (Token Plan console + curated catalog, @@ -399,8 +401,9 @@ pub fn max_output_tokens_for_model(model: &str) -> Option { Some(131_072) } "qwen3.7-plus" | "qwen3.7-max" | "qwen3.6-flash" => Some(65_536), - "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5-turbo" | "glm-5.1" - | "glm-5.2" | "glm-5.3" | "glm-5-turbo" => Some(131_072), + "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5.3-flash" + | "z-ai/glm-5-turbo" | "glm-5.1" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash" + | "glm-5-turbo" => Some(131_072), "xiaomi/mimo-v2.5-pro" | "xiaomi/mimo-v2.5" | "mimo-v2.5-pro" @@ -522,10 +525,12 @@ pub fn model_supports_reasoning(model: &str) -> bool { | "z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" + | "z-ai/glm-5.3-flash" | "z-ai/glm-5-turbo" | "glm-5.1" | "glm-5.2" | "glm-5.3" + | "glm-5.3-flash" | "glm-5-turbo" | "grok-4.6" | "grok-4.5" @@ -920,6 +925,7 @@ mod tests { ("z-ai/glm-5.1", 202_752), ("z-ai/glm-5.2", 1_000_000), ("z-ai/glm-5.3", 1_000_000), + ("z-ai/glm-5.3-flash", 1_000_000), ] { assert_eq!(context_window_for_model(model), Some(expected_window)); assert!(model_supports_reasoning(model)); @@ -1322,6 +1328,7 @@ mod tests { ("glm-5.2", 1_000_000), // Inherited from glm-5.2 pending official Z.ai release metadata. ("glm-5.3", 1_000_000), + ("glm-5.3-flash", 1_000_000), ("glm-5-turbo", 202_752), ] { assert_eq!(context_window_for_model(model), Some(expected_window)); @@ -1347,6 +1354,7 @@ mod tests { assert_eq!(max_output_tokens_for_model("glm-5.1"), Some(131_072)); assert_eq!(max_output_tokens_for_model("glm-5.2"), Some(131_072)); assert_eq!(max_output_tokens_for_model("glm-5.3"), Some(131_072)); + assert_eq!(max_output_tokens_for_model("glm-5.3-flash"), Some(131_072)); } #[test] diff --git a/crates/tui/src/models_dev_live.rs b/crates/tui/src/models_dev_live.rs index 174d8d0e09..52af6f348d 100644 --- a/crates/tui/src/models_dev_live.rs +++ b/crates/tui/src/models_dev_live.rs @@ -93,6 +93,23 @@ struct PersistedModelsDevCache { body: String, } +/// Metadata header for the v2 cache format. +/// +/// v1 serialized the whole cache as one JSON envelope with the catalog body +/// escaped inside it, so loading parsed ~5MB twice (envelope, then body) plus +/// a full-body copy on every interactive boot. v2 stores the metadata as a +/// single JSON header line followed by the raw catalog body bytes, so boot +/// performs exactly one catalog parse and zero body copies. +const CACHE_SCHEMA_VERSION_V2: u32 = 2; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PersistedModelsDevCacheV2 { + schema_version: u32, + fetched_at: u64, + source_fingerprint: String, + source_label: String, +} + /// Why a Models.dev refresh did not publish new rows. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ModelsDevRefreshError { @@ -192,26 +209,27 @@ pub fn maybe_load_persisted_cache() { let Some(path) = cache_path() else { return; }; - if let Some(cache) = load_cache_file(&path) { - let age = now_unix().saturating_sub(cache.fetched_at); - let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { - ModelsDevFreshness::Stale - } else { - ModelsDevFreshness::Live - }; - if let Err(err) = publish_from_body( - &cache.body, - &cache.source_fingerprint, - cache.fetched_at, - &cache.source_label, - freshness, - ) { - tracing::debug!( - target: "models_dev_live", - error = %err, - "persisted Models.dev cache failed to publish; keeping bundled" - ); - } + let Some(cache) = load_cache_file(&path) else { + return; + }; + let age = now_unix().saturating_sub(cache.fetched_at); + let freshness = if age > DEFAULT_MODELS_DEV_TTL_SECS { + ModelsDevFreshness::Stale + } else { + ModelsDevFreshness::Live + }; + if let Err(err) = publish_from_body( + &cache.body, + &cache.source_fingerprint, + cache.fetched_at, + cache.source_label.as_str(), + freshness, + ) { + tracing::debug!( + target: "models_dev_live", + error = %err, + "persisted Models.dev cache failed to publish; keeping bundled" + ); } } @@ -401,8 +419,32 @@ fn mark_failed(err: ModelsDevRefreshError) { set_status(next); } +/// Load the on-disk Models.dev cache. +/// +/// Reads v2 (single-parse: one header line + raw body) and v1 (JSON envelope +/// with an escaped body) formats. Returns metadata and the *unescaped* body +/// without copying it in the v2 path. fn load_cache_file(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; + // v2: single-line JSON header terminated by a newline, then the verbatim + // catalog body. One small parse, zero body copies. + if bytes.first() == Some(&b'{') && bytes.contains(&b'\n') { + let split = bytes.iter().position(|b| *b == b'\n')?; + if let Ok(header) = serde_json::from_slice::(&bytes[..split]) + && header.schema_version == CACHE_SCHEMA_VERSION_V2 + && !bytes[split + 1..].is_empty() + { + let body = String::from_utf8(bytes[split + 1..].to_vec()).ok()?; + return Some(PersistedModelsDevCache { + schema_version: CACHE_SCHEMA_VERSION, + fetched_at: header.fetched_at, + source_fingerprint: header.source_fingerprint, + source_label: header.source_label, + body, + }); + } + } + // v1 fallback: whole-file JSON envelope with the body escaped inside. let cache: PersistedModelsDevCache = serde_json::from_slice(&bytes).ok()?; if cache.schema_version != CACHE_SCHEMA_VERSION { return None; @@ -417,9 +459,19 @@ fn save_cache_file( path: &Path, cache: &PersistedModelsDevCache, ) -> Result<(), ModelsDevRefreshError> { - let bytes = - serde_json::to_vec(cache).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; - atomic_write(path, &bytes).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) + // Write the single-parse format so the next boot parses the catalog once. + let header = PersistedModelsDevCacheV2 { + schema_version: CACHE_SCHEMA_VERSION_V2, + fetched_at: cache.fetched_at, + source_fingerprint: cache.source_fingerprint.clone(), + source_label: cache.source_label.clone(), + }; + let mut header_line = + serde_json::to_vec(&header).map_err(|err| ModelsDevRefreshError::Io(err.to_string()))?; + header_line.push(b'\n'); + let mut payload = header_line; + payload.extend_from_slice(cache.body.as_bytes()); + atomic_write(path, &payload).map_err(|err| ModelsDevRefreshError::Io(err.to_string())) } /// Compile helper exposed for unit tests: body → live offerings with normalized diff --git a/crates/tui/src/pricing.rs b/crates/tui/src/pricing.rs index 13a12f393b..b13d696c99 100644 --- a/crates/tui/src/pricing.rs +++ b/crates/tui/src/pricing.rs @@ -625,6 +625,9 @@ fn known_pricing_for_model(model_lower: &str) -> Option { // Z.ai GLM-5.2 cache-read rate per https://docs.z.ai/guides/overview/pricing // (cache storage limited-time free). "z-ai/glm-5.2" | "glm-5.2" => Some(usd_only_pricing(0.26, 1.40, 4.40)), + // GLM-5.3-Flash list rates (2026-08-26). Promo 50% off until + // 2026-09-09 UTC+8 is not the durable row. + "z-ai/glm-5.3-flash" | "glm-5.3-flash" => Some(usd_only_pricing(0.03, 0.15, 0.50)), // Moonshot K2.7 Code cache-read rate per // https://platform.kimi.ai/docs/pricing/chat-k27-code "moonshotai/kimi-k2.7-code" | "kimi-k2.7-code" => Some(usd_only_pricing(0.19, 0.95, 4.00)), @@ -1835,7 +1838,11 @@ fn provider_owned_hand_pricing_at( // owns a *hand-written price row* for the model, and no GLM-5.3 rate // has been published. An absent price is honest; an owned-but-empty // row is not. See `glm_5_3_has_no_hardcoded_price` below. - ApiProvider::Zai => matches!(model_lower.as_str(), "glm-5.1" | "glm-5.2" | "glm-5-turbo"), + // GLM-5.3-Flash *does* have a published USD list (2026-08-26). + ApiProvider::Zai => matches!( + model_lower.as_str(), + "glm-5.1" | "glm-5.2" | "glm-5.3-flash" | "glm-5-turbo" + ), // `k3` (Kimi Code membership) is deliberately absent: it is quota // billed and must never inherit the direct-platform kimi-k3 rate. ApiProvider::Moonshot => matches!( @@ -3298,6 +3305,8 @@ mod tests { ("z-ai/glm-5.1", 0.26, 1.40, 4.40), ("glm-5.2", 0.26, 1.40, 4.40), ("z-ai/glm-5.2", 0.26, 1.40, 4.40), + ("glm-5.3-flash", 0.03, 0.15, 0.50), + ("z-ai/glm-5.3-flash", 0.03, 0.15, 0.50), ("glm-5-turbo", 0.24, 1.20, 4.00), ("z-ai/glm-5-turbo", 0.24, 1.20, 4.00), ("qwen/qwen3.6-plus", 0.325, 0.325, 1.95), diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d585e78cb6..9f85872f6d 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -128,11 +128,20 @@ fn bundled_snapshot() -> &'static CatalogSnapshot { /// deliberately downstream of every publisher so stale cached rows cannot /// bypass the client-side live-fetch filter. fn apply_provider_model_cutlines(mut snapshot: CatalogSnapshot) -> CatalogSnapshot { + // `ApiProvider::parse` scans every provider and alias list per call; the + // distinct provider strings in a catalog are few, so resolve each distinct + // string once instead of once per offering (boot-path profiles showed + // this loop as the largest post-parse compute block). + let mut resolved: std::collections::HashMap> = + std::collections::HashMap::new(); snapshot.offerings = snapshot .offerings .into_iter() .filter_map(|mut offering| { - if ApiProvider::parse(&offering.provider) == Some(ApiProvider::OpencodeGo) { + let parsed = *resolved + .entry(offering.provider.clone()) + .or_insert_with(|| ApiProvider::parse(&offering.provider)); + if parsed == Some(ApiProvider::OpencodeGo) { let canonical = opencode_go_chat_model_id(&offering.wire_model_id)?; offering.provider = ApiProvider::OpencodeGo.as_str().to_string(); offering.wire_model_id = canonical.to_string(); diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 0f82c6f21d..f658dd6912 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -4137,6 +4137,11 @@ async fn execute_foreground_via_background( } let deadline = timeout_ms.map(|timeout| Instant::now() + Duration::from_millis(timeout)); + // Adaptive poll cadence: fast commands (the common case — grep, wc, echo) + // finish in single-digit milliseconds, and a fixed 100ms tick made every + // foreground call pay that full quantum before completion was noticed. + // Start fine-grained and back off to the 100ms cap for long-running work. + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; loop { if context .cancel_token @@ -4192,12 +4197,22 @@ async fn execute_foreground_via_background( return Ok(result); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); } } const BASH_MAX_TIMEOUT_MS: u64 = i32::MAX as u64; +/// Initial cadence for foreground-via-background completion polling. Fast +/// commands dominate real agent traffic; detection latency on `true`-class +/// commands drops from ~100ms to ~10ms, while long commands reach the +/// 100ms cap within one doubling step. +const FOREGROUND_POLL_INITIAL_MS: u64 = 10; +/// Poll-cadence ceiling; matches the previous fixed tick so long-running +/// command overhead is unchanged. +const FOREGROUND_POLL_MAX_MS: u64 = 100; + /// Default foreground lifetime for a contract-`bash` `action=run` that names /// no `timeout_ms`. Matches the value the tool's own input schema advertises; /// before this existed the omitted case fell through to @@ -5488,6 +5503,7 @@ impl BashTool { .collect() }; + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let statuses = loop { let current = { let mut manager = context @@ -5521,7 +5537,8 @@ impl BashTool { timed_out = true; break current; } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; let running_after = statuses @@ -5934,6 +5951,7 @@ async fn wait_for_shell_delta_cancellable( let mut stdout_accum = String::new(); let mut stderr_accum = String::new(); + let mut poll_tick_ms: u64 = FOREGROUND_POLL_INITIAL_MS; let (command, result, stdout_total_len, stderr_total_len) = loop { if context .cancel_token @@ -5981,7 +5999,8 @@ async fn wait_for_shell_delta_cancellable( break (command, delta.result, stdout_total_len, stderr_total_len); } - tokio::time::sleep(Duration::from_millis(100)).await; + tokio::time::sleep(Duration::from_millis(poll_tick_ms)).await; + poll_tick_ms = (poll_tick_ms * 2).min(FOREGROUND_POLL_MAX_MS); }; Ok(( diff --git a/crates/tui/src/tui/ambient_life.rs b/crates/tui/src/tui/ambient_life.rs index d4662fe1b1..a22e754838 100644 --- a/crates/tui/src/tui/ambient_life.rs +++ b/crates/tui/src/tui/ambient_life.rs @@ -191,7 +191,7 @@ pub struct AmbientFrameStats { /// [`AmbientFrameStats::marks_built`], not a runtime clamp: the population is /// bounded by construction, and this constant is what fails the build if a /// future change makes it unbounded. -#[allow(dead_code)] +#[cfg(test)] pub const MAX_FRAME_MARKS: u32 = 24; /// Optional pointer reaction for fish dart / bubble rise. diff --git a/crates/tui/src/tui/file_tree.rs b/crates/tui/src/tui/file_tree.rs index 9fd699ee7d..e92d2e00c9 100644 --- a/crates/tui/src/tui/file_tree.rs +++ b/crates/tui/src/tui/file_tree.rs @@ -348,7 +348,7 @@ impl FileTreeState { } /// Adjust scroll for a given visible height. - #[allow(dead_code)] + #[cfg(test)] pub fn adjust_scroll(&mut self, visible: usize) { if self.cursor < self.scroll_offset { self.scroll_offset = self.cursor; @@ -663,6 +663,19 @@ mod tests { ); } + #[test] + fn adjust_scroll_keeps_the_cursor_inside_the_visible_window() { + let ws = fixture_workspace(); + let mut state = FileTreeState::new(ws.path()); + state.cursor = state.entries.len().saturating_sub(1); + state.adjust_scroll(3); + assert!(state.cursor < state.scroll_offset + 3); + + state.cursor = 0; + state.adjust_scroll(3); + assert_eq!(state.scroll_offset, 0); + } + #[test] fn stale_expand_results_are_discarded() { let ws = fixture_workspace(); diff --git a/crates/tui/src/tui/focus_texture.rs b/crates/tui/src/tui/focus_texture.rs index fc26f2a9bc..ebaa4cbb98 100644 --- a/crates/tui/src/tui/focus_texture.rs +++ b/crates/tui/src/tui/focus_texture.rs @@ -114,7 +114,7 @@ pub struct FocusTextureStats { impl FocusTextureStats { /// The accounting identity asserted by the unit tests. This type's only /// consumer is the test gate below, hence the `dead_code` allowance. - #[allow(dead_code)] + #[cfg(test)] #[must_use] pub fn accounted(&self) -> bool { self.cells_examined diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 2f77df1c9e..5f30bcaa18 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -73,10 +73,11 @@ pub(crate) fn friendly_subagent_progress(app: &App, id: &str, status: &str) -> S if let Some(existing) = app.agent_progress.get(id) && !is_noisy_subagent_progress(existing) && existing != "working" + && existing != "in the current" { return existing.clone(); } - "working".to_string() + "in the current".to_string() } pub(crate) fn one_line_summary(text: &str, max_width: usize) -> String { diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 4077c2149b..09a5e6dbd0 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -1167,7 +1167,7 @@ pub struct ExploringCell { impl ExploringCell { /// Render the exploring cell into lines. - #[allow(dead_code)] + #[cfg(test)] pub fn lines_with_motion(&self, width: u16, low_motion: bool) -> Vec> { self.lines_with_motion_and_locale(width, low_motion, Locale::En) } @@ -1691,7 +1691,7 @@ impl GenericToolCell { /// `mode` controls multi-line output handling: `Live` caps at /// `TOOL_OUTPUT_LINE_LIMIT` rows with a "+N more" affordance; /// `Transcript` emits the full output. - #[allow(dead_code)] + #[cfg(test)] pub fn lines_with_mode( &self, width: u16, diff --git a/crates/tui/src/tui/history/latex_render.rs b/crates/tui/src/tui/history/latex_render.rs index 242b179743..7394b9f36d 100644 --- a/crates/tui/src/tui/history/latex_render.rs +++ b/crates/tui/src/tui/history/latex_render.rs @@ -128,7 +128,29 @@ fn render_math_segment(text: &str) -> String { } /// Replace math delimiters with plain Unicode while preserving Markdown code. -pub fn render_latex_in_text(text: &str) -> String { +/// +/// Fast path (#perf-r5): the overwhelming majority of streamed content +/// contains no math delimiters at all. A single byte scan for the three +/// opening delimiters (`$`, `\(`, `\[`) decides between borrowing the input +/// untouched and running the full transform, so the per-chunk streaming +/// render avoids allocating a full-content copy on every update when no +/// math is present. +pub fn render_latex_in_text(text: &str) -> std::borrow::Cow<'_, str> { + // Math can only start at '$' (incl. '$$') or the two-byte '\(' and '\['. + // Scanning bytes directly avoids a regex; any hit falls back to the + // full transform below, which re-verifies delimiters precisely. + let has_delim = text + .as_bytes() + .iter() + .enumerate() + .any(|(idx, &byte)| match byte { + b'$' => true, + b'\\' => matches!(text.as_bytes().get(idx + 1), Some(b'(') | Some(b'[')), + _ => false, + }); + if !has_delim { + return std::borrow::Cow::Borrowed(text); + } let mut result = String::with_capacity(text.len()); let mut cursor = 0; @@ -156,7 +178,7 @@ pub fn render_latex_in_text(text: &str) -> String { } } - result + std::borrow::Cow::Owned(result) } // --- Environment rendering --- @@ -1653,6 +1675,7 @@ mod tests { fn test_inline_dollar() { let r = render_latex_in_text(r"text $x^2$ more"); assert_eq!(r, "text x\u{00b2} more"); + assert!(matches!(r, std::borrow::Cow::Owned(_))); } #[test] fn test_display_bracket() { @@ -1660,6 +1683,16 @@ mod tests { assert_eq!(r, "text x\u{00b2} more"); } #[test] + fn no_math_is_borrowed_without_copy() { + let r = render_latex_in_text("plain prose with `code` but no math at all"); + assert!(matches!(r, std::borrow::Cow::Borrowed(_))); + assert_eq!(&*r, "plain prose with `code` but no math at all"); + // The '$' fast path must not miss \( + let p = render_latex_in_text("parens \\(x^2\\) inline"); + assert!(matches!(p, std::borrow::Cow::Owned(_))); + assert_eq!(&*p, "parens x\u{00b2} inline"); + } + #[test] fn preserves_currency() { assert_eq!(render_latex_in_text("cost $5 and $10"), "cost $5 and $10"); } diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index a85ca4d3f3..0e93dc4800 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -1175,7 +1175,7 @@ fn a_card_verb_agrees_with_its_own_label_in_every_locale() { }], }; - let header_en = line_text(&cell.lines_with_motion_and_locale(80, true, Locale::En)[0]); + let header_en = line_text(&cell.lines_with_motion(80, true)[0]); assert!( header_en.contains(expected_en), "{label:?} should read {expected_en:?}: {header_en:?}" diff --git a/crates/tui/src/tui/history/thinking.rs b/crates/tui/src/tui/history/thinking.rs index ffffaba844..ccd782d17c 100644 --- a/crates/tui/src/tui/history/thinking.rs +++ b/crates/tui/src/tui/history/thinking.rs @@ -30,7 +30,7 @@ enum ThinkingVisualState { Idle, } -#[allow(dead_code)] // Kept for compatibility/tests; live view uses explicit summaries only. +#[cfg(test)] #[must_use] pub fn extract_reasoning_summary(text: &str) -> Option { extract_explicit_reasoning_summary(text).or_else(|| { diff --git a/crates/tui/src/tui/mcp_routing.rs b/crates/tui/src/tui/mcp_routing.rs index 3d4442bf66..52589fef80 100644 --- a/crates/tui/src/tui/mcp_routing.rs +++ b/crates/tui/src/tui/mcp_routing.rs @@ -87,6 +87,10 @@ fn push_server(lines: &mut Vec, server: &McpServerSnapshot, locale: Loca crate::mcp::McpRecoveryKind::Reauth => "Re-auth", crate::mcp::McpRecoveryKind::Diagnose => "Diagnose", }; + lines.push(format!( + " {}", + crate::mcp::mcp_startup_warning(&server.name, recovery, server.error.is_some()) + )); lines.push(format!(" next: {verb} {command}")); lines.push(format!( " discovered: {} tools, {} resources, {} prompts", diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index ed9b76b443..49155b6722 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -598,7 +598,7 @@ fn title_animation_base() -> &'static Mutex { } fn title_activity_verb() -> &'static Mutex { - TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("working…".to_string())) + TITLE_ACTIVITY_VERB.get_or_init(|| Mutex::new("in the current…".to_string())) } /// Configure whether the title whale cycles frames. @@ -610,7 +610,7 @@ pub fn set_title_motion_enabled(enabled: bool) { } /// Update the truthful activity verb shown next to the title whale -/// (`working…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`). +/// (`in the current…`, `reasoning…`, `using tool…`, `verifying…`, `waiting on you…`). pub fn set_title_activity_verb(verb: &str) { let verb = verb.trim(); if verb.is_empty() { @@ -640,7 +640,7 @@ pub fn set_title_activity_verb(verb: &str) { fn title_activity_label(base: &str, elapsed: Duration, focused: bool, motion: bool) -> String { let verb = title_activity_verb() .lock() - .map_or_else(|_| "working…".to_string(), |v| v.clone()); + .map_or_else(|_| "in the current…".to_string(), |v| v.clone()); let body = if verb.is_empty() { base.to_string() } else { @@ -698,7 +698,7 @@ pub fn start_title_animation(original: &str) { if let Ok(mut verb) = title_activity_verb().lock() && verb.is_empty() { - "working…".clone_into(&mut *verb); + "in the current…".clone_into(&mut *verb); } COMPLETION_MARKER_SHOWN.store(false, Ordering::SeqCst); TITLE_ANIMATION_RUNNING.store(true, Ordering::SeqCst); @@ -1234,23 +1234,23 @@ mod tests { fn title_whale_is_static_when_focused_or_motion_disabled() { let _guard = prefix_lock(); if let Ok(mut verb) = title_activity_verb().lock() { - "working…".clone_into(&mut *verb); + "in the current…".clone_into(&mut *verb); } assert_eq!( title_activity_label("Codewhale", Duration::ZERO, true, true), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::ZERO, false, false), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::ZERO, false, true), - "🐳 working…" + "🐳 in the current…" ); assert_eq!( title_activity_label("Codewhale", Duration::from_millis(800), false, true), - "🐋 working…" + "🐋 in the current…" ); } @@ -1557,8 +1557,8 @@ mod tests { assert_eq!(taskbar_progress_sequence(1, Some(42)), "\x1b]9;4;1;42\x07"); assert_eq!(taskbar_progress_sequence(0, None), "\x1b]9;4;0\x07"); assert_eq!( - terminal_title_sequence("🐳 working…"), - "\x1b]0;🐳 working…\x07" + terminal_title_sequence("🐳 in the current…"), + "\x1b]0;🐳 in the current…\x07" ); } diff --git a/crates/tui/src/tui/session_metrics.rs b/crates/tui/src/tui/session_metrics.rs index f48a45e100..e48940ed08 100644 --- a/crates/tui/src/tui/session_metrics.rs +++ b/crates/tui/src/tui/session_metrics.rs @@ -300,46 +300,68 @@ pub fn format_rate(rate: f64) -> String { /// A cell whose evidence has not arrived is omitted — never a placeholder: /// `TTFT avg` / `tok/s` appear only once a model call reported them, `Cache /// hit` only when a provider reported cache classes, `Input` only after the -/// first usage receipt. Turn, step, and time cells are always present once -/// the session has started (zero is a real count). +/// first usage receipt. Turn cells are present once the session has started +/// (zero turns is a real count). Step cells wait for the first completed +/// model or tool call so `0 steps` cannot look like a stalled scoreboard. #[must_use] pub fn build_groups(snapshot: MetricsSnapshot, locale: Locale) -> Vec { let label = |id: MessageId| tr(locale, id).into_owned(); let mut groups = Vec::new(); for group in GROUP_ORDER { let cells = match group { - MetricGroup::Turns => vec![ - MetricCell { - label: label(if snapshot.turns == 1 { - MessageId::SessionMetricsTurn - } else { - MessageId::SessionMetricsTurns - }), - value: snapshot.turns.to_string(), - value_first: true, - }, - MetricCell { - label: label(if snapshot.steps == 1 { - MessageId::SessionMetricsStep - } else { - MessageId::SessionMetricsSteps - }), - value: snapshot.steps.to_string(), - value_first: true, - }, - ], - MetricGroup::Llm => vec![ - MetricCell { - label: label(MessageId::SessionMetricsLlm), - value: format_duration(snapshot.llm_time), - value_first: false, - }, - MetricCell { - label: label(MessageId::SessionMetricsTools), - value: format_duration(snapshot.tool_time), - value_first: false, - }, - ], + MetricGroup::Turns => { + if snapshot.turns == 0 && snapshot.steps == 0 { + continue; + } + let mut cells = Vec::new(); + if snapshot.turns > 0 { + cells.push(MetricCell { + label: label(if snapshot.turns == 1 { + MessageId::SessionMetricsTurn + } else { + MessageId::SessionMetricsTurns + }), + value: snapshot.turns.to_string(), + value_first: true, + }); + } + if snapshot.steps > 0 { + cells.push(MetricCell { + label: label(if snapshot.steps == 1 { + MessageId::SessionMetricsStep + } else { + MessageId::SessionMetricsSteps + }), + value: snapshot.steps.to_string(), + value_first: true, + }); + } + if cells.is_empty() { + continue; + } + cells + } + MetricGroup::Llm => { + let mut cells = Vec::new(); + if !snapshot.llm_time.is_zero() { + cells.push(MetricCell { + label: label(MessageId::SessionMetricsLlm), + value: format_duration(snapshot.llm_time), + value_first: false, + }); + } + if !snapshot.tool_time.is_zero() { + cells.push(MetricCell { + label: label(MessageId::SessionMetricsTools), + value: format_duration(snapshot.tool_time), + value_first: false, + }); + } + if cells.is_empty() { + continue; + } + cells + } MetricGroup::Latency => { let mut cells = Vec::new(); if let Some(ttft) = snapshot.ttft_avg { @@ -635,6 +657,12 @@ mod tests { ); } + #[test] + fn idle_snapshot_paints_nothing() { + let text = full_text(MetricsSnapshot::default(), Locale::En, false); + assert_eq!(text, ""); + } + #[test] fn absent_evidence_omits_the_cell_instead_of_a_placeholder() { let mut snapshot = sample(); @@ -664,7 +692,7 @@ mod tests { ..MetricsSnapshot::default() }; let text = full_text(snapshot, Locale::En, false); - assert!(text.starts_with("1 turn · 1 step │"), "{text}"); + assert_eq!(text, "1 turn · 1 step", "{text}"); } #[test] diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 572ee9f74b..ba2b1960c6 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -8454,7 +8454,11 @@ async fn auto_dispatch_keeps_last_and_pending_receipts_aligned() { app.pending_turn_route .as_ref() .map(|(provider, model, auto)| (*provider, model.as_str(), *auto)), - Some((ApiProvider::Zai, crate::config::ZAI_GLM_5_TURBO_MODEL, true)) + Some(( + ApiProvider::Zai, + crate::config::ZAI_GLM_5_3_FLASH_MODEL, + true, + )) ); assert_eq!( app.last_auto_route_receipt, app.pending_auto_route_receipt, @@ -8468,13 +8472,10 @@ async fn auto_dispatch_keeps_last_and_pending_receipts_aligned() { ); assert_eq!( app.last_effective_reasoning_effort, - Some(EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable), + Some(EffectiveReasoningEffort::Tier(ReasoningEffort::High)), "the post-turn receipt must retain exact route capability constraints" ); - assert_eq!( - app.reasoning_effort_display_label(), - "low→thinking enabled; granularity unavailable" - ); + assert_eq!(app.reasoning_effort_display_label(), "low→high"); } #[tokio::test] diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index b25fa91e05..af6854c857 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -564,9 +564,9 @@ pub(crate) fn title_activity_verb(app: &App) -> &'static str { LiveActivityKind::Reasoning => "reasoning…", LiveActivityKind::Reading => "reading…", LiveActivityKind::UsingTool => "using tool…", - LiveActivityKind::UsingSubagents => "subagents…", + LiveActivityKind::UsingSubagents => "pod underway…", LiveActivityKind::Verifying => "verifying…", - LiveActivityKind::Working => "working…", + LiveActivityKind::Working => "in the current…", }, } } diff --git a/crates/tui/src/tui/whales.rs b/crates/tui/src/tui/whales.rs index 7d75aba94b..3d700d8a9e 100644 --- a/crates/tui/src/tui/whales.rs +++ b/crates/tui/src/tui/whales.rs @@ -73,7 +73,7 @@ pub enum WhaleSpecies { impl WhaleSpecies { /// Every species, for exhaustive checks and the test gallery. - #[allow(dead_code)] + #[cfg(test)] pub const ALL: [WhaleSpecies; 7] = [ Self::Scout, Self::Patch, @@ -190,7 +190,7 @@ pub enum WhaleState { impl WhaleState { /// Every state, for exhaustive checks and the test gallery. - #[allow(dead_code)] + #[cfg(test)] pub const ALL: [WhaleState; 6] = [ Self::Resting, Self::Thinking, @@ -202,7 +202,7 @@ impl WhaleState { /// CWC state priority; higher wins when several facts apply. Public /// contract for surfaces that fold several children into one whale. - #[allow(dead_code)] + #[cfg(test)] #[must_use] pub const fn priority(self) -> u8 { match self { @@ -633,7 +633,7 @@ pub fn portrait( /// The portrait narrowed through the glyph charter's ASCII fallback — what an /// `CODEWHALE_ASCII_SAFE=1` terminal draws. Pure text, for tests and /// text-only surfaces. -#[allow(dead_code)] // test/text-surface API; the draw path narrows per cell +#[cfg(test)] #[must_use] pub fn portrait_ascii( species: WhaleSpecies, @@ -656,7 +656,7 @@ pub fn portrait_ascii( } /// The portrait as plain Unicode rows (no color), for tests and snapshots. -#[allow(dead_code)] // test/snapshot API +#[cfg(test)] #[must_use] pub fn portrait_text( species: WhaleSpecies, @@ -695,7 +695,7 @@ pub fn badge(species: WhaleSpecies, theme: &UiTheme) -> Vec> { /// Badge followed by the state word (glyph + word: never color alone). The /// word takes the state's tone; when `state` is `None` only the badge renders. -#[allow(dead_code)] // frame-less convenience for static surfaces +#[cfg(test)] #[must_use] pub fn badge_with_state( species: WhaleSpecies, @@ -753,7 +753,7 @@ fn state_cue( } /// The badge as ASCII text (`<#`, `#]`, ...), for tests and text surfaces. -#[allow(dead_code)] // test/text-surface API +#[cfg(test)] #[must_use] pub fn badge_ascii(species: WhaleSpecies) -> String { let (feature, body, feature_first) = species.badge_glyphs(); @@ -859,6 +859,14 @@ mod tests { } } + #[test] + fn state_priority_orders_attention_before_work() { + assert!( + WhaleState::Waiting.priority() > WhaleState::Working.priority() + && WhaleState::Working.priority() > WhaleState::Resting.priority() + ); + } + #[test] fn authored_art_rows_and_ink_maps_agree() { for species in WhaleSpecies::ALL { diff --git a/crates/tui/src/work_graph/mod.rs b/crates/tui/src/work_graph/mod.rs index e6029b0b3d..31f1ff869c 100644 --- a/crates/tui/src/work_graph/mod.rs +++ b/crates/tui/src/work_graph/mod.rs @@ -13,11 +13,9 @@ //! and liveness truth stays with the owning subsystems — the graph records //! observations, it never invents them. //! -//! This slice is the core only: model, changes, pure reducer, validation. //! Session persistence, legacy import, UI projections, and liveness adapters -//! land in later slices; nothing in the app or engine calls this yet. -// Staged cutover: later slices wire persistence, UI, and liveness; until -// then the public surface (including re-exports) has no external callers. +//! are wired: `session_manager` stores a snapshot, `tools/shell` and +//! `tools/tasks` observe operations, and the engine records owner liveness. #![allow(dead_code)] #![allow(unused_imports)] diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 4d9fffbd4f..5048e6c7fc 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -581,7 +581,7 @@ overlay and lets DSH resolve its own keys. | `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = "https://api.moonshot.cn/v1"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. | | `antigravity` | `[providers.antigravity]` | `ANTIGRAVITY_API_KEY` | `ANTIGRAVITY_BASE_URL`; default `https://cloudcode-pa.googleapis.com/v1internal` | none advertised — requests fail closed until the cloud-code wire protocol exists | Antigravity (`agy` 1.1.13) credential plane: consent-gated read-only import of the official CLI's `state.vscdb` OAuth token (`antigravityUnifiedStateSync.oauthToken`), pinned to the exact per-OS app-profile path. The store is opened read-only through the secure no-follow boundary with an inode recheck; Codewhale never writes, refreshes, or re-authenticates. Precedence: `ANTIGRAVITY_API_KEY` > process `AGY_ADC_AUTH` > consented file. Not an embed of any other harness. No live calls made in this environment. | | `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. | -| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. `GLM-5.3` is the default and a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`); an explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; it carries no price. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. | +| `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. `GLM-5.3` is the default and a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = "GLM-5.3-Flash"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. | | `stepfun` | `[providers.stepfun]` | `STEPFUN_API_KEY`, `STEP_API_KEY` | `STEPFUN_BASE_URL`, `STEP_BASE_URL`; default `https://api.stepfun.ai/v1`; Coding Plan endpoint `https://api.stepfun.ai/step_plan/v1` | `step-3.7-flash` | StepFun / StepFlash direct OpenAI-compatible route. `/provider` setup asks which billing route the key belongs to — pay-as-you-go or Step Plan — validates the key against the chosen endpoint, and writes the answer to `[providers.stepfun].base_url` only. A base URL that is neither recognized route is left alone and the question is skipped. You can also set `[providers.stepfun].base_url` or `STEP_BASE_URL` to the Coding Plan URL by hand. Offline accounting labels recognized routes as `stepfun-payg` or `stepfun-plan` without persisting the raw endpoint, and only the standard PAYG route receives token pricing. `STEPFUN_MODEL` and `STEP_MODEL` are accepted. | | `minimax` | `[providers.minimax]` | `MINIMAX_API_KEY` | `MINIMAX_BASE_URL`; default `https://api.minimax.io/v1`; China `https://api.minimaxi.com/v1` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | MiniMax direct OpenAI-compatible route. Codewhale sends `reasoning_split = true` so MiniMax thinking arrives separately from answer text. Both MiniMax dialects sell pay-as-you-go and Token Plan over the same endpoints and the same key, so billing is classified from the credential *product*, never from the endpoint or from a default. `mode = "token-plan"` in `[providers.minimax]`/`[providers.minimax_anthropic]`, or a Token Plan key shaped `sk-cp…`, bills as MiniMax Token Plan quota with no dollar estimates; an explicit pay-as-you-go mode (`pay-as-you-go`/`payg`/`metered`) wins over key shape. The key's product prefix is only visible when the key is in config, bound by `api_key_env`, or exported as `MINIMAX_API_KEY` on an official endpoint — a key saved through `codewhale auth set` (secret store / OS keyring) is deliberately not read to classify billing. With no explicit mode and no visible product marker the route reports `cost: unknown` rather than assuming pay-as-you-go, so a Token Plan account is never charged invented dollars. Custom/gateway endpoints also fail closed with `cost: unknown`. Official M3 input modalities are text, image, and video; M2.7 is text-only. | | `minimax-anthropic` | `[providers.minimax_anthropic]` | `MINIMAX_API_KEY` | `MINIMAX_ANTHROPIC_BASE_URL`; default `https://api.minimax.io/anthropic`; China `https://api.minimaxi.com/anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | MiniMax direct Anthropic-compatible Messages route. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`; the route uses `x-api-key`. M3 supports adaptive or disabled thinking. M2.7 always keeps thinking enabled. | @@ -738,15 +738,17 @@ large models verified through OpenRouter's model metadata: context multimodal model for coding, tool use, and long-horizon agentic work. `GLM-5.3` is now the default direct Z.AI Coding Plan model; `GLM-5.2` / `z-ai/glm-5.2` remain available (explicit selections keep their own id), -`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model, and -`GLM-5-Turbo` / `z-ai/glm-5-turbo` serve as the faster same-family sibling -used by faster/explore sub-agents. -`GLM-5.3` / `z-ai/glm-5.3` are first-class picker ids on the Z.ai and -OpenRouter routes (`/model` after `/provider zai`, or `model = "GLM-5.3"`). -Limits and reasoning options are inherited from -`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and they carry no -price. A live call can still 429 with entitlement code 1311 on accounts -that are not provisioned for 5.3. +`GLM-5.1` / `z-ai/glm-5.1` remain available as the smaller model, +`GLM-5.3-Flash` / `z-ai/glm-5.3-flash` is the faster/explore sibling of +`GLM-5.3`, and `GLM-5-Turbo` / `z-ai/glm-5-turbo` remains the faster sibling +of `GLM-5.2`. +`GLM-5.3` / `z-ai/glm-5.3` and `GLM-5.3-Flash` / `z-ai/glm-5.3-flash` are +first-class picker ids on the Z.ai and OpenRouter routes (`/model` after +`/provider zai`, or `model = "GLM-5.3-Flash"`). +Limits and reasoning options for 5.3 are inherited from +`GLM-5.2` until Z.ai publishes distinct 5.3 metadata, and 5.3 carries no +price. Flash ships the published $0.15/$0.50 list. A live call can still +429 with entitlement code 1311 on accounts that are not provisioned for 5.3. ## Static Model Registry @@ -763,7 +765,7 @@ endpoint when the endpoint supports model listing. | `atlascloud` | `deepseek-ai/deepseek-v4-flash`, `deepseek-ai/deepseek-v4-pro` | yes | yes | | `wanjie-ark` | `deepseek-reasoner` | yes | yes | | `volcengine` | `DeepSeek-V4-Pro`, `DeepSeek-V4-Flash` | yes | yes | -| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes | +| `openrouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `arcee-ai/trinity-large-thinking`, `minimax/minimax-m3`, `minimax/minimax-m2.7`, `xiaomi/mimo-v2.5-pro`, `xiaomi/mimo-v2.5`, `qwen/qwen3.6-flash`, `qwen/qwen3.6-35b-a3b`, `qwen/qwen3.6-max-preview`, `qwen/qwen3.6-27b`, `qwen/qwen3.6-plus`, `qwen/qwen3.7-max`, `moonshotai/kimi-k2.7-code`, `moonshotai/kimi-k2.6`, `z-ai/glm-5.1`, `z-ai/glm-5.2`, `z-ai/glm-5.3`, `z-ai/glm-5.3-flash`, `z-ai/glm-5-turbo`, `tencent/hy3-preview`, `google/gemma-4-31b-it`, `google/gemma-4-26b-a4b-it`, `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, `nvidia/nemotron-3-ultra-550b-a55b` | yes | yes | | `orcarouter` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash`, `orcarouter/auto` | yes | yes | | `xiaomi-mimo` | `mimo-v2.5-pro`, `mimo-v2.5-pro-ultraspeed`, `mimo-v2.5`; speech/TTS IDs are selected through `codewhale speech` / `tts` | yes | yes for chat models; no for speech/TTS models | | `novita` | `deepseek/deepseek-v4-pro`, `deepseek/deepseek-v4-flash` | yes | yes | @@ -771,7 +773,7 @@ endpoint when the endpoint supports model listing. | `siliconflow` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | yes | yes | | `arcee` | `trinity-large-thinking`, `trinity-large-preview`; provider-hinted custom model IDs pass through | yes | yes for `trinity-large-thinking`; no for `trinity-large-preview` | | `moonshot` | `kimi-k2.7-code`, `kimi-k2.6` | yes | yes | -| `zai` | `GLM-5.3`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes | +| `zai` | `GLM-5.3`, `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, `GLM-5-Turbo`; provider-hinted custom model IDs pass through | yes | yes | | `stepfun` | `step-3.7-flash` | yes | no | | `minimax` | `MiniMax-M3`, `MiniMax-M2.7`, `MiniMax-M2.7-highspeed`, `MiniMax-M2.5`, `MiniMax-M2.5-highspeed`, `MiniMax-M2.1`, `MiniMax-M2.1-highspeed`, `MiniMax-M2` | yes | yes | | `minimax-anthropic` | `MiniMax-M3`, `MiniMax-M2.7` | yes | yes | @@ -865,6 +867,7 @@ while bare `k3` can use an entitled 1M override. | Direct Moonshot/Kimi K2.7/K2.6 (`kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`) | 262,144 | 32,768 | yes | no | provider-reported bundled catalog | | Kimi Code membership `kimi-for-coding`, `kimi-for-coding-highspeed` | 262,144 | unknown — the membership catalog owns these limits and no client-side ceiling is claimed | yes | no | exact `https://api.kimi.com/coding/v1` route | | Direct Z.AI `GLM-5.3` (default) | 1,000,000 | 131,072 | yes | no | live on the GLM Coding Plan; limits inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 numbers; no USD price | +| Direct Z.AI `GLM-5.3-Flash` | 1,000,000 | 131,072 | yes | no | natively multimodal; $0.15/$0.50 list (2026-08-26); faster/explore sibling of `GLM-5.3` | | Direct Z.AI `GLM-5.2` | 1,000,000 | 131,072 | yes | no | not documented in code | | Direct Z.AI `GLM-5.1` | 202,752 | 131,072 | yes | no | not documented in code | | Direct Z.AI `GLM-5-Turbo` | 202,752 | 131,072 | yes | no | faster/explore sub-agent sibling | @@ -981,6 +984,7 @@ Providers marked "omitted" receive no reasoning fields at all for that tier. | First-party `minimax` `MiniMax-M3` | `reasoning_split: true` + `thinking: {type: disabled}` | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | `reasoning_split: true` + `thinking: {type: adaptive}`; effective tier granularity unavailable | | First-party Z.ai `GLM-5.2` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: "high"` | enabled thinking + `reasoning_effort: "max"` | | First-party Z.ai `GLM-5.3` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: "high"` | enabled thinking + `reasoning_effort: "max"` | +| First-party Z.ai `GLM-5.3-Flash` | `thinking: {type: disabled}`; no `reasoning_effort` | enabled thinking; only effective `high` adds `reasoning_effort: "high"` | enabled thinking + `reasoning_effort: "max"` | | First-party Z.ai `GLM-5-Turbo` | `thinking: {type: disabled}` | enabled thinking; effort granularity unavailable | enabled thinking; effort granularity unavailable | | Compatible gateways configured as `zai` | omitted; effective unavailable | omitted; effective unavailable | omitted; effective unavailable | | `nvidia-nim` | `chat_template_kwargs.thinking: false` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: "high"` | `chat_template_kwargs`: `thinking: true` + `reasoning_effort: "max"` | diff --git a/docs/design/TUI_DECONSTRUCTION.md b/docs/design/TUI_DECONSTRUCTION.md new file mode 100644 index 0000000000..324d851269 --- /dev/null +++ b/docs/design/TUI_DECONSTRUCTION.md @@ -0,0 +1,91 @@ +# TUI deconstruction + +Stop paying the monolith tax by **consolidating then extracting**, never the reverse. This is the playbook. It is not permission to open a crate per file. + +## Invariants (mechanical, every PR) + +- Runtime-contract receipt (`python3 scripts/measure-runtime-contract.py` / `check-runtime-contract-budget.py`) is **byte-identical** before and after. That is the KV-cache prefix made checkable. +- `crates/core/tests/single_turn_loop.rs` stays green. There is one turn loop: `Engine::run_turn`. Do not add a second. +- `BASE_PROMPT` in `crates/tui/src/prompts/text.rs` is the sole base prompt. Tool catalog order is a cache-prefix fact; do not shuffle it as a drive-by. +- Dead-code / file-size / persistence budgets may go **down**, never up, unless the PR names why. +- Clippy/fmt + targeted tests via `scripts/dev-test.sh [filter]`. Do not `cargo test --workspace` for a single-area edit. +- Never merge `#5576` or `#5628`. Never add `target-*` at the repo root. + +## Anti-goal + +No micro-crates. No "extract because the file is large." A new crate exists only when it has **more than one production consumer** already, or when the extraction is the last step of a finished consolidation. + +## Target topology + +| Lives in | Owns | +| --- | --- | +| `crates/tui` | UI, slash commands, process entry. Target: **<150K lines**. | +| `crates/mcp`, `crates/tools`, `crates/state` | Grow in place. One MCP client (the rmcp stack). | +| **new** `codewhale-models` | After catalog/config consolidation (C3): client, one catalog, pricing, credentials, routing. | +| **Decision A** | Thread store + HTTP automation either becomes `codewhale-runtime` **or** folds into `crates/app-server`. Pick one; do not ship both. | +| `codewhale-engine` | Extracted **last**. Today the engine still lives in `tui/src/core`. | + +`crates/core` already owns request construction, bounded fragments, and thread/session types. It does not run turns. Do not rename it as a substitute for extracting the engine. + +## Sequencing + +### Phase 0 — preconditions (do these first; they are the audit's C1–C4) + +Never extract a crate before these finish. Extraction-before-consolidation relocates the mess. + +1. **C1 MCP unification** — one client. Move the rmcp stack down, delete the hand-rolled stdio client. +2. **C2 Config mirror deletion** — one schema crate owns `config.toml`. TUI's `Config` becomes a resolved view. One struct pair per PR. +3. **C3 Model-facts unification** — config crate catalog is the single source. Delete the seeded `model_registry` table and its drift-guard test. Prices become data. +4. **C4 Test-giant migration** — move the six `>10K` `tests.rs` files out via the existing `#[path = "tests/..."]` pattern. Test count identical before/after. + +Off-ramp: stop after Phase 0 if that is all 0.9.12 can hold. That is a legitimate ship. + +### Phase 1 — dismember `lib.rs` intra-crate + +Stay inside `crates/tui`. Follow `scripts/command-migration-topology.json`. Order: `cli_args` → `doctor` → subcommands → tests out. + +Gate: `crates/tui/src/lib.rs` **< 2,000 lines**. + +### Phase 2 — leaf extractions, fewest-dependents first + +Always **two PRs per extraction**: + +1. Pure `git mv` + re-export shims. Zero logic edits. Receipt identical. +2. Repoint consumers, delete shims, `cargo machete`. Shims get a removal issue at merge. + +Never mix a move with an edit. + +### Phase 3 — engine last + +Extract `Engine` / `run_turn` only after C4 (suite builds fast) and after the leaves are gone. Freeze behavior with the existing runtime-contract receipt. Introduce `TurnLoopState` as a field grouping, not a second loop. + +Off-ramp after "2e" (leaves extracted, engine still in tui) is allowed. + +## Contributor loop (already exists — do not add a second script) + +```sh +./scripts/dev-test.sh config +./scripts/dev-test.sh tui session_metrics:: +./scripts/dev-test.sh tui-integration +./scripts/dev-test.sh crates/tui/src/elapsed.rs +``` + +- Incremental by default. Isolated build-dir via `scripts/dev-cache.sh`. +- Prints the exact `cargo` / `nextest` command (`+ cargo …`). +- `CODEWHALE_DEV_NEXTEST=0` forces libtest. There is no `CARGO_INCREMENTAL=0` requirement for ordinary targeted work. +- `--lib` does not cover `crates/tui/tests/`. Use `tui-integration` / `tui-cucumber`. +- Full CI remains the release gate. Local `tui` is `--lib` on purpose. + +If a `tui full` alias is needed, add it to **this** script, not a new one. + +## Providers (OMP / OpenCode, not a new enum) + +Hosted OpenAI Chat Completions backends (Baseten, Groq, Cerebras, SenseNova) are **data rows** in `crates/config/src/provider_templates.rs` (`ProviderSetupApply::Compatible`). They persist as `[providers.] kind = "openai-compatible"`. They do **not** get a `ProviderKind` / `ApiProvider` variant. + +Add a new hosted Chat Completions host by appending one template (id, URL, env, default model, docs). Enum variants stay for distinct **wires**: Anthropic Messages, Codex Responses, Google thought signatures, OAuth-only import. + +OMP does the same: one catalog descriptor + one auth file. OpenCode does models.dev + named `provider` config + plugins. Neither adds a 15-arm match. + +## Recipe reminder + +Add a layer only when the PR **names or deletes** the layer it replaces. Before adding `model_*`, `*_config`, `provider_*`, or anything that "bridges" / "mirrors" / "stages", grep the existing thing and edit it. diff --git a/scripts/dead-code-budget.json b/scripts/dead-code-budget.json index 0552d2ca42..b9f1eb5eb1 100644 --- a/scripts/dead-code-budget.json +++ b/scripts/dead-code-budget.json @@ -1,10 +1,9 @@ { "_comment": "Ceiling for `#[allow(dead_code)]` across crates/. This number may go down freely; raising it needs a reviewer to say why in the PR. Regenerate with: python3 scripts/check-dead-code-budget.py --update", "_issue": "https://github.com/Hmbown/CodeWhale/issues/4785", - "total": 448, + "total": 372, "per_crate": { - "config": 1, "tools": 2, - "tui": 445 + "tui": 370 } }