diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 51e6accfe5..f7489a357f 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -4,22 +4,6 @@ use std::fmt; use codewhale_config::{ProviderKind, opencode_go_chat_model_id}; use serde::{Deserialize, Serialize}; -/// High-level model family used for shared identity affordances across clients. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum ModelFamily { - DeepSeek, - Anthropic, - OpenAI, - Google, - Meta, - Mistral, - Qwen, - Grok, - Cohere, - GptOss, - Inferencer, -} - /// Metadata for a single model entry in the registry. /// /// Each model has a canonical `id` used by the provider, a list of `aliases` @@ -1635,61 +1619,6 @@ fn normalize(value: &str) -> String { value.trim().to_ascii_lowercase() } -#[must_use] -/// Classify a model identifier by its underlying model family. -pub fn model_family(model_id: &str) -> ModelFamily { - let normalized = normalize(model_id); - if normalized.is_empty() { - return ModelFamily::Inferencer; - } - - if normalized.contains("deepseek") { - return ModelFamily::DeepSeek; - } - if normalized.contains("claude") || normalized.contains("anthropic") { - return ModelFamily::Anthropic; - } - if normalized.contains("gpt-oss") || normalized.contains("gpt_oss") { - return ModelFamily::GptOss; - } - if normalized.starts_with("gpt-") - || normalized.contains("/gpt-") - || normalized.contains("openai/") - { - return ModelFamily::OpenAI; - } - if normalized.contains("gemini") - || normalized.contains("gemma") - || normalized.contains("google/") - { - return ModelFamily::Google; - } - if normalized.contains("llama") - || normalized.contains("muse-spark") - || normalized.contains("meta-") - || normalized.contains("meta/") - { - return ModelFamily::Meta; - } - if normalized.contains("mistral") - || normalized.contains("mixtral") - || normalized.contains("codestral") - { - return ModelFamily::Mistral; - } - if normalized.contains("qwen") { - return ModelFamily::Qwen; - } - if normalized.contains("grok") { - return ModelFamily::Grok; - } - if normalized.contains("cohere") || normalized.contains("command-r") { - return ModelFamily::Cohere; - } - - ModelFamily::Inferencer -} - fn model_matches(model: &ModelInfo, requested: &str) -> bool { let requested = normalize(requested); normalize(&model.id) == requested @@ -2104,29 +2033,6 @@ mod tests { } } - /// The bare `k3` id belongs to the Kimi Code coding-plan endpoint and - /// `kimi-k3` to the direct platform endpoint. Neither may be laundered - /// into the other's id by alias expansion. - #[test] - fn moonshot_k3_ids_are_never_rewritten_into_each_other() { - let registry = ModelRegistry::default(); - - assert_eq!( - registry - .resolve_ok(Some("kimi-k3"), Some(ProviderKind::Moonshot)) - .resolved - .id, - "kimi-k3" - ); - assert_eq!( - registry - .resolve_ok(Some("k3"), Some(ProviderKind::Moonshot)) - .resolved - .id, - "k3" - ); - } - /// A provider-scoped question must never be answered with another /// vendor's model. `kimi-k3` also exists in the OpenCode Go catalog; /// before this fix that entry answered `--provider moonshot` requests. @@ -2483,7 +2389,6 @@ mod tests { assert_eq!(alias.resolved.provider, ProviderKind::Meta); assert_eq!(alias.resolved.id, "muse-spark-1.2"); assert!(!alias.used_fallback); - assert_eq!(model_family("muse-spark-1.2"), ModelFamily::Meta); } #[test] @@ -2499,16 +2404,6 @@ mod tests { } } - #[test] - fn grok_ids_stay_in_grok_family() { - assert_eq!(model_family("grok-4.6"), ModelFamily::Grok); - assert_eq!(model_family("grok-4.5"), ModelFamily::Grok); - assert_eq!( - model_family("grok-4.20-0309-non-reasoning"), - ModelFamily::Grok - ); - } - #[test] fn stepfun_and_minimax_direct_models_resolve_when_provider_hinted() { let registry = ModelRegistry::default(); @@ -2774,54 +2669,4 @@ mod tests { assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek); assert_eq!(resolved.resolved.id, "deepseek-v4-flash"); } - - #[test] - fn model_family_classifies_known_model_ids() { - assert_eq!(model_family("deepseek-v4-pro"), ModelFamily::DeepSeek); - assert_eq!(model_family("openai/gpt-5.4"), ModelFamily::OpenAI); - assert_eq!( - model_family("anthropic/claude-opus-4-7"), - ModelFamily::Anthropic - ); - assert_eq!( - model_family("meta-llama/llama-3.3-70b-instruct"), - ModelFamily::Meta - ); - assert_eq!(model_family("Qwen/Qwen3-Coder"), ModelFamily::Qwen); - } - - #[test] - fn model_family_uses_underlying_model_for_router_ids() { - assert_eq!( - model_family("groq/llama-3.3-70b-versatile"), - ModelFamily::Meta - ); - assert_eq!( - model_family("openrouter/openai/gpt-5.4"), - ModelFamily::OpenAI - ); - assert_eq!( - model_family("fireworks/accounts/fireworks/models/deepseek-v4-pro"), - ModelFamily::DeepSeek - ); - } - - #[test] - fn model_family_covers_prominent_google_and_mistral_model_names() { - assert_eq!(model_family("google/gemma-3-27b-it"), ModelFamily::Google); - assert_eq!( - model_family("mistralai/mixtral-8x22b"), - ModelFamily::Mistral - ); - assert_eq!(model_family("codestral-latest"), ModelFamily::Mistral); - } - - #[test] - fn model_family_falls_back_to_inferencer_for_unknown_models() { - assert_eq!( - model_family("custom-gateway/my-private-model"), - ModelFamily::Inferencer - ); - assert_eq!(model_family(""), ModelFamily::Inferencer); - } } diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index 38baac0df0..a6f8ba07a3 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -4634,29 +4634,6 @@ mod tests { ); } - // ── cors_layer ───────────────────────────────────────────────────── - - #[test] - fn cors_layer_includes_default_origins() { - let layer = cors_layer(&[]); - // Just verify it doesn't panic and creates successfully - let _ = layer; - } - - #[test] - fn cors_layer_adds_extra_origins() { - let extras = vec!["https://example.com".to_string()]; - let layer = cors_layer(&extras); - let _ = layer; - } - - #[test] - fn cors_layer_skips_empty_origins() { - let extras = vec!["".to_string(), " ".to_string()]; - let layer = cors_layer(&extras); - let _ = layer; - } - // ── JsonRpc helpers ──────────────────────────────────────────────── #[test] diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index d2f7f29309..dabba6a47a 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -3256,16 +3256,6 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *codewhale-win assert!(ensure_supported_release_target("android", "aarch64").is_ok()); } - #[test] - fn android_release_assets_never_select_linux_arm64() { - // Sanity: the stem formatter must never produce a linux-* stem for android. - let stem = release_asset_stem_for_prefix("codewhale", "android", "aarch64"); - assert!( - !stem.contains("linux"), - "android stem must not contain linux: {stem}" - ); - } - #[test] fn mirror_release_uses_base_url_and_platform_assets() { let release = release_from_mirror_base_url( diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 62ec7fdcff..a98dd4a4fb 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -35,7 +35,7 @@ pub use provider_templates::{ CEREBRAS_TEMPLATE_ID, COMMAND_CODE_TEMPLATE_ID, GROQ_API_KEY_ENV, GROQ_BASE_URL, GROQ_DEFAULT_MODEL, GROQ_TEMPLATE_ID, ProviderSetupApply, ProviderSetupTemplate, SENSENOVA_API_KEY_ENV, SENSENOVA_BASE_URL, SENSENOVA_DEFAULT_MODEL, SENSENOVA_TEMPLATE_ID, - compatible_provider_setup_templates, provider_setup_template, provider_setup_templates, + provider_setup_template, provider_setup_templates, }; pub use settings_schema::{ SETTINGS_SCHEMA, SettingDef, SettingKind, SettingOption, SettingUi, schema_groups, schema_rows, diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 25f5b23437..04a4786ad3 100644 --- a/crates/config/src/provider_templates.rs +++ b/crates/config/src/provider_templates.rs @@ -303,12 +303,6 @@ pub fn provider_setup_templates() -> &'static [ProviderSetupTemplate] { TEMPLATES } -/// Templates that persist as named OpenAI-compatible tables. -pub fn compatible_provider_setup_templates() -> impl Iterator -{ - TEMPLATES.iter().filter(|template| template.is_compatible()) -} - /// Look up a template by id, documented alias, or first-class provider id. #[must_use] pub fn provider_setup_template(id: &str) -> Option<&'static ProviderSetupTemplate> { @@ -385,17 +379,6 @@ mod tests { } } - #[test] - fn compatible_template_ids_do_not_shadow_built_ins() { - for template in compatible_provider_setup_templates() { - assert!( - ProviderKind::parse(template.id).is_none(), - "compatible template '{}' shadows ProviderKind", - template.id - ); - } - } - #[test] fn first_class_templates_reuse_registry_facts() { let zen = provider_setup_template("opencode-zen").expect("zen"); @@ -431,6 +414,14 @@ mod tests { assert_eq!(sense.picker_models(), vec![SENSENOVA_DEFAULT_MODEL]); assert!(sense.docs_url().is_none()); assert!(sense.credential_url().is_none()); + // Re-pointed from compatible_template_ids_do_not_shadow_built_ins: + // this template id is the one the surviving hosted-host enumeration + // does not cover, and it must never shadow a ProviderKind built-in. + assert!( + ProviderKind::parse(SENSENOVA_TEMPLATE_ID).is_none(), + "compatible template '{}' shadows ProviderKind", + SENSENOVA_TEMPLATE_ID + ); } #[test] diff --git a/crates/config/src/resolve.rs b/crates/config/src/resolve.rs index d13c792f2b..1dba666070 100644 --- a/crates/config/src/resolve.rs +++ b/crates/config/src/resolve.rs @@ -1,17 +1,12 @@ -//! Ranked settings resolution with provenance. +//! Ranked settings resolution. //! //! One rank table for every setting: the highest-ranked layer that names a -//! scalar wins outright (scalars replace, never merge); map layers -//! deep-merge with the highest rank winning per leaf; secrets travel by -//! reference only — a [`SecretRef`] names the source and id, never the -//! value, so a resolved row can say *which* secret won without carrying it. +//! scalar wins outright. //! //! Layers with no producer today (managed policy files, project settings) //! simply contribute no entries; the rank is the contract, and an empty //! layer abstains rather than vetoing. -use std::collections::BTreeMap; - /// Where a setting value came from, highest rank first. /// /// The order is the product rule: policy beats the CLI flag, the flag beats @@ -32,98 +27,6 @@ pub enum Layer { Default, } -impl Layer { - /// Stable wire name for receipts and row labels. - #[must_use] - pub fn name(self) -> &'static str { - match self { - Layer::ManagedPolicy => "policy", - Layer::CliFlag => "cli", - Layer::SessionOverride => "session", - Layer::ProjectConfig => "project", - Layer::UserConfig => "user", - Layer::Default => "default", - } - } -} - -/// A secret by reference: the layer that resolved it plus where to find it. -/// The value itself never enters the resolver, a row, or a receipt. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SecretRef { - /// Which layer named this secret. - pub layer: Layer, - /// Where it lives: `env:DASHSCOPE_API_KEY`, `keychain:codewhale/...`, … - pub reference: String, -} - -impl SecretRef { - #[must_use] - pub fn new(layer: Layer, reference: impl Into) -> Self { - Self { - layer, - reference: reference.into(), - } - } -} - -/// The winning layer for one scalar key, with every layer that named it. -/// Empty input means the schema default wins unopposed. -#[must_use] -pub fn resolve_scalar<'a>( - candidates: impl IntoIterator, -) -> (Layer, Vec) { - let mut best: Option = None; - let mut named: Vec = Vec::new(); - for (layer, _) in candidates { - if !named.contains(&layer) { - named.push(layer); - } - if best.is_none_or(|top| layer < top) { - best = Some(layer); - } - } - named.sort(); - (best.unwrap_or(Layer::Default), named) -} - -/// Deep-merge map layers: every key present anywhere survives, and per key -/// the highest-ranked layer's value wins. Lower layers contribute only the -/// keys nobody above them named. -#[must_use] -pub fn merge_maps(layers: &BTreeMap>) -> BTreeMap { - let mut merged = BTreeMap::new(); - let mut order: Vec = layers.keys().copied().collect(); - order.sort(); - for layer in order { - if let Some(map) = layers.get(&layer) { - for (key, value) in map { - merged.entry(key.clone()).or_insert_with(|| value.clone()); - } - } - } - merged -} - -/// Which layer supplied each merged key. Same walk as [`merge_maps`], kept -/// as a separate pass so a row can name the winner per key. -#[must_use] -pub fn merge_provenance( - layers: &BTreeMap>, -) -> BTreeMap { - let mut provenance = BTreeMap::new(); - let mut order: Vec = layers.keys().copied().collect(); - order.sort(); - for layer in order { - if let Some(map) = layers.get(&layer) { - for key in map.keys() { - provenance.entry(key.clone()).or_insert(layer); - } - } - } - provenance -} - #[cfg(test)] mod tests { use super::*; @@ -142,73 +45,4 @@ mod tests { sorted.sort(); assert_eq!(sorted, layers); } - - #[test] - fn scalar_resolution_picks_the_highest_ranked_layer() { - let (winner, named) = resolve_scalar([ - (Layer::Default, "bottom"), - (Layer::UserConfig, "user"), - (Layer::SessionOverride, "session"), - ]); - assert_eq!(winner, Layer::SessionOverride); - assert_eq!( - named, - vec![Layer::SessionOverride, Layer::UserConfig, Layer::Default] - ); - } - - #[test] - fn scalar_resolution_with_no_candidates_falls_back_to_default() { - let (winner, named) = resolve_scalar([]); - assert_eq!(winner, Layer::Default); - assert!(named.is_empty()); - } - - #[test] - fn scalar_values_replace_never_merge() { - // The loser's text is evidence only; the winner's value is untouched. - let (winner, _) = resolve_scalar([(Layer::UserConfig, "user"), (Layer::CliFlag, "cli")]); - assert_eq!(winner, Layer::CliFlag); - } - - #[test] - fn map_merge_keeps_every_key_with_highest_rank_winning_per_key() { - let layers = BTreeMap::from([ - ( - Layer::UserConfig, - BTreeMap::from([ - ("a".to_string(), "user-a".to_string()), - ("b".to_string(), "user-b".to_string()), - ]), - ), - ( - Layer::ProjectConfig, - BTreeMap::from([("b".to_string(), "project-b".to_string())]), - ), - ]); - let merged = merge_maps(&layers); - assert_eq!(merged.get("a").map(String::as_str), Some("user-a")); - assert_eq!(merged.get("b").map(String::as_str), Some("project-b")); - let provenance = merge_provenance(&layers); - assert_eq!(provenance.get("a"), Some(&Layer::UserConfig)); - assert_eq!(provenance.get("b"), Some(&Layer::ProjectConfig)); - } - - #[test] - fn secret_refs_carry_no_value() { - let secret = SecretRef::new(Layer::UserConfig, "env:DASHSCOPE_API_KEY"); - let debug = format!("{secret:?}"); - assert!(debug.contains("env:DASHSCOPE_API_KEY")); - assert_eq!(secret.layer, Layer::UserConfig); - } - - #[test] - fn layer_names_are_stable() { - assert_eq!(Layer::ManagedPolicy.name(), "policy"); - assert_eq!(Layer::CliFlag.name(), "cli"); - assert_eq!(Layer::SessionOverride.name(), "session"); - assert_eq!(Layer::ProjectConfig.name(), "project"); - assert_eq!(Layer::UserConfig.name(), "user"); - assert_eq!(Layer::Default.name(), "default"); - } } diff --git a/crates/config/src/setup_state.rs b/crates/config/src/setup_state.rs index a378a72354..d8d08cde1f 100644 --- a/crates/config/src/setup_state.rs +++ b/crates/config/src/setup_state.rs @@ -935,13 +935,4 @@ mod tests { let path = tmp.path().join("does-not-exist.json"); assert!(SetupState::load_from(&path).is_none()); } - - #[test] - fn step_result_carries_no_secret_by_construction() { - // The result field is a caller-supplied safe summary; this documents the - // contract that callers pass names, not keys. - let entry = verified("0.8.67").with_result("provider: openai, model: mimo"); - let json = serde_json::to_string(&entry).unwrap(); - assert!(!json.to_lowercase().contains("sk-")); - } } diff --git a/crates/core/src/fragments.rs b/crates/core/src/fragments.rs index 1679fb3462..93686d604c 100644 --- a/crates/core/src/fragments.rs +++ b/crates/core/src/fragments.rs @@ -213,8 +213,6 @@ pub enum FragmentCapError { bytes: usize, max: usize, }, - #[error("context has too many fragments: {count} > {max}")] - TooManyFragments { count: usize, max: usize }, } pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapError> { @@ -236,19 +234,6 @@ pub fn validate_fragment(fragment: &BoundedFragment) -> Result<(), FragmentCapEr Ok(()) } -pub fn validate_fragment_set(fragments: &[BoundedFragment]) -> Result<(), FragmentCapError> { - if fragments.len() > MAX_FRAGMENTS_PER_CONTEXT { - return Err(FragmentCapError::TooManyFragments { - count: fragments.len(), - max: MAX_FRAGMENTS_PER_CONTEXT, - }); - } - for f in fragments { - validate_fragment(f)?; - } - Ok(()) -} - // Project-instruction import (#3978) pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[ "AGENTS.md", @@ -266,21 +251,6 @@ pub const PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[ ".github/muse-instructions.md", ]; -/// Workspace instruction formats not already owned by Codewhale's canonical -/// project-context loader. The TUI uses this subset to avoid injecting -/// `AGENTS.md` / `CLAUDE.md` / `instructions.md` twice while still importing -/// additional agent rule formats through the typed fragment boundary. -pub const ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES: &[&str] = &[ - ".agents/AGENTS.md", - ".cursorrules", - ".cursor/rules", - ".clinerules", - ".windsurf/rules", - ".gemini", - ".github/copilot-instructions.md", - ".github/muse-instructions.md", -]; - fn is_symlink(p: &Path) -> bool { std::fs::symlink_metadata(p) .map(|m| m.file_type().is_symlink()) @@ -423,20 +393,6 @@ pub fn load_project_instruction_fragment(workspace: &Path) -> Option Option { - load_project_instruction_fragment_from_candidates( - workspace, - ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES, - ) -} - /// Load exactly the instruction candidates the caller names. /// /// The caller owns the opt-in decision; an empty list imports nothing and @@ -451,75 +407,6 @@ pub fn load_selected_project_instruction_fragment( load_project_instruction_fragment_from_candidates(workspace, candidates) } -pub fn project_instructions_from_sources( - sources: impl IntoIterator, -) -> Option { - let mut sections = Vec::new(); - for (name, content) in sources { - let trimmed = content.trim(); - if trimmed.is_empty() { - continue; - } - let body = if trimmed.len() > INSTRUCTIONS_FILE_MAX_BYTES { - let mut end = INSTRUCTIONS_FILE_MAX_BYTES; - while end > 0 && !trimmed.is_char_boundary(end) { - end -= 1; - } - let omitted = trimmed.len() - end; - format!("{}\n[…truncated: {omitted} bytes omitted]", &trimmed[..end]) - } else { - trimmed.to_string() - }; - sections.push(format!( - "\n{body}\n" - )); - if sections.len() >= MAX_INSTRUCTION_FILES { - break; - } - } - if sections.is_empty() { - return None; - } - Some(BoundedFragment::project_instructions(sections.join("\n\n"))) -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub struct FragmentBudgetSnapshot { - pub fragment_ids: Vec, - pub fragment_markers: Vec, - pub max_fragment_bytes: usize, - pub max_fragment_tokens: usize, - pub default_fragment_max_bytes: usize, - pub max_fragments_per_context: usize, - pub instructions_file_max_bytes: usize, - pub max_instruction_files: usize, - pub project_instruction_candidates: Vec, -} - -#[must_use] -pub fn fragment_budget_snapshot() -> FragmentBudgetSnapshot { - FragmentBudgetSnapshot { - fragment_ids: FragmentId::all() - .iter() - .map(|id| id.as_str().to_string()) - .collect(), - fragment_markers: FragmentId::all() - .iter() - .map(|id| id.marker().to_string()) - .collect(), - max_fragment_bytes: MAX_FRAGMENT_BYTES, - max_fragment_tokens: MAX_FRAGMENT_TOKENS, - default_fragment_max_bytes: DEFAULT_FRAGMENT_MAX_BYTES, - max_fragments_per_context: MAX_FRAGMENTS_PER_CONTEXT, - instructions_file_max_bytes: INSTRUCTIONS_FILE_MAX_BYTES, - max_instruction_files: MAX_INSTRUCTION_FILES, - project_instruction_candidates: PROJECT_INSTRUCTION_CANDIDATES - .iter() - .map(|s| s.to_string()) - .collect(), - } -} - fn hash_content(content: &str) -> u64 { let mut hasher = DefaultHasher::new(); content.hash(&mut hasher); @@ -602,17 +489,6 @@ mod tests { assert!(fragment.is_within_token_ceiling()); } #[test] - fn injected_item_count_cap_is_enforced() { - let fragments: Vec = (0..MAX_FRAGMENTS_PER_CONTEXT) - .map(|i| BoundedFragment::new(FragmentId::Workspace, format!("item {i}"))) - .collect(); - validate_fragment_set(&fragments).expect("exactly MAX_FRAGMENTS must pass"); - let mut too_many = fragments.clone(); - too_many.push(BoundedFragment::new(FragmentId::Route, "one too many")); - let err = validate_fragment_set(&too_many).expect_err("one over cap must fail"); - assert!(matches!(err, FragmentCapError::TooManyFragments { .. })); - } - #[test] #[cfg(unix)] fn symlinked_candidate_directory_cannot_escape_the_workspace() { // `Path::is_dir()` follows symlinks, so a symlinked candidate dir used @@ -677,64 +553,5 @@ mod tests { fragment.content.contains(".cursorrules") || fragment.content.contains(".clinerules") ); validate_fragment(&fragment).expect("project-instructions fragment must satisfy caps"); - let from_sources = project_instructions_from_sources(vec![ - ("AGENTS.md".to_string(), "# AGENTS\nbe helpful".to_string()), - ( - ".cursorrules".to_string(), - "cursor: do the thing".to_string(), - ), - ]) - .expect("sources"); - assert_eq!(from_sources.id, FragmentId::ProjectInstructions); - assert!(from_sources.content.contains("AGENTS.md")); - assert!(from_sources.content.contains(".cursorrules")); - validate_fragment(&from_sources).expect("explicit sources must also satisfy caps"); - } - #[test] - fn additional_project_instruction_import_does_not_duplicate_canonical_authority() { - let dir = tempdir().expect("tempdir"); - let ws = dir.path(); - fs::write(ws.join("AGENTS.md"), "canonical authority marker").expect("write agents"); - - assert!( - load_additional_project_instruction_fragment(ws).is_none(), - "AGENTS.md is already owned by the canonical project-context loader" - ); - - fs::write(ws.join(".cursorrules"), "additional cursor marker").expect("write cursor rules"); - let additional = load_additional_project_instruction_fragment(ws) - .expect("additional rules must produce a typed fragment"); - assert!(additional.content.contains("additional cursor marker")); - assert!(!additional.content.contains("canonical authority marker")); - - let complete = load_project_instruction_fragment(ws) - .expect("complete importer must retain every supported source"); - assert!(complete.content.contains("canonical authority marker")); - assert!(complete.content.contains("additional cursor marker")); - } - #[test] - fn fragment_budget_snapshot_is_stable() { - let snap = fragment_budget_snapshot(); - assert_eq!(snap.max_fragment_tokens, 10_000); - assert_eq!(snap.max_fragment_bytes, 40_000); - assert_eq!(snap.max_fragments_per_context, 16); - assert_eq!(snap.default_fragment_max_bytes, 4 * 1024); - assert!( - snap.fragment_ids - .contains(&"project_instructions".to_string()) - ); - assert!(snap.fragment_ids.contains(&"constitution".to_string())); - assert!( - snap.project_instruction_candidates - .contains(&".cursorrules".to_string()) - ); - assert!( - snap.project_instruction_candidates - .contains(&".github/copilot-instructions.md".to_string()) - ); - assert!( - snap.fragment_markers - .contains(&"".to_string()) - ); } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a70611c685..39a6d93eae 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -205,53 +205,6 @@ pub struct JobRecord { pub updated_at: i64, } -/// Map a durable [`JobRecord`] to the dependency-neutral run read model. -/// -/// Pure projection of the record as persisted: unknown budgets stay unset and -/// nothing is fabricated. `updated_at` (epoch seconds) provides the terminal -/// timestamp because the job manager records no separate end time. The -/// free-form job detail is intentionally omitted because this owner does not -/// classify it as safe for a cross-surface read model. -#[must_use] -pub fn job_record_to_agent_run( - record: &JobRecord, -) -> codewhale_protocol::agent_run::AgentRunSnapshot { - use codewhale_protocol::agent_run::{ - AgentRunSnapshot, BudgetSummary, RunSource, RunState, TerminalOutcome, TerminalSummary, - }; - - let (state, terminal) = match record.status { - JobStatus::Queued => (RunState::Queued, None), - JobStatus::Running => (RunState::Running, None), - JobStatus::Paused => (RunState::Paused, None), - JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled => { - let outcome = match record.status { - JobStatus::Completed => TerminalOutcome::Completed, - JobStatus::Failed => TerminalOutcome::Failed, - _ => TerminalOutcome::Cancelled, - }; - ( - RunState::Terminal, - Some(TerminalSummary { - outcome, - ended_at_ms: record.updated_at.checked_mul(1000), - detail: None, - }), - ) - } - }; - - AgentRunSnapshot { - run_id: record.id.clone(), - parent: None, - source: RunSource::CoreJob, - state, - budget: BudgetSummary::default(), - terminal, - refs: Vec::new(), - } -} - /// Manages background jobs with retry logic and persistence. #[derive(Debug, Default)] pub struct JobManager { @@ -452,20 +405,6 @@ impl JobManager { .unwrap_or_default() } - /// Resets queued or running jobs back to queued on application resume. - pub fn resume_pending(&mut self) -> Vec { - let mut resumed = Vec::new(); - for job in self.jobs.values_mut() { - if matches!(job.status, JobStatus::Queued | JobStatus::Running) { - job.status = JobStatus::Queued; - job.updated_at = Self::now_ts(); - Self::push_history(job, "queued_after_resume"); - resumed.push(job.clone()); - } - } - resumed - } - /// Loads jobs from the state store, deserializing extended detail when available. pub fn load_from_store(&mut self, store: &StateStore) -> Result<()> { let persisted = store.list_jobs(Some(500))?; @@ -2583,25 +2522,6 @@ mod tests { assert!(jm.history("nonexistent").is_empty()); } - #[test] - fn resume_pending_requeues_running_and_queued() { - let mut jm = JobManager::default(); - let _j1 = jm.enqueue("queued_task"); - let j2 = jm.enqueue("running_task"); - let j3 = jm.enqueue("completed_task"); - let id2 = j2.id.clone(); - let id3 = j3.id.clone(); - jm.set_running(&id2); - jm.set_running(&id3); - jm.complete(&id3); - - let resumed = jm.resume_pending(); - assert_eq!(resumed.len(), 2); - for job in &resumed { - assert_eq!(job.status, JobStatus::Queued); - } - } - // ── JobManager: backoff ──────────────────────────────────────────── #[test] @@ -2629,17 +2549,19 @@ mod tests { "attempt {attempt}" ); } - } - - #[test] - fn deterministic_backoff_saturates_at_high_exponent() { - let retry = JobRetryMetadata { + // High attempts must stay finite: the exponent clamps at 20 and the + // multiply saturates, so attempt 63 yields base * 2^20 rather than + // an overflow panic in the retry path. + let saturated = JobRetryMetadata { attempt: 63, backoff_base_ms: 1000, ..Default::default() }; - // Should not panic; result saturates - let _ = JobManager::deterministic_backoff_ms(&retry); + assert_eq!( + JobManager::deterministic_backoff_ms(&saturated), + 1000u64 << 20, + "attempt 63 must saturate, not overflow" + ); } // ── JobManager: history truncation ───────────────────────────────── @@ -2891,106 +2813,6 @@ mod tests { assert_eq!(reloaded_job.status, JobStatus::Paused); } - // ── O1: JobRecord → AgentRunSnapshot adapter ──────────────────────── - - fn sample_job_record(status: JobStatus, detail: Option<&str>) -> JobRecord { - JobRecord { - id: "job-o1-1".to_string(), - name: "sample".to_string(), - status, - progress: None, - detail: detail.map(str::to_string), - retry: JobRetryMetadata { - attempt: 0, - max_attempts: DEFAULT_JOB_MAX_ATTEMPTS, - backoff_base_ms: DEFAULT_JOB_BACKOFF_BASE_MS, - next_backoff_ms: 0, - next_retry_at: None, - }, - history: Vec::new(), - created_at: 1_700_000_000, - updated_at: 1_700_000_042, - } - } - - #[test] - fn job_record_to_agent_run_maps_non_terminal_states() { - use codewhale_protocol::agent_run::RunState; - - for (status, expected) in [ - (JobStatus::Queued, RunState::Queued), - (JobStatus::Running, RunState::Running), - (JobStatus::Paused, RunState::Paused), - ] { - let snapshot = job_record_to_agent_run(&sample_job_record(status, None)); - assert!(snapshot.is_coherent()); - assert_eq!(snapshot.run_id, "job-o1-1"); - assert_eq!(snapshot.parent, None); - assert_eq!( - snapshot.source, - codewhale_protocol::agent_run::RunSource::CoreJob - ); - assert_eq!(snapshot.state, expected); - assert!(snapshot.terminal.is_none()); - assert!(snapshot.refs.is_empty()); - assert_eq!( - snapshot.budget, - codewhale_protocol::agent_run::BudgetSummary::default() - ); - } - } - - #[test] - fn job_record_to_agent_run_maps_terminal_states_without_fabricating_fields() { - use codewhale_protocol::agent_run::{RunState, TerminalOutcome}; - - let cases = [ - ( - JobStatus::Completed, - TerminalOutcome::Completed, - Some("done"), - ), - (JobStatus::Failed, TerminalOutcome::Failed, Some("boom")), - (JobStatus::Cancelled, TerminalOutcome::Cancelled, None), - ]; - - for (status, outcome, detail) in cases { - let snapshot = job_record_to_agent_run(&sample_job_record(status, detail)); - assert!(snapshot.is_coherent()); - assert_eq!(snapshot.state, RunState::Terminal); - let terminal = snapshot.terminal.expect("terminal summary"); - assert_eq!(terminal.outcome, outcome); - assert_eq!(terminal.ended_at_ms, Some(1_700_000_042_000)); - assert_eq!(terminal.detail, None); - assert_eq!( - snapshot.budget, - codewhale_protocol::agent_run::BudgetSummary::default() - ); - assert!(snapshot.refs.is_empty()); - assert_eq!(snapshot.parent, None); - } - } - - #[test] - fn job_record_to_agent_run_does_not_export_unclassified_detail() { - let record = sample_job_record(JobStatus::Failed, Some("owner-private diagnostic")); - let snapshot = job_record_to_agent_run(&record); - let terminal = snapshot.terminal.as_ref().expect("terminal summary"); - assert_eq!(terminal.detail, None); - let serialized = serde_json::to_string(&snapshot).expect("serialize snapshot"); - assert!(!serialized.contains("owner-private diagnostic")); - } - - #[test] - fn job_record_to_agent_run_omits_ended_at_on_updated_at_overflow() { - let mut record = sample_job_record(JobStatus::Completed, Some("ok")); - record.updated_at = i64::MAX; - let snapshot = job_record_to_agent_run(&record); - assert!(snapshot.is_coherent()); - let terminal = snapshot.terminal.expect("terminal summary"); - assert_eq!(terminal.ended_at_ms, None); - } - #[test] fn unarchive_thread_updates_running_threads_cache() { let store = temp_core_state("unarchive-cache"); diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index adfb06991f..c5cccad3d0 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -359,14 +359,4 @@ mod tests { "re-saving a loaded transcript must not change a single byte", ); } - - #[test] - fn primary_turn_preparation_owns_shared_defaults() { - let request = prepare_primary_turn_request(primary_turn()); - assert_eq!(request.stream, Some(true)); - assert!(request.metadata.is_none()); - assert!(request.thinking.is_none()); - assert!(request.temperature.is_none()); - assert!(request.top_p.is_none()); - } } diff --git a/crates/execpolicy/src/lib.rs b/crates/execpolicy/src/lib.rs index 40a6c35dc2..ccc69b8c1e 100644 --- a/crates/execpolicy/src/lib.rs +++ b/crates/execpolicy/src/lib.rs @@ -2558,13 +2558,6 @@ mod tests { } } - #[test] - fn ask_action_default_backward_compatible() { - // Without explicit action, rules default to Ask via serde default. - let rule = ToolAskRule::exec_shell("cargo test"); - assert_eq!(rule.action, PermissionAction::Ask); - } - #[test] fn deny_action_constructors_produce_ask_by_default() { assert_eq!(ToolAskRule::new("exec_shell").action, PermissionAction::Ask); @@ -3110,12 +3103,6 @@ mod tests { assert_eq!(d.requirement.phase(), "forbidden"); } - #[test] - fn ask_is_default_when_action_omitted() { - let rule = ToolAskRule::exec_shell("cargo test"); - assert_eq!(rule.action, PermissionAction::Ask); - } - // ── cross-cutting ───────────────────────────────────────────────────── #[test] diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index fbdfca872f..1714bc07ad 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -604,22 +604,6 @@ impl McpManager { } } } - - /// Generate sandbox state update notices for all registered servers. - pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result> { - let mut notices = Vec::new(); - for server_name in self.configs.keys() { - notices.push(json!({ - "server_name": server_name, - "method": "codex/sandbox-state/update", - "params": { - "sandbox_mode": sandbox_mode, - "cwd": cwd - } - })); - } - Ok(notices) - } } fn default_true() -> bool { @@ -2271,21 +2255,6 @@ mod tests { ); } - #[test] - fn manager_update_sandbox_state_returns_notices() { - let mut manager = McpManager::default(); - manager - .register_server( - make_server_config("s1"), - ToolFilter::default(), - Box::new(InMemoryMcpClient::default()), - ) - .unwrap(); - let notices = manager.update_sandbox_state("strict", "/tmp").unwrap(); - assert_eq!(notices.len(), 1); - assert_eq!(notices[0]["server_name"], "s1"); - } - // ── Tool filter ──────────────────────────────────────────────────── #[test] diff --git a/crates/protocol/src/runtime/mod.rs b/crates/protocol/src/runtime/mod.rs index 10c794c653..9c60451c73 100644 --- a/crates/protocol/src/runtime/mod.rs +++ b/crates/protocol/src/runtime/mod.rs @@ -151,16 +151,6 @@ pub struct DynamicToolSpec { pub defer_loading: bool, } -/// Lifecycle status of a dynamic tool item shown in thread detail and event -/// payloads. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum DynamicToolItemStatus { - InProgress, - Completed, - Failed, -} - /// Parameters identifying a dynamic tool call request emitted by the runtime. /// /// This is the typed payload for `tool_call.requested` events and also the @@ -266,22 +256,6 @@ mod tests { assert!(!spec.defer_loading); } - #[test] - fn dynamic_tool_item_status_snake_case() { - assert_eq!( - serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(), - "\"in_progress\"" - ); - assert_eq!( - serde_json::from_str::("\"completed\"").unwrap(), - DynamicToolItemStatus::Completed - ); - assert_eq!( - serde_json::from_str::("\"failed\"").unwrap(), - DynamicToolItemStatus::Failed - ); - } - #[test] fn dynamic_tool_call_params_roundtrip() { let params = DynamicToolCallParams { diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs index 5edfd9c8e9..7b404f4036 100644 --- a/crates/secrets/src/lib.rs +++ b/crates/secrets/src/lib.rs @@ -2390,24 +2390,6 @@ mod tests { assert_eq!(store.get("deepseek").unwrap(), Some("sk-fresh".to_string())); } - #[test] - fn file_store_default_path_uses_home() { - let _lock = env_lock(); - clear_known_envs(); - let tmp = tempfile::tempdir().unwrap(); - let _home = EnvVarGuard::set("HOME", tmp.path()); - let _userprofile = EnvVarGuard::set("USERPROFILE", tmp.path()); - - let path = FileKeyringStore::default_path().unwrap(); - assert_eq!( - path, - tmp.path() - .join(".codewhale") - .join("secrets") - .join("secrets.json") - ); - } - #[test] fn default_path_with_explicit_codewhale_home_does_not_migrate_ambient_legacy() { // FR003-C001: explicit CODEWHALE_HOME must not silently import ambient diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index f8e9a5be5f..ed7694ad3e 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -4757,64 +4757,6 @@ mod tests { } } - #[tokio::test] - async fn non_streaming_envelope_bounds_a_stalled_provider() { - // The injected budget is process-global; serialize against other - // tests (which may issue non-streaming requests with their own - // timing assumptions) through the shared test-env lock. - let _env_lock = crate::test_support::lock_test_env(); - let _envelope = NonStreamingEnvelopeGuard::millis(2000); - let server = MockServer::start().await; - // The provider accepts the connection but stalls far past the - // budgeted envelope before answering: the whole request (headers - // and body) must be cut off with a timeout instead of wedging the - // caller. - Mock::given(method("POST")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(json!({ - "model": "deepseek-v4-pro", - "choices": [ - { "message": { "role": "assistant", "content": "late" } } - ] - })) - .set_delay(Duration::from_secs(5)), - ) - .mount(&server) - .await; - - let client = deepseek_request_boundary_client(&server.uri(), server.uri()); - let request = MessageRequest { - model: "deepseek-v4-pro".to_string(), - messages: vec![Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: "envelope".to_string(), - cache_control: None, - }], - }], - max_tokens: 16, - system: None, - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("off".to_string()), - stream: Some(false), - temperature: None, - top_p: None, - }; - - let err = client - .create_message(request) - .await - .expect_err("a provider that never answers must hit the envelope"); - assert!( - err.to_string().to_lowercase().contains("timed out"), - "envelope timeout must be reported as such; got {err:#}" - ); - } - #[tokio::test] async fn envelope_exceed_probes_recovery_once_degraded() { // A provider that wedges past the whole envelope must not leave @@ -7755,16 +7697,23 @@ mod tests { let _guard = crate::tools::truncate::TEST_SPILLOVER_GUARD .lock() .unwrap_or_else(|err| err.into_inner()); + let _artifact_guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD + .lock() + .unwrap_or_else(|err| err.into_inner()); let tmp = tempfile::tempdir().expect("tempdir"); let spillover_root = tmp.path().join(".codewhale").join("tool_outputs"); + let sessions_root = tmp.path().join(".codewhale").join("sessions"); let prior = crate::tools::truncate::set_test_spillover_root(Some(spillover_root.clone())); - struct Restore(Option); + let prior_artifacts = + crate::artifacts::set_test_artifact_sessions_root(Some(sessions_root.clone())); + struct Restore(Option, Option); impl Drop for Restore { fn drop(&mut self) { crate::tools::truncate::set_test_spillover_root(self.0.take()); + crate::artifacts::set_test_artifact_sessions_root(self.1.take()); } } - let _restore = Restore(prior); + let _restore = Restore(prior, prior_artifacts); let head = (0..40) .map(|_| format!("{}\n", "safe-head".repeat(100))) @@ -7779,15 +7728,24 @@ mod tests { ); let mut spilled = crate::tools::spec::ToolResult::success(raw.clone()); - let path = crate::tools::truncate::apply_spillover(&mut spilled, "call-local-secret") - .expect("turn-loop spillover"); - crate::tools::truncate::publish_legacy_spillover_ownership( - &path, + // The plain spillover path is the legacy routing arm of + // `apply_spillover_with_artifact`, reached via the classic rollback + // switch (the old `apply_spillover` wrapper was removed). + // SAFETY: single-threaded test; no other thread reads env concurrently. + unsafe { std::env::set_var("CODEWHALE_CLASSIC_OUTPUT_ROUTING", "1") }; + let path = crate::tools::truncate::apply_spillover_with_artifact( + &mut spilled, + "call-local-secret", + "exec_shell", "workspace", - raw.as_bytes(), ) - .expect("publish compatibility ownership proof"); - assert_eq!(path.parent(), Some(spillover_root.as_path())); + .expect("turn-loop spillover"); + unsafe { std::env::remove_var("CODEWHALE_CLASSIC_OUTPUT_ROUTING") }; + // Classic routing still returns the session artifact handle; the + // legacy payload lands under the spillover root and the ownership + // sidecar is published by `apply_spillover_with_artifact` itself. + let expected_artifacts = sessions_root.join("workspace").join("artifacts"); + assert_eq!(path.parent(), Some(expected_artifacts.as_path())); assert!( std::fs::read_to_string(&path) .expect("read local spillover") @@ -10951,56 +10909,6 @@ mod tests { ); } - #[test] - fn chat_messages_keeps_valid_tool_calls_intact() { - // Complete call+result pair should NOT be stripped. - let messages = vec![ - Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Thinking { - signature: None, - state: None, - thinking: "Need to list files".to_string(), - redacted_data: None, - reasoning_details: None, - }, - ContentBlock::ToolUse { - id: "tool-ok".to_string(), - name: "list_dir".to_string(), - input: json!({}), - caller: None, - thought_signature: None, - }, - ], - }, - Message { - role: Role::User, - content: vec![ContentBlock::ToolResult { - tool_use_id: "tool-ok".to_string(), - content: "files".to_string(), - is_error: None, - content_blocks: None, - }], - }, - ]; - - let out = build_chat_messages(None, &messages, "deepseek-v4-flash"); - let assistant = out - .iter() - .find(|value| value.get("role").and_then(Value::as_str) == Some("assistant")) - .expect("assistant message"); - assert!( - assistant.get("tool_calls").is_some(), - "valid tool_calls should remain intact" - ); - assert!( - out.iter() - .any(|value| value.get("role").and_then(Value::as_str) == Some("tool")), - "tool result should remain" - ); - } - #[test] fn chat_messages_strips_partial_tool_results() { let messages = vec![ diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 4546127f1f..68e8b9460f 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -2562,20 +2562,6 @@ mod tests { assert_eq!(usage.prompt_cache_write_tokens, Some(0)); } - #[test] - fn usage_mapping_keeps_cache_write_separate_from_miss() { - let usage = parse_anthropic_usage(&json!({ - "input_tokens": 3, - "cache_creation_input_tokens": 2045, - "cache_read_input_tokens": 18000, - "output_tokens": 1, - })); - assert_eq!(usage.input_tokens, 3 + 2045 + 18000); - assert_eq!(usage.prompt_cache_hit_tokens, Some(18000)); - assert_eq!(usage.prompt_cache_miss_tokens, Some(3)); - assert_eq!(usage.prompt_cache_write_tokens, Some(2045)); - } - #[test] fn error_envelope_parses_type_and_message() { let (error_type, message) = parse_anthropic_error_envelope( diff --git a/crates/tui/src/client/provider_native_search/kimi.rs b/crates/tui/src/client/provider_native_search/kimi.rs index 024593ad7b..2ace0102f5 100644 --- a/crates/tui/src/client/provider_native_search/kimi.rs +++ b/crates/tui/src/client/provider_native_search/kimi.rs @@ -326,10 +326,6 @@ mod tests { #[test] fn kimi_code_request_and_structured_response_contract() { - let body = json!({ "text_query": request().query }); - assert_eq!(body["text_query"], "current release"); - assert_eq!(body.as_object().map(serde_json::Map::len), Some(1)); - let parsed = parse_kimi_code(&json!({ "search_results": [{ "title": "Kimi", diff --git a/crates/tui/src/client/wire.rs b/crates/tui/src/client/wire.rs index c7a0a97822..e49445b22e 100644 --- a/crates/tui/src/client/wire.rs +++ b/crates/tui/src/client/wire.rs @@ -499,16 +499,6 @@ mod tests { "invalid line is consumed so retries cannot loop" ); } - // from take_sse_line_rejects_invalid_bytes_without_replacement - { - let mut buffer = b"data: ok".to_vec(); - buffer.push(0xFF); - buffer.extend_from_slice(b"\n"); - let err = take_sse_line(&mut buffer).expect_err("0xFF is not UTF-8"); - assert_eq!(err.valid_up_to, 8); - assert!(!err.to_string().contains('\u{FFFD}')); - assert!(buffer.is_empty(), "invalid line must be drained"); - } } #[test] diff --git a/crates/tui/src/cloud_dispatch.rs b/crates/tui/src/cloud_dispatch.rs index c4edfa6743..fd8946995e 100644 --- a/crates/tui/src/cloud_dispatch.rs +++ b/crates/tui/src/cloud_dispatch.rs @@ -15,10 +15,6 @@ //! the persisted contract, the launcher seam, and the fail-closed gates. //! Auto-decide heuristics remain leftover: Codewhale may propose, never //! confirm itself. -//! -//! A sandbox create receipt is infrastructure identity, not Computer -//! entitlement. Metering requires [`crate::computer_meter`] admission plus a -//! provider-accepted active observation. use std::fs; use std::io::Write; @@ -31,11 +27,6 @@ use codewhale_paths::codewhale_home; use codewhale_secrets::Secrets; use serde::{Deserialize, Serialize}; -use crate::computer_meter::{ - ComputerAdmission, ComputerMeterError, ComputerMeterReceipt, ProviderObservation, - issue_computer_meter_receipt, -}; - const MAX_PROMPT_CHARS: usize = 4_000; const MAX_REMOTE_BYTES: usize = 4 * 1024; const JOB_KIND: &str = "cloud"; @@ -623,11 +614,6 @@ fn membership_signed_in() -> bool { matches!(store.load(), Ok(Some(_))) } -/// Auto-decide leftover: Codewhale may propose, but never confirm itself. -pub fn should_auto_confirm(_plan: &DispatchPlan) -> bool { - false -} - /// Presence of the Codewhale account machine token that authenticates the /// IN-SANDBOX agent. Mirrors [`CredentialState`]: a fact about availability, /// never the value — the token is used in the create body and nothing else. @@ -1334,27 +1320,6 @@ pub fn validate_outbound_origin(raw: &str) -> Result { Ok(url) } -/// Meter one closed interval on a dispatched cloud job. -/// -/// The job's sandbox id must match the provider observation. Wall-clock after -/// create is not enough: the observation has to be provider-accepted active -/// time bound to the immutable admission. -pub fn meter_cloud_job( - job: &CloudJob, - admission: &ComputerAdmission, - observation: ProviderObservation, -) -> Result { - match job.sandbox_id.as_deref() { - Some(sandbox_id) if sandbox_id == observation.provider_sandbox_id => { - issue_computer_meter_receipt(admission, observation) - } - _ => Err(ComputerMeterError::AllocationMismatch { - message: "Cloud job sandbox id does not match the provider allocation snapshot." - .to_string(), - }), - } -} - /// Real Daytona HTTP launcher. Fails closed on every step; never invents a /// PR URL; never logs or returns the API key. /// @@ -2285,15 +2250,6 @@ mod tests { assert!(job.sandbox_id.is_none()); assert!(job.pr_url.is_none()); assert_eq!(job.kind, "cloud"); - assert!(!should_auto_confirm(&DispatchPlan { - prompt: job.prompt.clone(), - remote: SelectedRemote { - forge: job.forge, - name: job.remote_name.clone(), - url: job.remote_url.clone(), - }, - branch: job.branch.clone(), - })); } #[test] @@ -3270,85 +3226,4 @@ mod tests { assert!(!warning.contains("cloud_00000000000000ab")); assert!(!warning.contains("cloud_00000000000000ac")); } - - #[test] - fn sandbox_create_is_not_computer_entitlement() { - use crate::computer_meter::{ - ComputerAdmissionRequest, MeterBasis, bind_computer_admission, - }; - - let temp = tempfile::tempdir().unwrap(); - let store = CloudJobStore::from_path(temp.path().join("jobs")); - let plan = plan_dispatch( - &remotes(&[("github", "https://github.com/org/repo.git")]), - "meter honesty", - Some(Forge::Github), - Some("codewhale/cloud-meter"), - ) - .unwrap(); - let outcome = execute_dispatch( - &store, - plan, - true, - &CredentialState::Present { - source: CredentialSource::Keyring, - }, - &MachineTokenState::Present, - ) - .unwrap(); - let DispatchOutcome::Accepted(mut job) = outcome else { - panic!("expected accept"); - }; - job.sandbox_id = Some("sbox_std8_a".to_string()); - let admission = bind_computer_admission(ComputerAdmissionRequest { - admission_id: "adm_dispatch".to_string(), - account_id: "acct_demo".to_string(), - computer_id: "cmp_dispatch".to_string(), - run_id: job.id.clone(), - provider: "daytona".to_string(), - profile_id: "standard-8".to_string(), - funding_authority: "coding_membership_included".to_string(), - quote_id: "quote_dispatch".to_string(), - expires_at: "2026-08-31T18:00:00.000Z".to_string(), - meter_revision: String::new(), - catalog_revision: String::new(), - }) - .unwrap(); - let idle = ProviderObservation { - provider: "daytona".to_string(), - provider_sandbox_id: "sbox_std8_a".to_string(), - provider_event_ref: "daytona:sbox_std8_a:idle".to_string(), - state: "running".to_string(), - idle: true, - provider_accepted: true, - meter_basis: MeterBasis::WallClock, - cpu: 2, - memory_gib: 8, - disk_gib: 8, - started_at: "2026-08-31T12:00:00.000Z".to_string(), - ended_at: "2026-08-31T13:00:00.000Z".to_string(), - }; - assert_eq!( - meter_cloud_job(&job, &admission, idle).unwrap_err().code(), - "computer_meter_wall_clock_idle" - ); - let accepted = ProviderObservation { - provider: "daytona".to_string(), - provider_sandbox_id: "sbox_std8_a".to_string(), - provider_event_ref: "daytona:sbox_std8_a:running".to_string(), - state: "running".to_string(), - idle: false, - provider_accepted: true, - meter_basis: MeterBasis::ProviderAcceptedActive, - cpu: 2, - memory_gib: 8, - disk_gib: 8, - started_at: "2026-08-31T12:00:00.000Z".to_string(), - ended_at: "2026-08-31T12:10:00.000Z".to_string(), - }; - let receipt = meter_cloud_job(&job, &admission, accepted).unwrap(); - assert_eq!(receipt.accepted_seconds, 600); - assert_eq!(receipt.standard_equivalent_seconds, 600); - assert_eq!(receipt.admission_id, admission.admission_id); - } } diff --git a/crates/tui/src/commands/groups/core/hooks.rs b/crates/tui/src/commands/groups/core/hooks.rs index 5675c83156..400ccfd198 100644 --- a/crates/tui/src/commands/groups/core/hooks.rs +++ b/crates/tui/src/commands/groups/core/hooks.rs @@ -530,38 +530,6 @@ mod tests { ); } - #[test] - fn list_renders_hooks_grouped_by_event_and_notes_disabled_state() { - // We test the formatter directly via a synthetic HooksConfig - // because `App` is heavyweight to spin up here. The actual - // `list(&App)` path is exercised once we hand the real - // config in via `app.hooks.config()`; the formatter logic is - // unit-tested standalone below. - let cfg = crate::hooks::HooksConfig { - enabled: false, - hooks: vec![ - Hook::new(HookEvent::SessionStart, "echo started").with_name("greet"), - Hook::new(HookEvent::ToolCallAfter, "notify-send done") - .with_condition(HookCondition::ToolName { - name: "exec_shell".into(), - }) - .with_name("notify"), - ], - ..crate::hooks::HooksConfig::default() - }; - - // Synthesize the expected sections by re-running the same - // formatter logic against the BTreeMap grouping. - let mut by_event: std::collections::BTreeMap<&str, Vec<&Hook>> = - std::collections::BTreeMap::new(); - for h in &cfg.hooks { - by_event.entry(event_label(h.event)).or_default().push(h); - } - let events: Vec<&&str> = by_event.keys().collect(); - // BTreeMap sorts alphabetically — `session_start` before `tool_call_after`. - assert_eq!(events, vec![&"session_start", &"tool_call_after"]); - } - #[test] fn events_listing_states_scope_and_the_steering_allowlist() { let body = events().message.expect("non-empty body"); diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 1b299ce297..a7cfd9033e 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -805,10 +805,4 @@ mod tests { ("resend it to the queue", false) ); } - - #[test] - fn recorder_detection_does_not_crash() { - // Just verify the function runs without panicking - let _ = is_available(); - } } diff --git a/crates/tui/src/commands/groups/session/session.rs b/crates/tui/src/commands/groups/session/session.rs index e7350af888..362212a2e6 100644 --- a/crates/tui/src/commands/groups/session/session.rs +++ b/crates/tui/src/commands/groups/session/session.rs @@ -1005,17 +1005,6 @@ mod tests { assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home); } - #[test] - fn test_save_serialization_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - // This should work normally since SavedSession is serializable - // Testing error path would require mocking, which is complex - let save_path = tmpdir.path().join("test.json"); - let result = save(&mut app, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - } - #[test] fn test_load_without_path_returns_error() { let tmpdir = TempDir::new().unwrap(); diff --git a/crates/tui/src/commands/user_commands.rs b/crates/tui/src/commands/user_commands.rs index 08c65eb516..8a0900a350 100644 --- a/crates/tui/src/commands/user_commands.rs +++ b/crates/tui/src/commands/user_commands.rs @@ -379,13 +379,6 @@ mod tests { ); } - #[test] - fn test_load_user_commands_when_no_dir_exists() { - let cmds = load_user_commands(None); - // Should not panic; returns empty vec when no directories exist. - assert!(cmds.is_empty() || !cmds.is_empty()); - } - #[test] fn test_try_dispatch_nonexistent_command() { use crate::config::Config; @@ -486,16 +479,6 @@ mod tests { ); } - #[test] - fn load_user_commands_without_workspace_falls_back_to_global_only() { - // When no workspace is passed, only global command directories are - // scanned. On test machines these often don't exist, so we just - // verify we don't panic. - let cmds = load_user_commands(None); - // This should not panic; can be empty or have user's real commands. - let _ = cmds; - } - #[test] fn try_dispatch_uses_workspace_local_command() { use crate::config::Config; diff --git a/crates/tui/src/commands/user_registry.rs b/crates/tui/src/commands/user_registry.rs index 1f2da90572..c32d6c4951 100644 --- a/crates/tui/src/commands/user_registry.rs +++ b/crates/tui/src/commands/user_registry.rs @@ -956,13 +956,6 @@ mod tests { assert_eq!(registry.get("/other").unwrap().body, "Body"); } - #[test] - fn reload_and_current_registry_compile_sentinel() { - reload(None); - let registry = current_registry(); - assert!(registry.is_valid()); - } - fn write_workspace_command(workspace: &Path, name: &str, content: &str) { let dir = workspace.join(".codewhale").join("commands"); std::fs::create_dir_all(&dir).expect("create commands dir"); diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index d0aa802886..ba2db64248 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -2337,18 +2337,6 @@ mod tests { .unwrap(); } - #[test] - fn coverage_floor_rejects_a_replacement_that_drops_last_round_assistant() { - let original = vec![ - msg("user", "What failed?"), - msg("assistant", "session_store::roundtrip panics on reload."), - ]; - let gutting = vec![msg("user", "What failed?")]; - let error = last_round::validate_last_round_coverage(&original, &gutting) - .expect_err("dropping last-round assistant text must fail closed"); - assert!(error.to_string().contains("assistant"), "{error}"); - } - #[test] fn summary_quality_gate_rejects_empty_and_known_placeholder_text() { for summary in [ @@ -3273,18 +3261,4 @@ mod tests { .collect(); assert!(should_compact(&messages, None, &prepared(&config))); } - - #[test] - fn test_compaction_result_retries_used() { - // This test verifies the CompactionResult structure - let result = CompactionResult { - messages: vec![], - summary_prompt: None, - retries_used: 2, - coverage: CompactionCoverage::default(), - }; - - assert_eq!(result.retries_used, 2); - assert!(result.messages.is_empty()); - } } diff --git a/crates/tui/src/computer_meter.rs b/crates/tui/src/computer_meter.rs deleted file mode 100644 index 74c1fe4423..0000000000 --- a/crates/tui/src/computer_meter.rs +++ /dev/null @@ -1,768 +0,0 @@ -//! Provider-accepted Computer active-second receipts. -//! -//! **Scope: this module is not billing input.** The control plane in -//! codewhale-apps is the billing authority. It writes sandbox state intervals -//! itself and derives receipts server-side from them; it never accepts a -//! receipt from a client, correctly, because the CLI runs on the customer's -//! machine. Anything issued here is a local display or self-check. -//! -//! "Codewhale is the billing authority" below is a statement about *what is -//! billable*, not about which service decides: entitlement moves for -//! provider-accepted **active** seconds rather than Daytona's provisioned wall -//! clock. An audit read it as core-versus-control-plane and carried that -//! misreading into codewhale-apps, where it shaped a PR before it was caught. -//! -//! Codewhale is the billing authority over the *measure*. Daytona (or a future -//! adapter) supplies infrastructure only. Entitlement moves solely for -//! provider-accepted *active* seconds, per second, multiplied by the selected -//! profile's 1x/2x/4x rate. -//! -//! **Unwired as of 2026-09-01.** The only consumer, `cloud_dispatch:: -//! meter_cloud_job`, is itself called only from `#[cfg(test)] mod tests` -//! (cloud_dispatch.rs:1210 and :1227, inside the module opening at :917). No -//! `ComputerMeterReceipt` is transmitted anywhere. This is a complete -//! implementation that was never connected; it must be deliberately wired as a -//! local self-check or deleted, because a dead module that reads as -//! authoritative is exactly how the drift above happened. -//! -//! Wall-clock-if-idle, requested, queued, rejected, stopped, suspended, -//! archived, failed-before-acceptance, and teardown-tail time cannot mint a -//! receipt. Provider-observed CPU/RAM/disk must equal the admitted profile. -//! Corrections are append-only and preserve the original receipt. - -use chrono::{DateTime, SecondsFormat, Utc}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::hashing::sha256_hex; - -/// Pinned v3 meter revision. Bump only with a catalog owner change. -pub const COMPUTER_METER_REVISION: &str = "computer-meter-v3.20260831"; -/// Pinned v3 profile catalog revision. -pub const COMPUTER_CATALOG_REVISION: &str = "computer-profiles-v3.20260831"; -/// Admission record schema. -pub const COMPUTER_ADMISSION_SCHEMA: &str = "codewhale.computer-admission/v1"; -/// Meter receipt schema. -pub const COMPUTER_METER_RECEIPT_SCHEMA: &str = "codewhale.computer-meter-receipt/v1"; -/// Receipt kind for one accepted active interval. -pub const COMPUTER_METER_RECEIPT_KIND: &str = "computer.meter.active_seconds"; - -const ADMISSION_DIGEST_NS: &str = "codewhale/computer-admission/v1"; -const RECEIPT_DIGEST_NS: &str = "codewhale/computer-meter-receipt/v1"; -const MAX_REF_CHARS: usize = 240; - -/// Ratified v3 launch profiles. Historic `standard`/`large`/`xl` decode only. -pub const COMPUTER_PROFILES: [ComputerProfile; 3] = [ - ComputerProfile { - id: ComputerProfileId::Standard8, - label: "8 GB", - cpu: 2, - memory_gib: 8, - disk_gib: 8, - multiplier: 1, - }, - ComputerProfile { - id: ComputerProfileId::Standard16, - label: "16 GB", - cpu: 4, - memory_gib: 16, - disk_gib: 16, - multiplier: 2, - }, - ComputerProfile { - id: ComputerProfileId::Standard32, - label: "32 GB", - cpu: 8, - memory_gib: 32, - disk_gib: 32, - multiplier: 4, - }, -]; - -/// Selectable, quotable, creatable v3 Computer profile. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum ComputerProfileId { - /// 2 vCPU / 8 GiB RAM / 8 GiB disk at 1x. - #[serde(rename = "standard-8")] - Standard8, - /// 4 vCPU / 16 GiB RAM / 16 GiB disk at 2x. - #[serde(rename = "standard-16")] - Standard16, - /// 8 vCPU / 32 GiB RAM / 32 GiB disk at 4x. - #[serde(rename = "standard-32")] - Standard32, -} - -impl ComputerProfileId { - /// Stable catalog id. - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Standard8 => "standard-8", - Self::Standard16 => "standard-16", - Self::Standard32 => "standard-32", - } - } -} - -/// Fixed resource envelope and allowance multiplier. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ComputerProfile { - /// Catalog id. - pub id: ComputerProfileId, - /// Customer label. - pub label: &'static str, - /// vCPU count. - pub cpu: u32, - /// RAM in GiB. - #[serde(rename = "memoryGiB")] - pub memory_gib: u32, - /// Disk in GiB. - #[serde(rename = "diskGiB")] - pub disk_gib: u32, - /// Standard-equivalent multiplier (1, 2, or 4). - pub multiplier: u32, -} - -/// How an interval claims to have been measured. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum MeterBasis { - /// Provider confirmed the allocation was actively running. - ProviderAcceptedActive, - /// Wall-clock elapsed time. Never entitlement. - WallClock, -} - -/// Immutable pre-dispatch binding. CWC remains the commercial owner; Engine -/// refuses to meter anything that is not this record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ComputerAdmission { - /// Schema id. - pub schema_id: String, - /// Caller-supplied admission identity. - pub admission_id: String, - /// Account that funds the Computer. - pub account_id: String, - /// Durable Computer id. - pub computer_id: String, - /// Optional run this admission authorizes. - #[serde(default)] - pub run_id: String, - /// Infrastructure provider (currently `daytona`). - pub provider: String, - /// Selected v3 profile. - pub profile_id: ComputerProfileId, - /// Bound vCPU. - pub cpu: u32, - /// Bound RAM GiB. - #[serde(rename = "memoryGiB")] - pub memory_gib: u32, - /// Bound disk GiB. - #[serde(rename = "diskGiB")] - pub disk_gib: u32, - /// Bound multiplier. - pub multiplier: u32, - /// Meter revision at bind time. - pub meter_revision: String, - /// Catalog revision at bind time. - pub catalog_revision: String, - /// Funding authority (membership included seconds or a time pack). - pub funding_authority: String, - /// Quote identity bound before dispatch. - pub quote_id: String, - /// Admission expiry (inclusive bound is refused). - pub expires_at: String, - /// Digest of the bound fields. - pub binding_digest: String, -} - -/// Provider-observed allocation at one instant. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderAllocation { - /// Provider name. - pub provider: String, - /// Provider sandbox / allocation id. - pub provider_sandbox_id: String, - /// Observed vCPU. - pub cpu: u32, - /// Observed RAM GiB. - #[serde(rename = "memoryGiB", alias = "memoryGb")] - pub memory_gib: u32, - /// Observed disk GiB. - #[serde(rename = "diskGiB", alias = "diskGb")] - pub disk_gib: u32, - /// Provider lifecycle state. - pub state: String, - /// Whether the provider accepted this as a live allocation. - pub accepted: bool, -} - -/// One closed provider observation used to mint a receipt. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ProviderObservation { - /// Infrastructure provider. - pub provider: String, - /// Provider sandbox / allocation id. - pub provider_sandbox_id: String, - /// Idempotent provider event / interval reference. - pub provider_event_ref: String, - /// Provider lifecycle state. - pub state: String, - /// True when the allocation is allocated but idle. - #[serde(default)] - pub idle: bool, - /// True only after the provider accepted the live allocation. - #[serde(default)] - pub provider_accepted: bool, - /// Measurement basis. Wall-clock is never entitlement. - pub meter_basis: MeterBasis, - /// Observed vCPU. - pub cpu: u32, - /// Observed RAM GiB. - #[serde(rename = "memoryGiB", alias = "memoryGb")] - pub memory_gib: u32, - /// Observed disk GiB. - #[serde(rename = "diskGiB", alias = "diskGb")] - pub disk_gib: u32, - /// Interval start (inclusive). - pub started_at: String, - /// Interval end (exclusive). - pub ended_at: String, -} - -/// Immutable receipt for one provider-accepted active interval. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ComputerMeterReceipt { - /// Schema id. - pub schema_id: String, - /// Receipt identity derived from the bound work. - pub receipt_id: String, - /// Receipt kind. - pub kind: String, - /// Funding account. - pub account_id: String, - /// Run, when the interval is run-scoped. - #[serde(default)] - pub run_id: String, - /// Computer id. - pub computer_id: String, - /// Admission this interval was authorized under. - pub admission_id: String, - /// Provider name. - pub provider: String, - /// Profile billed at. - pub profile_id: ComputerProfileId, - /// Multiplier billed at. - pub multiplier: u32, - /// Bound vCPU. - pub cpu: u32, - /// Bound RAM GiB. - #[serde(rename = "memoryGiB")] - pub memory_gib: u32, - /// Bound disk GiB. - #[serde(rename = "diskGiB")] - pub disk_gib: u32, - /// Meter revision. - pub meter_revision: String, - /// Catalog revision. - pub catalog_revision: String, - /// Funding authority copied from admission. - pub funding_authority: String, - /// Quote identity copied from admission. - pub quote_id: String, - /// Interval start. - pub started_at: String, - /// Interval end. - pub ended_at: String, - /// Provider-accepted active whole seconds. - pub accepted_seconds: u64, - /// `accepted_seconds * multiplier`. - pub standard_equivalent_seconds: u64, - /// Allocation snapshot the provider accepted. - pub provider_allocation: ProviderAllocation, - /// Provider event / interval reference. - pub provider_event_ref: String, - /// Prior receipt this exact replay matched. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub replay_of: Option, - /// Original receipt this append-only correction restates. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub correction_of: Option, - /// Prior receipt ids in this lineage. - #[serde(default)] - pub lineage: Vec, - /// Digest of the bound receipt fields. - pub binding_digest: String, -} - -/// Inputs required to bind an admission before dispatch. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ComputerAdmissionRequest { - /// Caller-supplied admission identity. - pub admission_id: String, - /// Account that funds the Computer. - pub account_id: String, - /// Durable Computer id. - pub computer_id: String, - /// Optional run. - #[serde(default)] - pub run_id: String, - /// Infrastructure provider. - pub provider: String, - /// Selected profile id (`standard-8` / `standard-16` / `standard-32`). - pub profile_id: String, - /// Funding authority. - pub funding_authority: String, - /// Quote identity. - pub quote_id: String, - /// Expiry timestamp. - pub expires_at: String, - /// Optional meter revision; must match v3 when supplied. - #[serde(default)] - pub meter_revision: String, - /// Optional catalog revision; must match v3 when supplied. - #[serde(default)] - pub catalog_revision: String, -} - -/// Fail-closed Computer meter errors. -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ComputerMeterError { - /// Profile id is not a v3 launch profile. - #[error("{message}")] - ProfileUnknown { message: String }, - /// Historic Large/XL/Auto/standard aliases cannot admit new work. - #[error("{message}")] - HistoricProfileNotAdmissible { message: String }, - /// Required identity field is missing or hostile. - #[error("{message}")] - ReferenceInvalid { message: String }, - /// Timestamp is not RFC 3339. - #[error("{message}")] - TimestampInvalid { message: String }, - /// Interval ends before it starts. - #[error("A Computer meter interval cannot end before it starts.")] - IntervalReversed, - /// Admission has expired before the interval started. - #[error("The Computer admission expired before this interval.")] - AdmissionExpired, - /// Meter or catalog revision is not the pinned v3 revision. - #[error("{message}")] - RevisionMismatch { message: String }, - /// Caller asked to meter wall-clock, including idle wall-clock. - #[error( - "Computer entitlement meters provider-accepted active seconds only, never wall-clock-if-idle." - )] - WallClockIdle, - /// Interval is not a provider-accepted active state. - #[error("{message}")] - NotProviderAcceptedActive { message: String }, - /// Observed allocation is not exactly the admitted profile. - #[error("{message}")] - AllocationMismatch { message: String }, - /// Replay disagrees with the original bound receipt. - #[error("This Computer meter receipt is already bound to different terms.")] - ReplayConflict, -} - -impl ComputerMeterError { - /// Stable error code for tests and CWC. - #[must_use] - pub fn code(&self) -> &'static str { - match self { - Self::ProfileUnknown { .. } => "computer_profile_unknown", - Self::HistoricProfileNotAdmissible { .. } => "computer_profile_historic_not_admissible", - Self::ReferenceInvalid { .. } => "computer_meter_reference_invalid", - Self::TimestampInvalid { .. } => "computer_meter_timestamp_invalid", - Self::IntervalReversed => "computer_meter_interval_reversed", - Self::AdmissionExpired => "computer_admission_expired", - Self::RevisionMismatch { .. } => "computer_meter_revision_mismatch", - Self::WallClockIdle => "computer_meter_wall_clock_idle", - Self::NotProviderAcceptedActive { .. } => "computer_meter_not_provider_accepted_active", - Self::AllocationMismatch { .. } => "computer_meter_allocation_mismatch", - Self::ReplayConflict => "computer_meter_receipt_replay_conflict", - } - } -} - -/// Decode a profile for reading historic or v3 ids. Does not authorize admission. -pub fn decode_computer_profile(input: &str) -> Result { - match normalize_token(input).as_str() { - "standard-8" | "standard" | "8" | "8gb" | "8gib" => Ok(COMPUTER_PROFILES[0]), - "standard-16" | "large" | "16" | "16gb" | "16gib" => Ok(COMPUTER_PROFILES[1]), - "standard-32" | "xl" | "32" | "32gb" | "32gib" => Ok(COMPUTER_PROFILES[2]), - other => Err(ComputerMeterError::ProfileUnknown { - message: format!("Unknown Computer profile: {other}."), - }), - } -} - -/// Resolve a profile that may be used to create, quote, resume, or bill v3 work. -pub fn admit_computer_profile(input: &str) -> Result { - let normalized = normalize_token(input); - match normalized.as_str() { - "standard-8" | "standard-16" | "standard-32" => decode_computer_profile(&normalized), - "standard" | "large" | "xl" | "auto" => { - Err(ComputerMeterError::HistoricProfileNotAdmissible { - message: format!( - "Historic Computer profile `{normalized}` remains readable but cannot admit, resume, or meter new v3 work." - ), - }) - } - other => Err(ComputerMeterError::ProfileUnknown { - message: format!("Unknown Computer profile: {other}."), - }), - } -} - -/// Bind provider, profile, resources, multiplier, revisions, account, funding, -/// quote, and expiry before dispatch. -pub fn bind_computer_admission( - request: ComputerAdmissionRequest, -) -> Result { - let profile = admit_computer_profile(&request.profile_id)?; - let admission_id = require_ref(&request.admission_id, "admissionId")?; - let account_id = require_ref(&request.account_id, "accountId")?; - let computer_id = require_ref(&request.computer_id, "computerId")?; - let run_id = optional_ref(&request.run_id, "runId")?; - let provider = require_ref(&request.provider, "provider")?; - let funding_authority = require_ref(&request.funding_authority, "fundingAuthority")?; - let quote_id = require_ref(&request.quote_id, "quoteId")?; - let expires_at = normalize_timestamp(&request.expires_at, "expiresAt")?; - let meter_revision = require_revision(&request.meter_revision, COMPUTER_METER_REVISION)?; - let catalog_revision = require_revision(&request.catalog_revision, COMPUTER_CATALOG_REVISION)?; - let bound = ComputerAdmission { - schema_id: COMPUTER_ADMISSION_SCHEMA.to_string(), - admission_id, - account_id, - computer_id, - run_id, - provider, - profile_id: profile.id, - cpu: profile.cpu, - memory_gib: profile.memory_gib, - disk_gib: profile.disk_gib, - multiplier: profile.multiplier, - meter_revision, - catalog_revision, - funding_authority, - quote_id, - expires_at, - binding_digest: String::new(), - }; - let binding_digest = admission_binding_digest(&bound); - Ok(ComputerAdmission { - binding_digest, - ..bound - }) -} - -/// Mint an immutable receipt for one provider-accepted active interval. -pub fn issue_computer_meter_receipt( - admission: &ComputerAdmission, - observation: ProviderObservation, -) -> Result { - assert_active_observation(&observation)?; - assert_allocation_matches(admission, &observation)?; - let started_at = normalize_timestamp(&observation.started_at, "startedAt")?; - let ended_at = normalize_timestamp(&observation.ended_at, "endedAt")?; - let started = parse_timestamp(&started_at, "startedAt")?; - let ended = parse_timestamp(&ended_at, "endedAt")?; - if ended < started { - return Err(ComputerMeterError::IntervalReversed); - } - let expires = parse_timestamp(&admission.expires_at, "expiresAt")?; - if started >= expires { - return Err(ComputerMeterError::AdmissionExpired); - } - let accepted_seconds = elapsed_whole_seconds(started, ended); - let standard_equivalent_seconds = - accepted_seconds.saturating_mul(u64::from(admission.multiplier)); - let provider = require_ref(&observation.provider, "provider")?; - if provider != admission.provider { - return Err(ComputerMeterError::AllocationMismatch { - message: format!( - "Provider `{}` does not match admitted provider `{}`.", - provider, admission.provider - ), - }); - } - let provider_sandbox_id = require_ref(&observation.provider_sandbox_id, "providerSandboxId")?; - let provider_event_ref = require_ref(&observation.provider_event_ref, "providerEventRef")?; - let allocation = ProviderAllocation { - provider: provider.clone(), - provider_sandbox_id: provider_sandbox_id.clone(), - cpu: observation.cpu, - memory_gib: observation.memory_gib, - disk_gib: observation.disk_gib, - state: normalize_token(&observation.state), - accepted: true, - }; - let mut receipt = ComputerMeterReceipt { - schema_id: COMPUTER_METER_RECEIPT_SCHEMA.to_string(), - receipt_id: String::new(), - kind: COMPUTER_METER_RECEIPT_KIND.to_string(), - account_id: admission.account_id.clone(), - run_id: admission.run_id.clone(), - computer_id: admission.computer_id.clone(), - admission_id: admission.admission_id.clone(), - provider, - profile_id: admission.profile_id, - multiplier: admission.multiplier, - cpu: admission.cpu, - memory_gib: admission.memory_gib, - disk_gib: admission.disk_gib, - meter_revision: admission.meter_revision.clone(), - catalog_revision: admission.catalog_revision.clone(), - funding_authority: admission.funding_authority.clone(), - quote_id: admission.quote_id.clone(), - started_at, - ended_at, - accepted_seconds, - standard_equivalent_seconds, - provider_allocation: allocation, - provider_event_ref, - replay_of: None, - correction_of: None, - lineage: Vec::new(), - binding_digest: String::new(), - }; - receipt.binding_digest = receipt_binding_digest(&receipt); - receipt.receipt_id = receipt_id_for(&receipt); - Ok(receipt) -} - -/// Exact replay of an existing receipt. Identity and digest must match. -pub fn assert_computer_meter_receipt_replay( - existing: &ComputerMeterReceipt, - incoming: &ComputerMeterReceipt, -) -> Result<(), ComputerMeterError> { - if existing.receipt_id == incoming.receipt_id - && existing.binding_digest == incoming.binding_digest - && existing.admission_id == incoming.admission_id - && existing.account_id == incoming.account_id - && existing.provider_event_ref == incoming.provider_event_ref - && existing.accepted_seconds == incoming.accepted_seconds - && existing.standard_equivalent_seconds == incoming.standard_equivalent_seconds - && existing.profile_id == incoming.profile_id - && existing.multiplier == incoming.multiplier - { - return Ok(()); - } - Err(ComputerMeterError::ReplayConflict) -} - -/// Append-only correction. The original receipt is not mutated. -pub fn correct_computer_meter_receipt( - original: &ComputerMeterReceipt, - admission: &ComputerAdmission, - observation: ProviderObservation, -) -> Result { - if original.admission_id != admission.admission_id - || original.account_id != admission.account_id - { - return Err(ComputerMeterError::ReplayConflict); - } - let mut correction = issue_computer_meter_receipt(admission, observation)?; - if correction.profile_id != original.profile_id || correction.multiplier != original.multiplier - { - return Err(ComputerMeterError::ReplayConflict); - } - correction.correction_of = Some(original.receipt_id.clone()); - correction.lineage = { - let mut lineage = original.lineage.clone(); - lineage.push(original.receipt_id.clone()); - lineage - }; - correction.binding_digest = receipt_binding_digest(&correction); - correction.receipt_id = receipt_id_for(&correction); - Ok(correction) -} - -/// Sum Standard-equivalent seconds across independent Computer receipts. -#[must_use] -pub fn sum_standard_equivalent_seconds(receipts: &[ComputerMeterReceipt]) -> u64 { - receipts - .iter() - .map(|receipt| receipt.standard_equivalent_seconds) - .fold(0, u64::saturating_add) -} - -fn assert_active_observation(observation: &ProviderObservation) -> Result<(), ComputerMeterError> { - if observation.meter_basis != MeterBasis::ProviderAcceptedActive || observation.idle { - return Err(ComputerMeterError::WallClockIdle); - } - if !observation.provider_accepted { - return Err(ComputerMeterError::NotProviderAcceptedActive { - message: "A Computer meter receipt requires a provider-accepted live allocation." - .to_string(), - }); - } - let state = normalize_token(&observation.state); - if !matches!(state.as_str(), "running" | "started" | "active") { - return Err(ComputerMeterError::NotProviderAcceptedActive { - message: format!( - "Computer entitlement does not accrue in `{state}` (requested, queued, rejected, stopped, suspended, archived, failed-before-acceptance, and teardown-tail are excluded)." - ), - }); - } - Ok(()) -} - -fn assert_allocation_matches( - admission: &ComputerAdmission, - observation: &ProviderObservation, -) -> Result<(), ComputerMeterError> { - if observation.cpu == admission.cpu - && observation.memory_gib == admission.memory_gib - && observation.disk_gib == admission.disk_gib - { - return Ok(()); - } - Err(ComputerMeterError::AllocationMismatch { - message: format!( - "Provider allocation {} vCPU / {} GiB RAM / {} GiB disk does not equal admitted profile {} ({} / {} / {}). Smaller is not a cost-saving substitution and larger is not an upgrade.", - observation.cpu, - observation.memory_gib, - observation.disk_gib, - admission.profile_id.as_str(), - admission.cpu, - admission.memory_gib, - admission.disk_gib - ), - }) -} - -fn admission_binding_digest(admission: &ComputerAdmission) -> String { - sha256_hex( - [ - ADMISSION_DIGEST_NS, - admission.admission_id.as_str(), - admission.account_id.as_str(), - admission.computer_id.as_str(), - admission.run_id.as_str(), - admission.provider.as_str(), - admission.profile_id.as_str(), - &admission.cpu.to_string(), - &admission.memory_gib.to_string(), - &admission.disk_gib.to_string(), - &admission.multiplier.to_string(), - admission.meter_revision.as_str(), - admission.catalog_revision.as_str(), - admission.funding_authority.as_str(), - admission.quote_id.as_str(), - admission.expires_at.as_str(), - ] - .join("\0"), - ) -} - -fn receipt_binding_digest(receipt: &ComputerMeterReceipt) -> String { - sha256_hex( - [ - RECEIPT_DIGEST_NS, - receipt.account_id.as_str(), - receipt.run_id.as_str(), - receipt.computer_id.as_str(), - receipt.admission_id.as_str(), - receipt.provider.as_str(), - receipt.profile_id.as_str(), - &receipt.multiplier.to_string(), - &receipt.cpu.to_string(), - &receipt.memory_gib.to_string(), - &receipt.disk_gib.to_string(), - receipt.meter_revision.as_str(), - receipt.catalog_revision.as_str(), - receipt.funding_authority.as_str(), - receipt.quote_id.as_str(), - receipt.started_at.as_str(), - receipt.ended_at.as_str(), - &receipt.accepted_seconds.to_string(), - &receipt.standard_equivalent_seconds.to_string(), - receipt.provider_event_ref.as_str(), - receipt.provider_allocation.provider_sandbox_id.as_str(), - &receipt.provider_allocation.cpu.to_string(), - &receipt.provider_allocation.memory_gib.to_string(), - &receipt.provider_allocation.disk_gib.to_string(), - receipt.provider_allocation.state.as_str(), - receipt.correction_of.as_deref().unwrap_or(""), - &receipt.lineage.join(","), - ] - .join("\0"), - ) -} - -fn receipt_id_for(receipt: &ComputerMeterReceipt) -> String { - format!("cmr_{}", &receipt.binding_digest[..32]) -} - -fn elapsed_whole_seconds(started: DateTime, ended: DateTime) -> u64 { - ended - .signed_duration_since(started) - .num_milliseconds() - .max(0) - .unsigned_abs() - / 1000 -} - -fn require_revision(value: &str, expected: &str) -> Result { - let normalized = value.trim(); - if normalized.is_empty() { - return Ok(expected.to_string()); - } - if normalized == expected { - return Ok(expected.to_string()); - } - Err(ComputerMeterError::RevisionMismatch { - message: format!("Computer meter revision `{normalized}` is not {expected}."), - }) -} - -fn require_ref(value: &str, field: &str) -> Result { - let normalized = value.trim(); - if normalized.is_empty() - || normalized.len() > MAX_REF_CHARS - || normalized.chars().any(|ch| ch.is_control()) - { - return Err(ComputerMeterError::ReferenceInvalid { - message: format!( - "Computer meter field `{field}` is required and must be a bounded token." - ), - }); - } - Ok(normalized.to_string()) -} - -fn optional_ref(value: &str, field: &str) -> Result { - let normalized = value.trim(); - if normalized.is_empty() { - return Ok(String::new()); - } - require_ref(normalized, field) -} - -fn normalize_token(value: &str) -> String { - value.trim().to_ascii_lowercase() -} - -fn normalize_timestamp(value: &str, field: &str) -> Result { - Ok(parse_timestamp(value, field)?.to_rfc3339_opts(SecondsFormat::Millis, true)) -} - -fn parse_timestamp(value: &str, field: &str) -> Result, ComputerMeterError> { - DateTime::parse_from_rfc3339(value.trim()) - .map(|parsed| parsed.with_timezone(&Utc)) - .map_err(|_| ComputerMeterError::TimestampInvalid { - message: format!("Computer meter field `{field}` must be an RFC 3339 timestamp."), - }) -} - -#[cfg(test)] -mod tests; diff --git a/crates/tui/src/computer_meter/fixtures/cases.json b/crates/tui/src/computer_meter/fixtures/cases.json deleted file mode 100644 index ea429bc5ef..0000000000 --- a/crates/tui/src/computer_meter/fixtures/cases.json +++ /dev/null @@ -1,429 +0,0 @@ -{ - "schema": "codewhale.computer-meter-fixtures/v1", - "note": "Hermetic Daytona-shaped observations. No live provider calls.", - "cases": [ - { - "id": "accepted-standard-8", - "expect": "receipt", - "acceptedSeconds": 600, - "standardEquivalentSeconds": 600, - "admission": { - "admissionId": "adm_std8_001", - "accountId": "acct_demo", - "computerId": "cmp_8gb_a", - "runId": "run_demo_1", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_001", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_std8_a", - "providerEventRef": "daytona:sbox_std8_a:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "id": "accepted-standard-16", - "expect": "receipt", - "acceptedSeconds": 600, - "standardEquivalentSeconds": 1200, - "admission": { - "admissionId": "adm_std16_001", - "accountId": "acct_demo", - "computerId": "cmp_16gb_a", - "runId": "run_demo_2", - "provider": "daytona", - "profileId": "standard-16", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std16_001", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_std16_a", - "providerEventRef": "daytona:sbox_std16_a:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 4, - "memoryGiB": 16, - "diskGiB": 16, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "id": "accepted-standard-32", - "expect": "receipt", - "acceptedSeconds": 600, - "standardEquivalentSeconds": 2400, - "admission": { - "admissionId": "adm_std32_001", - "accountId": "acct_demo", - "computerId": "cmp_32gb_a", - "runId": "run_demo_3", - "provider": "daytona", - "profileId": "standard-32", - "fundingAuthority": "computer_time_pack", - "quoteId": "quote_std32_001", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_std32_a", - "providerEventRef": "daytona:sbox_std32_a:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 8, - "memoryGiB": 32, - "diskGiB": 32, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "id": "per-second-no-minute-floor", - "expect": "receipt", - "acceptedSeconds": 11, - "standardEquivalentSeconds": 11, - "admission": { - "admissionId": "adm_std8_subminute", - "accountId": "acct_demo", - "computerId": "cmp_8gb_b", - "runId": "run_demo_4", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_subminute", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_std8_b", - "providerEventRef": "daytona:sbox_std8_b:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:00:11.400Z" - } - }, - { - "id": "idle-wall-clock", - "expect": "error", - "code": "computer_meter_wall_clock_idle", - "admission": { - "admissionId": "adm_std8_idle", - "accountId": "acct_demo", - "computerId": "cmp_8gb_idle", - "runId": "run_demo_idle", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_idle", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_idle", - "providerEventRef": "daytona:sbox_idle:wall-clock:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": true, - "providerAccepted": true, - "meterBasis": "wall_clock", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T13:00:00.000Z" - } - }, - { - "id": "queued-not-accepted", - "expect": "error", - "code": "computer_meter_not_provider_accepted_active", - "admission": { - "admissionId": "adm_std8_queued", - "accountId": "acct_demo", - "computerId": "cmp_8gb_queued", - "runId": "run_demo_queued", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_queued", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_queued", - "providerEventRef": "daytona:sbox_queued:queued:2026-08-31T12:00:00.000Z", - "state": "queued", - "idle": false, - "providerAccepted": false, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:02:00.000Z" - } - }, - { - "id": "stopped-storage-tail", - "expect": "error", - "code": "computer_meter_not_provider_accepted_active", - "admission": { - "admissionId": "adm_std8_stopped", - "accountId": "acct_demo", - "computerId": "cmp_8gb_stopped", - "runId": "run_demo_stopped", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_stopped", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_stopped", - "providerEventRef": "daytona:sbox_stopped:stopped:2026-08-31T12:10:00.000Z", - "state": "stopped", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:10:00.000Z", - "endedAt": "2026-08-31T13:10:00.000Z" - } - }, - { - "id": "teardown-tail", - "expect": "error", - "code": "computer_meter_not_provider_accepted_active", - "admission": { - "admissionId": "adm_std8_teardown", - "accountId": "acct_demo", - "computerId": "cmp_8gb_teardown", - "runId": "run_demo_teardown", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_teardown", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_teardown", - "providerEventRef": "daytona:sbox_teardown:teardown:2026-08-31T12:10:00.000Z", - "state": "teardown", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:10:00.000Z", - "endedAt": "2026-08-31T12:10:45.000Z" - } - }, - { - "id": "undersized-rollback-small", - "expect": "error", - "code": "computer_meter_allocation_mismatch", - "admission": { - "admissionId": "adm_std8_undersize", - "accountId": "acct_demo", - "computerId": "cmp_8gb_undersize", - "runId": "run_demo_undersize", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_undersize", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_undersize", - "providerEventRef": "daytona:sbox_undersize:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 4, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "id": "oversize-not-an-upgrade", - "expect": "error", - "code": "computer_meter_allocation_mismatch", - "admission": { - "admissionId": "adm_std8_oversize", - "accountId": "acct_demo", - "computerId": "cmp_8gb_oversize", - "runId": "run_demo_oversize", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_oversize", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_oversize", - "providerEventRef": "daytona:sbox_oversize:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 4, - "memoryGiB": 16, - "diskGiB": 16, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "id": "concurrent-two-standard-8", - "expect": "sum", - "standardEquivalentSeconds": 1200, - "intervals": [ - { - "admission": { - "admissionId": "adm_std8_conc_a", - "accountId": "acct_demo", - "computerId": "cmp_8gb_conc_a", - "runId": "run_conc_a", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_conc_a", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_conc_a", - "providerEventRef": "daytona:sbox_conc_a:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - }, - { - "admission": { - "admissionId": "adm_std8_conc_b", - "accountId": "acct_demo", - "computerId": "cmp_8gb_conc_b", - "runId": "run_conc_b", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_conc_b", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_conc_b", - "providerEventRef": "daytona:sbox_conc_b:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - } - } - ] - }, - { - "id": "historic-large-readable", - "expect": "decode", - "profileInput": "large", - "profileId": "standard-16", - "multiplier": 2 - }, - { - "id": "historic-large-not-admissible", - "expect": "admit_error", - "profileInput": "large", - "code": "computer_profile_historic_not_admissible" - }, - { - "id": "append-only-correction", - "expect": "correction", - "acceptedSeconds": 590, - "standardEquivalentSeconds": 590, - "admission": { - "admissionId": "adm_std8_corr", - "accountId": "acct_demo", - "computerId": "cmp_8gb_corr", - "runId": "run_demo_corr", - "provider": "daytona", - "profileId": "standard-8", - "fundingAuthority": "coding_membership_included", - "quoteId": "quote_std8_corr", - "expiresAt": "2026-08-31T18:00:00.000Z" - }, - "observation": { - "provider": "daytona", - "providerSandboxId": "sbox_corr", - "providerEventRef": "daytona:sbox_corr:running:2026-08-31T12:00:00.000Z", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:10:00.000Z" - }, - "restatedObservation": { - "provider": "daytona", - "providerSandboxId": "sbox_corr", - "providerEventRef": "daytona:sbox_corr:running:2026-08-31T12:00:00.000Z:restated", - "state": "running", - "idle": false, - "providerAccepted": true, - "meterBasis": "provider_accepted_active", - "cpu": 2, - "memoryGiB": 8, - "diskGiB": 8, - "startedAt": "2026-08-31T12:00:00.000Z", - "endedAt": "2026-08-31T12:09:50.000Z" - } - } - ] -} diff --git a/crates/tui/src/computer_meter/tests.rs b/crates/tui/src/computer_meter/tests.rs deleted file mode 100644 index 8ae5f72ca9..0000000000 --- a/crates/tui/src/computer_meter/tests.rs +++ /dev/null @@ -1,245 +0,0 @@ -use super::*; - -const FIXTURES: &str = include_str!("fixtures/cases.json"); - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FixtureFile { - cases: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FixtureCase { - id: String, - expect: String, - #[serde(default)] - code: Option, - #[serde(default)] - accepted_seconds: Option, - #[serde(default)] - standard_equivalent_seconds: Option, - #[serde(default)] - admission: Option, - #[serde(default)] - observation: Option, - #[serde(default)] - restated_observation: Option, - #[serde(default)] - intervals: Vec, - #[serde(default)] - profile_input: Option, - #[serde(default)] - profile_id: Option, - #[serde(default)] - multiplier: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct FixtureInterval { - admission: ComputerAdmissionRequest, - observation: ProviderObservation, -} - -fn fixtures() -> Vec { - serde_json::from_str::(FIXTURES) - .expect("computer meter fixtures must parse") - .cases -} - -fn issue_case(case: &FixtureCase) -> Result { - let admission = bind_computer_admission( - case.admission - .clone() - .expect("receipt fixture requires admission"), - )?; - issue_computer_meter_receipt( - &admission, - case.observation - .clone() - .expect("receipt fixture requires observation"), - ) -} - -#[test] -fn fixtures_cover_provider_accepted_active_seconds_only() { - let cases = fixtures(); - assert!( - cases.len() >= 12, - "fixture pack must cover accept, idle, queued, stopped, teardown, mismatch, concurrent, historic, correction" - ); - - for case in &cases { - match case.expect.as_str() { - "receipt" => { - let receipt = issue_case(case).unwrap_or_else(|error| { - panic!("{} should mint a receipt, got {}", case.id, error.code()) - }); - assert_eq!(receipt.schema_id, COMPUTER_METER_RECEIPT_SCHEMA); - assert_eq!(receipt.kind, COMPUTER_METER_RECEIPT_KIND); - assert_eq!(receipt.accepted_seconds, case.accepted_seconds.unwrap()); - assert_eq!( - receipt.standard_equivalent_seconds, - case.standard_equivalent_seconds.unwrap() - ); - assert_eq!( - receipt.standard_equivalent_seconds, - receipt.accepted_seconds * u64::from(receipt.multiplier) - ); - let admission = bind_computer_admission(case.admission.clone().unwrap()).unwrap(); - assert_eq!(receipt.admission_id, admission.admission_id); - assert_eq!(receipt.account_id, admission.account_id); - assert_eq!(receipt.profile_id, admission.profile_id); - assert_eq!(receipt.multiplier, admission.multiplier); - assert_eq!(receipt.provider_allocation.cpu, admission.cpu); - assert_eq!(receipt.provider_allocation.memory_gib, admission.memory_gib); - assert_eq!(receipt.provider_allocation.disk_gib, admission.disk_gib); - assert!(receipt.provider_allocation.accepted); - assert_eq!(receipt.meter_revision, COMPUTER_METER_REVISION); - assert_eq!(receipt.catalog_revision, COMPUTER_CATALOG_REVISION); - assert_ne!(receipt.binding_digest, admission.binding_digest); - } - "error" => { - let error = issue_case(case).expect_err(&case.id); - assert_eq!(error.code(), case.code.as_deref().unwrap(), "{}", case.id); - } - "sum" => { - let receipts: Vec<_> = case - .intervals - .iter() - .map(|interval| { - let admission = bind_computer_admission(interval.admission.clone()) - .unwrap_or_else(|error| panic!("{}: {}", case.id, error)); - issue_computer_meter_receipt(&admission, interval.observation.clone()) - .unwrap_or_else(|error| panic!("{}: {}", case.id, error)) - }) - .collect(); - assert_eq!( - sum_standard_equivalent_seconds(&receipts), - case.standard_equivalent_seconds.unwrap() - ); - } - "decode" => { - let profile = decode_computer_profile(case.profile_input.as_deref().unwrap()) - .unwrap_or_else(|error| panic!("{}: {}", case.id, error)); - assert_eq!(profile.id.as_str(), case.profile_id.as_deref().unwrap()); - assert_eq!(profile.multiplier, case.multiplier.unwrap()); - } - "admit_error" => { - let error = admit_computer_profile(case.profile_input.as_deref().unwrap()) - .expect_err(&case.id); - assert_eq!(error.code(), case.code.as_deref().unwrap()); - } - "correction" => { - let admission = bind_computer_admission(case.admission.clone().unwrap()).unwrap(); - let original = - issue_computer_meter_receipt(&admission, case.observation.clone().unwrap()) - .unwrap(); - let original_clone = original.clone(); - let correction = correct_computer_meter_receipt( - &original, - &admission, - case.restated_observation.clone().unwrap(), - ) - .unwrap(); - assert_eq!( - original, original_clone, - "corrections must not mutate the original receipt" - ); - assert_eq!( - correction.correction_of.as_deref(), - Some(original.receipt_id.as_str()) - ); - assert!(correction.lineage.contains(&original.receipt_id)); - assert_ne!(correction.receipt_id, original.receipt_id); - assert_eq!(correction.accepted_seconds, case.accepted_seconds.unwrap()); - assert_eq!( - correction.standard_equivalent_seconds, - case.standard_equivalent_seconds.unwrap() - ); - assert_eq!(correction.account_id, original.account_id); - assert_eq!(correction.admission_id, original.admission_id); - assert_eq!(correction.profile_id, original.profile_id); - } - other => panic!("unknown fixture expect `{other}`"), - } - } -} - -#[test] -fn exact_replay_is_idempotent_and_a_mutated_binding_conflicts() { - let case = fixtures() - .into_iter() - .find(|case| case.id == "accepted-standard-8") - .unwrap(); - let first = issue_case(&case).unwrap(); - let replay = issue_case(&case).unwrap(); - assert_eq!(first.receipt_id, replay.receipt_id); - assert_eq!(first.binding_digest, replay.binding_digest); - assert_computer_meter_receipt_replay(&first, &replay).unwrap(); - - let mut mutated = replay.clone(); - mutated.accepted_seconds = first.accepted_seconds + 1; - mutated.standard_equivalent_seconds = mutated.accepted_seconds; - assert_eq!( - assert_computer_meter_receipt_replay(&first, &mutated) - .unwrap_err() - .code(), - "computer_meter_receipt_replay_conflict" - ); -} - -#[test] -fn admission_binds_profile_multiplier_account_and_expiry_before_dispatch() { - let admission = bind_computer_admission(ComputerAdmissionRequest { - admission_id: "adm_bind".to_string(), - account_id: "acct_demo".to_string(), - computer_id: "cmp_bind".to_string(), - run_id: "run_bind".to_string(), - provider: "daytona".to_string(), - profile_id: "standard-16".to_string(), - funding_authority: "coding_membership_included".to_string(), - quote_id: "quote_bind".to_string(), - expires_at: "2026-08-31T18:00:00.000Z".to_string(), - meter_revision: String::new(), - catalog_revision: String::new(), - }) - .unwrap(); - assert_eq!(admission.schema_id, COMPUTER_ADMISSION_SCHEMA); - assert_eq!(admission.profile_id, ComputerProfileId::Standard16); - assert_eq!(admission.cpu, 4); - assert_eq!(admission.memory_gib, 16); - assert_eq!(admission.disk_gib, 16); - assert_eq!(admission.multiplier, 2); - assert_eq!(admission.account_id, "acct_demo"); - assert_eq!(admission.meter_revision, COMPUTER_METER_REVISION); - assert!(!admission.binding_digest.is_empty()); -} - -#[test] -fn one_standard_16_matches_two_standard_8_over_the_same_ten_minutes() { - let cases = fixtures(); - let two_eights = cases - .iter() - .find(|case| case.id == "concurrent-two-standard-8") - .unwrap(); - let sixteen = cases - .iter() - .find(|case| case.id == "accepted-standard-16") - .unwrap(); - let eight_receipts: Vec<_> = two_eights - .intervals - .iter() - .map(|interval| { - let admission = bind_computer_admission(interval.admission.clone()).unwrap(); - issue_computer_meter_receipt(&admission, interval.observation.clone()).unwrap() - }) - .collect(); - let sixteen_receipt = issue_case(sixteen).unwrap(); - assert_eq!( - sum_standard_equivalent_seconds(&eight_receipts), - sixteen_receipt.standard_equivalent_seconds - ); - assert_eq!(sum_standard_equivalent_seconds(&eight_receipts), 1200); -} diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index e5503e5414..a6a017aa33 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -10195,26 +10195,6 @@ pub(crate) fn validate_kimi_code_api_model_id( mod kimi_code_pairing_tests { use super::*; - #[test] - fn membership_roster_passes_on_kimi_code_endpoint() { - for model in [ - KIMI_CODE_K3_MODEL, - KIMI_CODE_K3_256K_MODEL, - DEFAULT_KIMI_CODE_MODEL, - KIMI_CODE_HIGHSPEED_MODEL, - ] { - assert!( - validate_kimi_code_api_model_id( - ApiProvider::Moonshot, - DEFAULT_KIMI_CODE_BASE_URL, - model, - ) - .is_ok(), - "{model} must be accepted on the exact Kimi Code membership endpoint" - ); - } - } - #[test] fn direct_platform_ids_fail_on_kimi_code_endpoint() { for model in [ diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index aa5f3ac492..ef2be48775 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4953,7 +4953,6 @@ impl Engine { ), mcp_tool_names: Vec::new(), mcp: McpToolState::Disabled, - subagent_runtime_model: None, mailbox: None, plugin_tool_names: HashSet::new(), }; @@ -5089,14 +5088,12 @@ impl Engine { None }; - let mut subagent_runtime_model = None; let mut tool_registry = if subagents_available { let runtime = if let Some(client) = route.client.clone() { let runtime_allow_shell = input_policy.allow_shell && !matches!(input_policy.mode, AppMode::Plan); let runtime_shell_policy = shell_policy_for_mode(input_policy.mode, runtime_allow_shell); - subagent_runtime_model = Some(route.model.clone()); let mut rt = SubAgentRuntime::new( client, route.model.clone(), @@ -5226,7 +5223,6 @@ impl Engine { surface, mcp_tool_names, mcp: mcp_state, - subagent_runtime_model, mailbox: mailbox_for_runtime, plugin_tool_names, } @@ -8618,10 +8614,6 @@ struct TurnToolBuild { mcp_tool_names: Vec, /// What is known about the MCP contribution to this catalog. mcp: McpToolState, - /// Route model installed into the child runtime, when sub-agent tools were - /// available. This is an internal receipt, not a manifest field. - #[cfg_attr(not(test), allow(dead_code))] - subagent_runtime_model: Option, /// Turn-scoped sub-agent mailbox and its flush barrier, when sub-agent /// wiring was live. The engine must seal, flush, and await this before it /// emits `TurnComplete`. Detached children never reopen this ordering diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index d18663dc74..0aeef0d720 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -609,7 +609,6 @@ async fn planned_route_builds_subagent_catalog_without_installed_client() { engine.deepseek_client = None; let planned = plan(&config, &identity, false, "planned child route").await; let route = planned.route.validate().expect("planned route validates"); - let planned_model = route.model.clone(); let policy = TurnAuthority::from_effective_fields( AppMode::Agent, false, @@ -648,11 +647,6 @@ async fn planned_route_builds_subagent_catalog_without_installed_client() { .any(|tool| tool.name == "agent"), "the planned route client must make sub-agent tools available" ); - assert_eq!( - build.subagent_runtime_model.as_deref(), - Some(planned_model.as_str()), - "the child runtime must carry the planned route model" - ); } /// Auto routing with no hypothetical prompt: every route-derived fact is diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ad04a7ffe6..99c5be1ecc 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -20927,9 +20927,10 @@ async fn code_execution_scenario() { #[cfg(unix)] #[tokio::test] async fn code_execution_timeout_kills_the_interpreter_instead_of_orphaning_it() { + use std::io::Write; // The kill path needs a real interpreter; skip where python is absent. if crate::dependencies::resolve_python_interpreter().is_none() { - eprintln!("skipping: python not present"); + let _ = writeln!(std::io::stderr(), "skipping: python not present"); return; } let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index a1ad560882..b18513dfce 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -6510,19 +6510,6 @@ fn turn_owned_child_parking_runtime_text(running: usize) -> String { ) } -#[cfg(test)] -fn should_hold_turn_for_subagents(queued_completions: usize, running_children: usize) -> bool { - // #3216: launching sub-agents must NOT barrier the parent turn. Only queued - // completions (work already finished that must be surfaced into the - // transcript) hold the turn open. Running children are background work — the - // parent ends its turn and their results arrive via the completion sentinel - // on a later turn. The - // `running_children` argument is kept for call-site clarity and the - // background-status message, but deliberately no longer gates the hold. - let _ = running_children; - queued_completions > 0 -} - fn stream_chunk_timeout_budget(config: &EngineConfig) -> (u64, Duration) { let secs = config.stream_chunk_timeout.as_secs(); (secs, Duration::from_secs(secs)) @@ -7864,19 +7851,6 @@ mod tests { assert!(text.contains(r#""origin_turn_id":"turn_abc""#)); } - #[test] - fn turn_holds_only_for_queued_completions_not_running_children() { - // #3216: queued completions hold the turn open so they get surfaced... - assert!(should_hold_turn_for_subagents(1, 0)); - // ...but running children no longer barrier the parent — launching a - // sub-agent is not the same as joining it (results arrive via the - // completion sentinel). - assert!(!should_hold_turn_for_subagents(0, 1)); - assert!(!should_hold_turn_for_subagents(0, 0)); - // Queued completions hold regardless of how many children are running. - assert!(should_hold_turn_for_subagents(2, 5)); - } - #[test] fn turn_owned_children_get_one_settlement_prompt_then_a_resumable_park() { assert!(should_guard_turn_end_for_owned_children(false, 1, false)); @@ -8058,65 +8032,6 @@ mod tests { )); } - /// Regression test for the OpenAI streaming batch tool_calls bug. - /// - /// Background: when an OpenAI-compatible backend (vLLM, Ollama, LM Studio, - /// etc.) streams a response containing multiple `tool_calls` in the same - /// assistant message, the streaming parser emits the events in this order: - /// - /// ```text - /// ContentBlockStart::ToolUse { index: 0, ..} // tool #1 - /// ContentBlockDelta { index: 0, .. } // its arguments - /// ContentBlockStart::ToolUse { index: 1, ..} // tool #2 - /// ContentBlockDelta { index: 1, .. } - /// … - /// ContentBlockStart::ToolUse { index: N-1, ..} - /// ContentBlockDelta { index: N-1, .. } - /// ContentBlockStop { index: 0 } // ── only flushed at - /// ContentBlockStop { index: 1 } // finish_reason - /// … // (see chat.rs - /// ContentBlockStop { index: N-1 } // L2050-L2064) - /// ``` - /// - /// All Starts arrive before any Stop. The fix replaces the single - /// `current_tool_index: Option` slot (overwritten by each Start) - /// with a `HashMap` that survives - /// every Start and routes each Stop to the right `tool_uses` entry. - /// - /// This test confirms the invariant: feed 7 Starts then 7 Stops, expect - /// all 7 indices to come back out in order. - #[test] - fn batch_tool_calls_preserve_all_tool_use_indices() { - let mut current_tool_indices: std::collections::HashMap = - std::collections::HashMap::new(); - - // Simulate `ContentBlockStart::ToolUse { index: i, ..}` for 7 tools. - for block_index in 0..7u32 { - current_tool_indices.insert(block_index, block_index as usize); - } - assert_eq!(current_tool_indices.len(), 7); - - // Now drain via `ContentBlockStop { index: i }` in the same order. - let mut recovered: Vec<(u32, usize)> = (0..7u32) - .map(|block_index| { - let tool_idx = current_tool_indices - .remove(&block_index) - .expect("each block_index must route to a tool_uses entry"); - (block_index, tool_idx) - }) - .collect(); - recovered.sort_by_key(|(block_index, _)| *block_index); - let expected: Vec<(u32, usize)> = (0..7u32).map(|i| (i, i as usize)).collect(); - assert_eq!( - recovered, expected, - "every Stop must recover the tool_uses index pushed by its matching Start" - ); - assert!( - current_tool_indices.is_empty(), - "all entries must drain after their Stops" - ); - } - #[test] fn resolve_auto_effort_ignores_stored_turn_metadata() { let messages = vec![Message { diff --git a/crates/tui/src/dependencies.rs b/crates/tui/src/dependencies.rs index 9b6cd9c524..7aa09aea05 100644 --- a/crates/tui/src/dependencies.rs +++ b/crates/tui/src/dependencies.rs @@ -711,11 +711,6 @@ mod tests { // ExternalTool trait tests // =================================================================== - #[test] - fn python_candidates_matches_const() { - assert_eq!(Python::candidates(), PYTHON_CANDIDATES); - } - #[test] fn node_candidates_is_node_only() { assert_eq!(Node::candidates(), &["node"]); @@ -805,31 +800,6 @@ mod tests { assert_eq!(first, second); } - #[test] - fn git_available_matches_resolve() { - assert_eq!(Git::available(), Git::resolve().is_some()); - } - - #[test] - fn python_available_matches_resolve() { - assert_eq!(Python::available(), Python::resolve().is_some()); - } - - #[test] - fn node_available_matches_resolve() { - assert_eq!(Node::available(), Node::resolve().is_some()); - } - - #[test] - fn rustc_available_matches_resolve() { - assert_eq!(RustC::available(), RustC::resolve().is_some()); - } - - #[test] - fn cargo_available_matches_resolve() { - assert_eq!(Cargo::available(), Cargo::resolve().is_some()); - } - #[test] fn git_command_returns_some_when_available() { if Git::available() { diff --git a/crates/tui/src/dispatch_runner.rs b/crates/tui/src/dispatch_runner.rs index 546334c775..3cfc336880 100644 --- a/crates/tui/src/dispatch_runner.rs +++ b/crates/tui/src/dispatch_runner.rs @@ -379,13 +379,6 @@ pub fn compose_pr_title(job: &CloudJob, patch: &PatchReceipt) -> String { one_line(&format!("codewhale cloud: {summary}"), MAX_TITLE_CHARS) } -/// Truthful PR body: what the agent did, the receipts Codewhale has, and an -/// explicit No-Issue line (no tracked issue; the cloud job is the record). -/// Sandbox-provider names never appear — the operator is Codewhale. -pub fn compose_pr_body(job: &CloudJob, patch: &PatchReceipt) -> String { - compose_pr_body_for_head(job, patch, &patch.head_sha) -} - /// PR body whose `Head:` is the sha actually applied/pushed. pub fn compose_pr_body_for_head(job: &CloudJob, patch: &PatchReceipt, head_sha: &str) -> String { let summary = if patch.summary.trim().is_empty() { @@ -1357,7 +1350,7 @@ mod tests { let patch = fixture_patch(); let title = compose_pr_title(&job, &patch); assert_eq!(title, "codewhale cloud: Fix the flaky dispatch test"); - let body = compose_pr_body(&job, &patch); + let body = compose_pr_body_for_head(&job, &patch, &patch.head_sha); assert!(body.contains("Codewhale cloud agent")); assert!(body.contains("What the agent did")); assert!(body.contains("Fix the flaky dispatch test")); @@ -1653,7 +1646,8 @@ mod tests { finished_unix: None, sandbox_pending: false, }; - let body = compose_pr_body(&job, &fixture_patch()); + let patch = fixture_patch(); + let body = compose_pr_body_for_head(&job, &patch, &patch.head_sha); assert!(body.chars().count() <= MAX_BODY_CHARS); } diff --git a/crates/tui/src/error_taxonomy/tests.rs b/crates/tui/src/error_taxonomy/tests.rs index bc06903888..54ba11422f 100644 --- a/crates/tui/src/error_taxonomy/tests.rs +++ b/crates/tui/src/error_taxonomy/tests.rs @@ -52,32 +52,21 @@ fn typed_llm_quota_envelope_is_non_recoverable_and_distinct_from_rate_limit() { } #[test] -fn llm_auth_error_envelope_renders_context_without_secret() { - let api_key = "tp-secret-token-plan-value"; - let envelope = ErrorEnvelope::from(LlmError::from_http_response_with_request_context( +fn llm_auth_error_envelope_classifies_401_and_carries_body_verbatim() { + // The envelope is a classifier, not a redactor: a 401 must map to a + // critical, non-recoverable authentication error, and the body must be + // carried verbatim — redacting secrets from provider bodies is the + // caller's job before the envelope is built (see the + // `redact_model_bound_text` scenario pins in client.rs). + let envelope = ErrorEnvelope::from(LlmError::from_http_response( 401, - &format!("Invalid API Key: {api_key}"), - Some("Xiaomi MiMo"), - Some("https://token-plan-sgp.xiaomimimo.com/v1"), - Some("mimo-v2.5"), - Some("env"), - Some(api_key), + "Invalid API Key: tp-secret-token-plan-value", )); assert_eq!(envelope.category, ErrorCategory::Authentication); assert_eq!(envelope.severity, ErrorSeverity::Critical); assert!(!envelope.recoverable); - for expected in [ - "provider: Xiaomi MiMo", - "base URL authority: token-plan-sgp.xiaomimimo.com", - "model: mimo-v2.5", - "key source: env", - "key fingerprint: tp-... (len=26)", - "key type: Xiaomi MiMo Token Plan key", - ] { - assert!(envelope.message.contains(expected)); - } - assert!(!envelope.message.contains(api_key)); - assert!(!envelope.message.contains("secret-token-plan-value")); + assert!(envelope.message.contains("Invalid API Key")); + assert!(envelope.message.contains("tp-secret-token-plan-value")); } #[test] diff --git a/crates/tui/src/eval.rs b/crates/tui/src/eval.rs index f6881960b4..228cd2a34e 100644 --- a/crates/tui/src/eval.rs +++ b/crates/tui/src/eval.rs @@ -14,40 +14,6 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; use tempfile::TempDir; -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EvalShellPlatform { - Windows, - Unix, -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -struct EvalShellInvocation { - program: &'static str, - args: Vec, - raw_payload_on_windows: bool, -} - -#[cfg(test)] -fn eval_shell_invocation_for_platform( - command: &str, - platform: EvalShellPlatform, -) -> EvalShellInvocation { - match platform { - EvalShellPlatform::Windows => EvalShellInvocation { - program: "cmd", - args: vec!["/C".to_string(), command.to_string()], - raw_payload_on_windows: true, - }, - EvalShellPlatform::Unix => EvalShellInvocation { - program: "sh", - args: vec!["-c".to_string(), command.to_string()], - raw_payload_on_windows: false, - }, - } -} - /// Representative tool steps covered by the evaluation harness. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] pub enum ScenarioStepKind { @@ -764,23 +730,3 @@ fn truncate_output(value: &str, max_chars: usize) -> String { let truncated: String = value.chars().take(max_chars).collect(); format!("{truncated}...") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn eval_shell_invocation_preserves_quoted_payload_as_single_arg() { - let command = r#"git commit -m "feat: complete sub-pages""#; - - let windows = eval_shell_invocation_for_platform(command, EvalShellPlatform::Windows); - assert_eq!(windows.program, "cmd"); - assert_eq!(windows.args, vec!["/C".to_string(), command.to_string()]); - assert!(windows.raw_payload_on_windows); - - let unix = eval_shell_invocation_for_platform(command, EvalShellPlatform::Unix); - assert_eq!(unix.program, "sh"); - assert_eq!(unix.args, vec!["-c".to_string(), command.to_string()]); - assert!(!unix.raw_payload_on_windows); - } -} diff --git a/crates/tui/src/fleet/host.rs b/crates/tui/src/fleet/host.rs index 54a18aedd4..1b07eceb29 100644 --- a/crates/tui/src/fleet/host.rs +++ b/crates/tui/src/fleet/host.rs @@ -1949,32 +1949,6 @@ mod tests { adapter.stop_worker("local-memory").unwrap(); } - #[cfg(unix)] - #[test] - fn fleet_host_stop_signals_known_leader_without_process_table() { - // Even when full session census is unavailable, stop must still reach - // the tracked session leader/dispatcher pid. - let tmp = TempDir::new().unwrap(); - let mut adapter = LocalProcessFleetHostAdapter::new(tmp.path()); - let request = FleetWorkerStartRequest::new( - "known-leader-stop", - shell_command("printf ready; sleep 30"), - ); - let handle = adapter.start_worker(request).unwrap(); - let pid = handle.pid.expect("pid") as libc::pid_t; - let _ = wait_for_log(&adapter, "known-leader-stop", "ready"); - - let status = adapter - .stop_worker("known-leader-stop") - .expect("stop known leader without process table"); - assert_eq!(status.state, FleetHostWorkerState::Stopped); - assert!( - wait_for_unix_pid_exit(pid, Duration::from_secs(2)), - "known session leader survived stop without process-table census" - ); - adapter.cleanup_worker("known-leader-stop").unwrap(); - } - #[test] fn fleet_host_ssh_kind_does_not_report_local_process_memory() { let tmp = TempDir::new().unwrap(); diff --git a/crates/tui/src/fleet/profile.rs b/crates/tui/src/fleet/profile.rs index 6844750a56..4133ecc5b5 100644 --- a/crates/tui/src/fleet/profile.rs +++ b/crates/tui/src/fleet/profile.rs @@ -169,10 +169,6 @@ struct AgentProfilePermissionsToml { approval_required: Option, } -pub fn load_workspace_agent_profiles(workspace: impl AsRef) -> Result> { - load_agent_profiles_from_dir(workspace.as_ref().join(WORKSPACE_AGENT_PROFILE_DIR)) -} - /// Load every valid workspace profile while reporting invalid neighbors /// individually. The runtime roster uses this path so one stale profile does /// not hide a newly-authored valid profile (or the rest of the party). @@ -257,16 +253,6 @@ pub(crate) fn load_plugin_agent_profiles_from_component( Ok((profiles, issues)) } -/// Read only the identity-bearing fields from workspace profiles for the -/// authoring collision gate. Unknown legacy fields are harmless here because -/// no profile behavior is loaded or executed from this representation. -pub fn load_workspace_agent_profile_identities( - workspace: impl AsRef, -) -> Result> { - let dir = workspace.as_ref().join(WORKSPACE_AGENT_PROFILE_DIR); - load_agent_profile_identities_from_dir(dir) -} - pub fn load_agent_profile_identities_from_dir( dir: impl AsRef, ) -> Result> { @@ -277,20 +263,6 @@ pub fn load_agent_profile_identities_from_dir( .collect() } -pub fn load_agent_profiles_from_dir(dir: impl AsRef) -> Result> { - let dir = dir.as_ref(); - let mut profiles = Vec::new(); - let mut seen = BTreeSet::new(); - for path in agent_profile_paths(dir)? { - let profile = load_agent_profile_file(&path)?; - if !seen.insert(profile.id.to_ascii_lowercase()) { - bail!("duplicate agent profile id {}", profile.id); - } - profiles.push(profile); - } - Ok(profiles) -} - fn agent_profile_paths(dir: &Path) -> Result> { if !dir.exists() { return Ok(Vec::new()); @@ -905,7 +877,9 @@ mod tests { let dir = TempDir::new().unwrap(); let path = write_profile(dir.path(), &draft.file_name(), &draft.render_toml()); - let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(dir.path(), ProfileOrigin::Workspace) + .expect("rendered TOML loads"); assert_eq!(profiles.len(), 1); let loaded = &profiles[0]; assert_eq!(loaded.id, "reviewer"); @@ -951,7 +925,9 @@ mod tests { let dir = TempDir::new().unwrap(); write_profile(dir.path(), &draft.file_name(), &rendered); - let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(dir.path(), ProfileOrigin::Workspace) + .expect("rendered TOML loads"); assert_eq!(profiles.len(), 1); let loaded = &profiles[0]; assert_eq!(loaded.profile.model.as_deref(), Some("deepseek-v4-flash")); @@ -980,7 +956,9 @@ mod tests { let dir = TempDir::new().unwrap(); write_profile(dir.path(), &draft.file_name(), &rendered); - let profiles = load_agent_profiles_from_dir(dir.path()).expect("rendered TOML loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(dir.path(), ProfileOrigin::Workspace) + .expect("rendered TOML loads"); assert_eq!(profiles.len(), 1); let loaded = &profiles[0]; assert_eq!(loaded.profile.provider.as_deref(), Some("deepseek")); @@ -1004,7 +982,9 @@ text = "Scout deeply." "#, ); - let profiles = load_agent_profiles_from_dir(dir.path()).expect("profile TOML loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(dir.path(), ProfileOrigin::Workspace) + .expect("profile TOML loads"); assert_eq!(profiles.len(), 1); // `xhigh` used to land here too; the thinking ladder made it a rung of // its own, so `ultracode` is the alias left to exercise. @@ -1055,7 +1035,10 @@ reasoning = "expensive" "#, ); - let err = load_agent_profiles_from_dir(dir.path()).expect_err("invalid effort must fail"); + let (_profiles, issues) = + load_agent_profiles_from_dir_tolerant(dir.path(), ProfileOrigin::Workspace) + .expect("dir scan must succeed"); + let err = issues.join("\n"); assert!( err.to_string().contains("reasoning_effort"), "unexpected error: {err}" @@ -1167,7 +1150,7 @@ model = "deepseek-v4-flash" fn agent_profile_loader_returns_empty_for_missing_workspace_dir() { let tmp = TempDir::new().unwrap(); - let profiles = load_workspace_agent_profiles(tmp.path()).unwrap(); + let (profiles, _issues) = load_workspace_agent_profiles_tolerant(tmp.path()).unwrap(); assert!(profiles.is_empty()); } @@ -1188,8 +1171,9 @@ models = ["glm-5.2", "deepseek-v4-pro"] "#, ); - let identities = load_workspace_agent_profile_identities(tmp.path()) - .expect("legacy fields do not obscure identity"); + let identities = + load_agent_profile_identities_from_dir(tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR)) + .expect("legacy fields do not obscure identity"); assert_eq!( identities, @@ -1207,9 +1191,10 @@ models = ["glm-5.2", "deepseek-v4-pro"] std::fs::create_dir_all(&agents_dir).unwrap(); write_profile(&agents_dir, "broken.toml", "id = [\n"); - let err = load_workspace_agent_profile_identities(tmp.path()) - .expect_err("malformed TOML cannot prove collision safety") - .to_string(); + let err = + load_agent_profile_identities_from_dir(tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR)) + .expect_err("malformed TOML cannot prove collision safety") + .to_string(); assert!(err.contains("broken.toml"), "unexpected error: {err}"); assert!(err.contains("profile identity"), "unexpected error: {err}"); @@ -1280,9 +1265,10 @@ models = ["glm-5.2", "deepseek-v4-pro"] std::fs::create_dir_all(&agents_dir).unwrap(); write_profile(&agents_dir, "broken.toml", "id = \"bad id\"\n"); - let err = load_workspace_agent_profile_identities(tmp.path()) - .expect_err("invalid identity tokens cannot prove collision safety") - .to_string(); + let err = + load_agent_profile_identities_from_dir(tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR)) + .expect_err("invalid identity tokens cannot prove collision safety") + .to_string(); assert!(err.contains("broken.toml"), "unexpected error: {err}"); assert!(err.contains("simple token"), "unexpected error: {err}"); @@ -1302,8 +1288,9 @@ models = ["glm-5.2", "deepseek-v4-pro"] let reviewer_path = write_profile(&agents_dir, "reviewer.toml", legacy); let before = std::fs::read_to_string(&reviewer_path).unwrap(); - let identities = load_workspace_agent_profile_identities(tmp.path()) - .expect("legacy neighbor must not block identity discovery"); + let identities = + load_agent_profile_identities_from_dir(tmp.path().join(WORKSPACE_AGENT_PROFILE_DIR)) + .expect("legacy neighbor must not block identity discovery"); assert_eq!(identities.len(), 1); assert_eq!(identities[0].id, "reviewer"); assert!( @@ -1366,7 +1353,7 @@ posture = "read-only" "#, ); - let profiles = load_workspace_agent_profiles(tmp.path()).unwrap(); + let (profiles, _issues) = load_workspace_agent_profiles_tolerant(tmp.path()).unwrap(); assert_eq!(profiles.len(), 1); let profile = &profiles[0]; @@ -1413,9 +1400,10 @@ role_hint = "reviewer" ), ); - let err = load_agent_profiles_from_dir(tmp.path()) - .unwrap_err() - .to_string(); + let (_profiles, issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("dir scan must succeed"); + let err = issues.join("\n"); assert!( err.contains(field) || err.contains("unknown field"), @@ -1440,7 +1428,9 @@ model = "deepseek/deepseek-v4-pro" "#, ); - let profiles = load_agent_profiles_from_dir(tmp.path()).expect("profile loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("profile loads"); assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].profile.provider.as_deref(), Some("openrouter")); assert_eq!( @@ -1465,7 +1455,9 @@ model = "qwen-2.5-7b" "#, ); - let profiles = load_agent_profiles_from_dir(tmp.path()).expect("profile loads"); + let (profiles, _issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("profile loads"); assert_eq!(profiles[0].profile.provider.as_deref(), Some("lm-studio")); assert_eq!(profiles[0].profile.model.as_deref(), Some("qwen-2.5-7b")); @@ -1484,9 +1476,10 @@ model = "some-model" "#, ); - let err = load_agent_profiles_from_dir(tmp.path()) - .unwrap_err() - .to_string(); + let (_profiles, issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("dir scan must succeed"); + let err = issues.join("\n"); assert!( err.contains("provider must be a simple provider id"), @@ -1508,9 +1501,10 @@ posture = "read-write" "#, ); - let err = load_agent_profiles_from_dir(tmp.path()) - .unwrap_err() - .to_string(); + let (_profiles, issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("dir scan must succeed"); + let err = issues.join("\n"); assert!( err.contains("would widen permissions"), @@ -1530,29 +1524,14 @@ model = "deepseek-v4-pro api_key=secret" "#, ); - let err = load_agent_profiles_from_dir(tmp.path()) - .unwrap_err() - .to_string(); + let (_profiles, issues) = + load_agent_profiles_from_dir_tolerant(tmp.path(), ProfileOrigin::Workspace) + .expect("dir scan must succeed"); + let err = issues.join("\n"); assert!( err.contains("model must be a visible model id"), "unexpected error: {err}" ); } - - #[test] - fn agent_profile_loader_rejects_duplicate_ids() { - let tmp = TempDir::new().unwrap(); - write_profile(tmp.path(), "a.toml", "name = \"reviewer\"\n"); - write_profile(tmp.path(), "b.toml", "id = \"reviewer\"\n"); - - let err = load_agent_profiles_from_dir(tmp.path()) - .unwrap_err() - .to_string(); - - assert!( - err.contains("duplicate agent profile id reviewer"), - "unexpected error: {err}" - ); - } } diff --git a/crates/tui/src/fleet/scheduler.rs b/crates/tui/src/fleet/scheduler.rs index 39d07adfa8..2a6eb67729 100644 --- a/crates/tui/src/fleet/scheduler.rs +++ b/crates/tui/src/fleet/scheduler.rs @@ -104,36 +104,6 @@ impl FleetScheduler { Ok(report) } - pub fn cancel_run(&self, run_id: &FleetRunId, reason: &str) -> Result { - let state = self.ledger.rebuild_state()?; - let mut report = FleetSchedulerReport::default(); - for task in state - .tasks - .values() - .filter(|task| task.entry.run_id == *run_id) - { - if !matches!( - task.status, - FleetTaskLedgerStatus::Enqueued | FleetTaskLedgerStatus::Leased - ) { - continue; - } - if self.ledger.cancel_task_if_active( - &task.entry.run_id, - &task.entry.task_id, - None, - &self.timestamp(), - Some(reason), - Some("scheduler"), - )? { - report.cancelled += 1; - } - } - self.ledger - .update_run_status(run_id, FleetRunStatus::Cancelled, &self.timestamp())?; - Ok(report) - } - fn recover_unhealthy_work( &self, run_id: &FleetRunId, @@ -1010,30 +980,4 @@ mod tests { assert_eq!(state.tasks["run-1:task-a"].entry.attempts, 1); assert_eq!(state.workers["worker-1"], FleetWorkerStatus::Busy); } - - #[test] - fn fleet_scheduler_cancel_run_interrupts_active_and_cancels_queued() { - let tmp = TempDir::new().unwrap(); - let scheduler = scheduler(&tmp, 1); - create_run( - &scheduler, - "run-1", - vec![task("task-a", 3), task("task-b", 3), task("task-c", 3)], - 2, - ); - scheduler.tick_run(&FleetRunId::from("run-1")).unwrap(); - - let report = scheduler - .cancel_run(&FleetRunId::from("run-1"), "operator") - .unwrap(); - - assert_eq!(report.cancelled, 3); - let state = scheduler.ledger.rebuild_state().unwrap(); - for task in state.tasks.values() { - assert_eq!(task.status, FleetTaskLedgerStatus::Cancelled); - } - let ledger = ledger_text(&scheduler); - assert!(ledger.contains("\"state\":\"interrupted\"")); - assert!(ledger.contains("\"state\":\"cancelled\"")); - } } diff --git a/crates/tui/src/fleet/task_spec.rs b/crates/tui/src/fleet/task_spec.rs index 40104a93a8..9182f4195a 100644 --- a/crates/tui/src/fleet/task_spec.rs +++ b/crates/tui/src/fleet/task_spec.rs @@ -5,6 +5,8 @@ use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; +#[cfg(test)] +use super::ledger::FleetLedger; use anyhow::{Context, Result, bail}; use chrono::{SecondsFormat, Utc}; use codewhale_protocol::fleet::*; @@ -12,8 +14,6 @@ use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use super::ledger::FleetLedger; - const MAX_SCORER_READ_BYTES: u64 = 1_000_000; const MAX_FLEET_ID_BYTES: usize = 128; const MAX_FLEET_NAME_BYTES: usize = 256; @@ -397,17 +397,6 @@ pub fn prepare_verification_receipt( Ok(receipt) } -pub fn record_verification_receipt( - ledger: &FleetLedger, - workspace: &Path, - input: &FleetTaskVerificationInput, - verification: FleetTaskVerification, -) -> Result { - let receipt = prepare_verification_receipt(workspace, input, verification)?; - ledger.record_receipt(receipt.clone())?; - Ok(receipt) -} - fn validate_tags(task_id: &str, tags: &[String]) -> Result<()> { let mut seen = BTreeSet::new(); for tag in tags { @@ -1107,8 +1096,8 @@ mod tests { &input, ); - let receipt = - record_verification_receipt(&ledger, tmp.path(), &input, verification).unwrap(); + let receipt = prepare_verification_receipt(tmp.path(), &input, verification).unwrap(); + ledger.record_receipt(receipt.clone()).unwrap(); assert_eq!(receipt.result, FleetTaskResult::Fail); assert_eq!(receipt.failure_kind, Some(FleetTaskFailureKind::Task)); diff --git a/crates/tui/src/fleet/worker_runtime.rs b/crates/tui/src/fleet/worker_runtime.rs index 29a82e87cf..d0f6811a7c 100644 --- a/crates/tui/src/fleet/worker_runtime.rs +++ b/crates/tui/src/fleet/worker_runtime.rs @@ -2954,8 +2954,11 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join(draft.file_name()), draft.render_toml()).unwrap(); - let profiles = crate::fleet::profile::load_agent_profiles_from_dir(dir.path()) - .expect("rendered profile TOML loads"); + let (profiles, _issues) = crate::fleet::profile::load_agent_profiles_from_dir_tolerant( + dir.path(), + crate::fleet::profile::ProfileOrigin::Workspace, + ) + .expect("rendered profile TOML loads"); assert_eq!(profiles.len(), 1); assert_eq!(profiles[0].profile.provider.as_deref(), Some("openrouter")); assert_eq!(profiles[0].profile.reasoning_effort.as_deref(), Some("max")); diff --git a/crates/tui/src/hooks/config.rs b/crates/tui/src/hooks/config.rs index 63f514238d..51ac880c21 100644 --- a/crates/tui/src/hooks/config.rs +++ b/crates/tui/src/hooks/config.rs @@ -265,7 +265,7 @@ fn default_continue_on_error() -> bool { impl Hook { /// Create a new hook with minimal configuration - #[allow(dead_code)] // Public builder API, used in tests + #[allow(dead_code)] // Public builder API: parent-app tests (pinvou3-tauri) build hooks here pub fn new(event: HookEvent, command: &str) -> Self { Self { event, @@ -280,6 +280,8 @@ impl Hook { } /// Builder: set condition + // Test-fixture ergonomics only: no production caller (hooks arrive via serde). + #[cfg(test)] #[allow(dead_code)] // Public builder API, used in tests pub fn with_condition(mut self, condition: HookCondition) -> Self { self.condition = Some(condition); @@ -287,6 +289,8 @@ impl Hook { } /// Builder: set timeout + // Test-fixture ergonomics only: no production caller (hooks arrive via serde). + #[cfg(test)] #[allow(dead_code)] // Public builder API, used in tests pub fn with_timeout(mut self, secs: u64) -> Self { self.timeout_secs = secs; @@ -294,6 +298,8 @@ impl Hook { } /// Builder: run in background + // Test-fixture ergonomics only: no production caller (hooks arrive via serde). + #[cfg(test)] #[allow(dead_code)] // Public builder API, used in tests pub fn background(mut self) -> Self { self.background = true; @@ -301,6 +307,8 @@ impl Hook { } /// Builder: set name + // Test-fixture ergonomics only: no production caller (hooks arrive via serde). + #[cfg(test)] #[allow(dead_code)] // Public builder API, used in tests pub fn with_name(mut self, name: &str) -> Self { self.name = Some(name.to_string()); diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index 3bbbe36293..354c710453 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -1593,23 +1593,6 @@ impl HookExecutor { } } - /// Create a disabled `HookExecutor` (no hooks will run) - #[allow(dead_code)] // Used in tests and as convenience constructor - pub fn disabled() -> Self { - Self { - config: HooksConfig { - enabled: false, - ..Default::default() - }, - default_working_dir: PathBuf::from("."), - session_id: String::new(), - observer_dispatcher: ObserverDispatcher::new(), - background_supervisor: BackgroundSupervisor::new(), - #[cfg(test)] - lose_message_submit_executor: false, - } - } - /// Check if hooks are enabled #[allow(dead_code)] // Public API for hook system consumers pub fn is_enabled(&self) -> bool { @@ -3140,13 +3123,6 @@ mod tests { ); } - #[test] - fn executor_type_is_available_from_executor_module() { - let executor = crate::hooks::executor::HookExecutor::disabled(); - - assert!(!executor.is_enabled()); - } - /// #456 — `parse_env_lines` covers the formats users actually emit from /// shell hooks: bare `KEY=VAL`, `export KEY=VAL`, quoted values, comments, /// blank lines. Lines without `=` are dropped; values are taken verbatim @@ -3411,7 +3387,7 @@ NOEQUAL line dropped #[test] fn test_hook_condition_always() { let hook = Hook::new(HookEvent::SessionStart, "echo test"); - let executor = HookExecutor::disabled(); + let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); let context = HookContext::new(); assert!(executor.matches_condition(&hook, &context)); @@ -3425,7 +3401,7 @@ NOEQUAL line dropped }, ); - let executor = HookExecutor::disabled(); + let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); let context_match = HookContext::new().with_tool_name("exec_shell"); let context_no_match = HookContext::new().with_tool_name("write_file"); @@ -3441,7 +3417,7 @@ NOEQUAL line dropped mode: "agent".to_string(), }); - let executor = HookExecutor::disabled(); + let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); let context_match = HookContext::new().with_mode("AGENT"); // Case insensitive let context_no_match = HookContext::new().with_mode("normal"); @@ -3481,25 +3457,6 @@ NOEQUAL line dropped assert!(hooks.is_empty()); } - #[test] - fn test_hook_builder() { - let hook = Hook::new(HookEvent::ToolCallAfter, "notify.sh") - .with_name("notify_tool") - .with_timeout(60) - .background() - .with_condition(HookCondition::ToolCategory { - category: "shell".to_string(), - }); - - assert_eq!(hook.name, Some("notify_tool".to_string())); - assert_eq!(hook.timeout_secs, 60); - assert!(hook.background); - assert!(matches!( - hook.condition, - Some(HookCondition::ToolCategory { .. }) - )); - } - #[test] fn test_hook_timeout_enforced() { let command = if cfg!(windows) { @@ -4161,7 +4118,7 @@ exit 7 #[test] fn has_hooks_for_event_fast_path_returns_false_for_empty_config() { - let executor = HookExecutor::disabled(); + let executor = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); // No hooks configured AT ALL — every event is a fast skip. for event in [ HookEvent::SessionStart, @@ -5676,7 +5633,7 @@ command = "echo project" assert_eq!(labels, vec!["shell-gate".to_string()], "{labels:?}"); // Globally disabled hooks are not gates either. - let disabled = HookExecutor::disabled(); + let disabled = HookExecutor::new(HooksConfig::default(), PathBuf::from(".")); assert!( disabled .matched_strict_gate_labels( diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 1aac3dc888..8f3517b81f 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -40,7 +40,6 @@ mod commands; pub mod compaction; mod composer_history; mod composer_stash; -pub mod computer_meter; #[doc(hidden)] pub mod config; mod config_persistence; @@ -12581,16 +12580,6 @@ mod serve_bind_host_tests { ); } - #[test] - fn mobile_defaults_to_loopback() { - assert_eq!( - resolve_serve_bind_host(true, None), - ServeBindHost { - host: "127.0.0.1".to_string(), - } - ); - } - #[test] fn mobile_respects_explicit_loopback_host() { assert_eq!( @@ -16399,31 +16388,6 @@ api_key = "test-only-key" ); } - #[test] - fn cli_route_tracks_auto_reasoning_independently_from_auto_model() { - use crate::tui::app::ReasoningEffort; - - let fixed_model_auto_reasoning = CliAutoRoute { - provider: crate::config::ApiProvider::Deepseek, - model: crate::config::DEFAULT_TEXT_MODEL.to_string(), - reasoning_effort: Some(ReasoningEffort::Auto), - auto_controls_reasoning: true, - auto_model: false, - }; - let auto_model_fixed_reasoning = CliAutoRoute { - provider: crate::config::ApiProvider::OpenaiCodex, - model: crate::config::DEFAULT_OPENAI_CODEX_MODEL.to_string(), - reasoning_effort: Some(ReasoningEffort::High), - auto_controls_reasoning: false, - auto_model: true, - }; - - assert!(fixed_model_auto_reasoning.auto_controls_reasoning); - assert!(!fixed_model_auto_reasoning.auto_model); - assert!(!auto_model_fixed_reasoning.auto_controls_reasoning); - assert!(auto_model_fixed_reasoning.auto_model); - } - #[test] fn saved_reasoning_preference_overrides_config_for_non_tui_runtimes() { let mut config = Config { diff --git a/crates/tui/src/llm_client/mod.rs b/crates/tui/src/llm_client/mod.rs index 9d453bbc46..c38ac3e112 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -216,90 +216,9 @@ pub trait LlmClient: Send + Sync { // === Authentication diagnostics === -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct AuthenticationErrorContext { - pub provider: Option, - pub base_url_authority: Option, - pub model: Option, - pub key_source: Option, - pub key_fingerprint: Option, - pub key_kind: Option, -} - -impl AuthenticationErrorContext { - #[must_use] - pub fn new( - provider: &str, - base_url: &str, - model: &str, - key_source: &str, - api_key: &str, - ) -> Self { - Self::from_parts( - Some(provider), - Some(base_url), - Some(model), - Some(key_source), - Some(api_key), - ) - } - - #[must_use] - pub fn from_parts( - provider: Option<&str>, - base_url: Option<&str>, - model: Option<&str>, - key_source: Option<&str>, - api_key: Option<&str>, - ) -> Self { - let api_key = api_key.and_then(non_empty_trimmed); - Self { - provider: provider.and_then(non_empty_trimmed).map(str::to_string), - base_url_authority: base_url.and_then(base_url_authority), - model: model.and_then(non_empty_trimmed).map(str::to_string), - key_source: key_source.and_then(non_empty_trimmed).map(str::to_string), - key_fingerprint: api_key.map(redacted_key_fingerprint), - key_kind: api_key.map(classify_api_key_prefix).map(str::to_string), - } - } - - fn is_empty(&self) -> bool { - self.provider.is_none() - && self.base_url_authority.is_none() - && self.model.is_none() - && self.key_source.is_none() - && self.key_fingerprint.is_none() - && self.key_kind.is_none() - } - - fn detail_segments(&self) -> Vec { - let mut segments = Vec::new(); - if let Some(provider) = self.provider.as_deref() { - segments.push(format!("provider: {provider}")); - } - if let Some(authority) = self.base_url_authority.as_deref() { - segments.push(format!("base URL authority: {authority}")); - } - if let Some(model) = self.model.as_deref() { - segments.push(format!("model: {model}")); - } - if let Some(source) = self.key_source.as_deref() { - segments.push(format!("key source: {source}")); - } - if let Some(fingerprint) = self.key_fingerprint.as_deref() { - segments.push(format!("key fingerprint: {fingerprint}")); - } - if let Some(kind) = self.key_kind.as_deref() { - segments.push(format!("key type: {kind}")); - } - segments - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthenticationErrorDetail { message: String, - context: Option, } impl AuthenticationErrorDetail { @@ -307,19 +226,6 @@ impl AuthenticationErrorDetail { pub fn new(message: impl Into) -> Self { Self { message: message.into(), - context: None, - } - } - - #[must_use] - pub fn with_context( - message: impl Into, - context: Option, - ) -> Self { - let context = context.filter(|context| !context.is_empty()); - Self { - message: message.into(), - context, } } @@ -330,15 +236,7 @@ impl AuthenticationErrorDetail { #[must_use] pub fn to_user_message(&self) -> String { - let Some(context) = self.context.as_ref() else { - return self.message.clone(); - }; - let segments = context.detail_segments(); - if segments.is_empty() { - self.message.clone() - } else { - format!("{} ({})", self.message, segments.join(", ")) - } + self.message.clone() } } @@ -354,54 +252,6 @@ impl From<&str> for AuthenticationErrorDetail { } } -#[must_use] -pub fn classify_api_key_prefix(api_key: &str) -> &'static str { - if api_key.starts_with("tp-") { - "Xiaomi MiMo Token Plan key" - } else { - "API key" - } -} - -fn non_empty_trimmed(value: &str) -> Option<&str> { - let value = value.trim(); - if value.is_empty() { None } else { Some(value) } -} - -fn base_url_authority(base_url: &str) -> Option { - let base_url = non_empty_trimmed(base_url)?; - let without_scheme = base_url - .split_once("://") - .map_or(base_url, |(_, rest)| rest); - let authority = without_scheme.split('/').next().unwrap_or(without_scheme); - let authority = authority - .rsplit_once('@') - .map_or(authority, |(_, authority)| authority); - non_empty_trimmed(authority).map(str::to_string) -} - -fn redacted_key_fingerprint(api_key: &str) -> String { - let api_key = api_key.trim(); - let len = api_key.chars().count(); - match public_key_prefix(api_key) { - Some(prefix) => format!("{prefix}... (len={len})"), - None => format!("unprefixed (len={len})"), - } -} - -fn public_key_prefix(api_key: &str) -> Option<&str> { - ["tp-", "sk-", "hf_", "hf-", "ak-", "rk-"] - .into_iter() - .find(|prefix| api_key.starts_with(prefix)) -} - -fn redact_api_key_from_message(message: &str, api_key: Option<&str>) -> String { - let Some(api_key) = api_key.and_then(non_empty_trimmed) else { - return message.to_string(); - }; - message.replace(api_key, "[redacted API key]") -} - // === LlmError - Classified Error Types === /// Evidence captured when an HTTP response explicitly identifies plan quota @@ -634,57 +484,6 @@ impl LlmError { LlmError::AuthenticationError(AuthenticationErrorDetail::new(message)) } - #[must_use] - pub fn authentication_error_with_context( - message: impl Into, - context: Option, - ) -> Self { - LlmError::AuthenticationError(AuthenticationErrorDetail::with_context(message, context)) - } - - /// Constructs an `LlmError` from HTTP response data plus request context - /// that is safe to display when authentication fails. - #[must_use] - pub fn from_http_response_with_request_context( - status: u16, - body: &str, - provider: Option<&str>, - base_url: Option<&str>, - model: Option<&str>, - key_source: Option<&str>, - api_key: Option<&str>, - ) -> Self { - let body = redact_api_key_from_message(body, api_key); - let context = - AuthenticationErrorContext::from_parts(provider, base_url, model, key_source, api_key); - Self::from_http_response_with_auth_context(status, &body, Some(context)) - } - - /// Constructs an `LlmError` from HTTP status code and response body, with - /// optional structured details for authentication failures. - /// - /// The `body` passed here must already be safe for user display. Prefer - /// [`Self::from_http_response_with_request_context`] when the raw API key is - /// available so the response body can be redacted before rendering. - #[must_use] - pub fn from_http_response_with_auth_context( - status: u16, - body: &str, - auth_context: Option, - ) -> Self { - match status { - 401 => Self::authentication_error_with_context(body, auth_context), - 403 => { - if looks_like_authentication_failure(body) { - Self::authentication_error_with_context(body, auth_context) - } else { - LlmError::AuthorizationError(body.to_string()) - } - } - _ => Self::from_http_response(status, body), - } - } - /// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header. pub fn from_http_response_with_retry_after( status: u16, @@ -1034,7 +833,6 @@ impl From for LlmError { /// - `exponential_base`: 2.0 /// - `jitter`: true (adds randomness to prevent thundering herd) /// - `jitter_factor`: 0.1 (10% variation) -/// - `retryable_status_codes`: [429, 499, 500, 502, 503, 504] #[derive(Debug, Clone)] pub struct RetryConfig { /// Whether retry logic is enabled @@ -1061,10 +859,6 @@ pub struct RetryConfig { /// Whether to respect server's Retry-After header pub respect_retry_after: bool, - /// HTTP status codes that should trigger a retry - #[allow(dead_code)] // Used in tests via is_retryable_status() - pub retryable_status_codes: Vec, - /// Timeout for individual requests (seconds, 0 = no timeout) #[allow(dead_code)] // Configuration field for retry consumers pub request_timeout: f64, @@ -1084,7 +878,6 @@ impl Default for RetryConfig { jitter: true, jitter_factor: 0.1, respect_retry_after: true, - retryable_status_codes: vec![429, 499, 500, 502, 503, 504], request_timeout: 120.0, total_timeout: 0.0, // No total timeout by default } @@ -1163,11 +956,6 @@ impl RetryConfig { Duration::from_secs_f64(final_delay) } - - /// Checks if a given HTTP status code should trigger a retry - pub fn is_retryable_status(&self, status: u16) -> bool { - self.retryable_status_codes.contains(&status) - } } /// Converts from the existing `RetryPolicy` in config @@ -1184,19 +972,6 @@ impl From for RetryConfig { } } -/// Converts back to `RetryPolicy` for compatibility -impl From for RetryPolicy { - fn from(config: RetryConfig) -> Self { - Self { - enabled: config.enabled, - max_retries: config.max_retries, - initial_delay: config.initial_delay, - max_delay: config.max_delay, - exponential_base: config.exponential_base, - } - } -} - // === Retry Error and Result Types === /// Error returned when all retry attempts have been exhausted. @@ -1430,13 +1205,6 @@ mod tests { ); } - fn auth_user_message(error: LlmError) -> String { - match error { - LlmError::AuthenticationError(auth) => auth.to_user_message(), - other => panic!("expected authentication error, got {other}"), - } - } - #[test] fn test_retry_config_defaults() { let config = RetryConfig::default(); @@ -1516,126 +1284,6 @@ mod tests { assert!(d2.as_secs_f64() <= base + range); } - #[test] - fn test_is_retryable_status() { - let config = RetryConfig::default(); - - assert!(config.is_retryable_status(429)); // Rate limit - assert!(config.is_retryable_status(499)); // Upstream request cancelled - assert!(config.is_retryable_status(500)); // Internal server error - assert!(config.is_retryable_status(502)); // Bad gateway - assert!(config.is_retryable_status(503)); // Service unavailable - assert!(config.is_retryable_status(504)); // Gateway timeout - - assert!(!config.is_retryable_status(400)); // Bad request - assert!(!config.is_retryable_status(401)); // Unauthorized - assert!(!config.is_retryable_status(403)); // Forbidden - assert!(!config.is_retryable_status(404)); // Not found - } - - #[test] - fn auth_error_with_context_includes_provider_authority_model_and_key_source() { - let err = LlmError::from_http_response_with_request_context( - 401, - "Invalid API Key", - Some("Xiaomi MiMo"), - Some("https://token-plan-sgp.xiaomimimo.com/v1"), - Some("mimo-v2.5"), - Some("env"), - Some("tp-secret-token-plan-value"), - ); - let message = auth_user_message(err); - - assert!(message.contains("Invalid API Key")); - assert!(message.contains("provider: Xiaomi MiMo")); - assert!(message.contains("base URL authority: token-plan-sgp.xiaomimimo.com")); - assert!(message.contains("model: mimo-v2.5")); - assert!(message.contains("key source: env")); - assert!(message.contains("key fingerprint: tp-... (len=26)")); - } - - #[test] - fn auth_error_redacts_full_api_key_from_body_and_context() { - let api_key = "tp-secret-token-plan-value"; - let err = LlmError::from_http_response_with_request_context( - 401, - &format!("Invalid API Key: {api_key}"), - Some("Xiaomi MiMo"), - Some("https://token-plan-sgp.xiaomimimo.com/v1"), - Some("mimo-v2.5"), - Some("config-file"), - Some(api_key), - ); - let message = auth_user_message(err); - - assert!(!message.contains(api_key)); - assert!(!message.contains("secret-token-plan-value")); - assert!(message.contains("[redacted API key]")); - assert!(message.contains("key fingerprint: tp-... (len=26)")); - } - - #[test] - fn auth_error_classifies_xiaomi_token_plan_key_prefix() { - let token_plan = AuthenticationErrorContext::from_parts( - None, - None, - None, - Some("session"), - Some("tp-secret-token-plan-value"), - ); - let generic = AuthenticationErrorContext::from_parts( - None, - None, - None, - Some("session"), - Some("sk-other"), - ); - let unprefixed = AuthenticationErrorContext::from_parts( - None, - None, - None, - Some("session"), - Some("plainsecretvalue"), - ); - - assert_eq!( - token_plan.key_kind.as_deref(), - Some("Xiaomi MiMo Token Plan key") - ); - assert_eq!(generic.key_kind.as_deref(), Some("API key")); - assert_eq!(unprefixed.key_kind.as_deref(), Some("API key")); - assert_eq!( - unprefixed.key_fingerprint.as_deref(), - Some("unprefixed (len=16)") - ); - } - - #[test] - fn authorization_403_is_not_reclassified_by_auth_context() { - let err = LlmError::from_http_response_with_request_context( - 403, - "forbidden", - Some("Arcee AI"), - Some("https://api.arcee.ai/v1"), - Some("auto"), - Some("env"), - Some("sk-arcee-secret"), - ); - - assert!(matches!(err, LlmError::AuthorizationError(_))); - } - - #[test] - fn auth_error_without_context_preserves_bare_message() { - let err = LlmError::from_http_response_with_auth_context( - 401, - "Invalid API Key", - Some(AuthenticationErrorContext::default()), - ); - - assert_eq!(auth_user_message(err), "Invalid API Key"); - } - /// A flat `{"error","message"}` body keeps its detail: the class alone /// ("Bad Request") told the person nothing about the rejected model. #[test] @@ -1804,11 +1452,6 @@ mod tests { assert_f64_eq(config.initial_delay, policy.initial_delay); assert_f64_eq(config.max_delay, policy.max_delay); assert_f64_eq(config.exponential_base, policy.exponential_base); - - // Convert back - let policy2: RetryPolicy = config.into(); - assert_eq!(policy2.enabled, policy.enabled); - assert_eq!(policy2.max_retries, policy.max_retries); } #[tokio::test] diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 1212303fe0..74ffed4b8e 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -6391,19 +6391,6 @@ mod tests { } } - #[test] - fn no_shipped_locale_renders_a_missing_message_marker() { - // rust_i18n falls back to en for absent keys, so a "{MessageId}" - // debug string in the UI would mean the fallback chain itself broke. - for locale in Locale::shipped() { - assert!( - missing_message_ids(*locale).is_empty(), - "{} renders raw message ids (missing-marker UI)", - locale.tag() - ); - } - } - // --- Devanagari grapheme safety (#4790 spike) -------------------------- #[test] diff --git a/crates/tui/src/mcp/tests.rs b/crates/tui/src/mcp/tests.rs index d08f9f5c4e..00816a0835 100644 --- a/crates/tui/src/mcp/tests.rs +++ b/crates/tui/src/mcp/tests.rs @@ -2,8 +2,7 @@ use super::headers::{MCP_HTTP_ACCEPT, is_safe_custom_header, with_default_mcp_ht use super::http::{HttpTransport, McpHttpAuth}; use super::streamable_http::StreamableHttpTransport; use super::wire::{ - find_sse_event_separator, find_sse_event_separator_bytes, is_mcp_stale_session_error, - parse_sse_message_data, + find_sse_event_separator_bytes, is_mcp_stale_session_error, parse_sse_message_data, }; use super::*; use reqwest::header::{ACCEPT, CONTENT_TYPE}; @@ -4386,18 +4385,6 @@ fn legacy_sse_transport_requires_explicit_config() { assert!(!is_legacy_sse_transport(&server)); } -#[test] -fn find_sse_event_separator_accepts_lf_and_crlf() { - assert_eq!( - find_sse_event_separator("event: endpoint\n\n"), - Some((15, 2)) - ); - assert_eq!( - find_sse_event_separator("event: endpoint\r\n\r\n"), - Some((15, 4)) - ); -} - #[test] fn find_sse_event_separator_bytes_matches_str_and_survives_multibyte() { // Same offsets as the str version. diff --git a/crates/tui/src/mcp/wire.rs b/crates/tui/src/mcp/wire.rs index 7ab55e0f37..6dbe35681d 100644 --- a/crates/tui/src/mcp/wire.rs +++ b/crates/tui/src/mcp/wire.rs @@ -64,18 +64,7 @@ pub(super) fn parse_sse_message_data(body: &str) -> Vec> { messages } -// Retained for tests; the SSE transport now uses the byte-oriented twin. -#[cfg(test)] -pub(super) fn find_sse_event_separator(buffer: &str) -> Option<(usize, usize)> { - match (buffer.find("\n\n"), buffer.find("\r\n\r\n")) { - (Some(lf), Some(crlf)) if crlf < lf => Some((crlf, 4)), - (Some(lf), _) => Some((lf, 2)), - (_, Some(crlf)) => Some((crlf, 4)), - _ => None, - } -} - -/// Byte-oriented twin of `find_sse_event_separator`. Used by the SSE +/// Byte-oriented SSE event-separator finder. Used by the SSE /// transport so it can accumulate RAW bytes and decode only complete event /// blocks — a multi-byte UTF-8 char split across two network reads is never /// corrupted to U+FFFD (the `\n`/`\r` separators are ASCII and can never fall diff --git a/crates/tui/src/model_profile.rs b/crates/tui/src/model_profile.rs index 4d520ff03f..15a2659102 100644 --- a/crates/tui/src/model_profile.rs +++ b/crates/tui/src/model_profile.rs @@ -103,31 +103,6 @@ impl CapabilityProfile { pub fn supports_reasoning(&self) -> bool { self.reasoning.is_supported() } - - #[must_use] - pub fn supports_image_input(&self) -> bool { - self.image_input.is_supported() - } - - #[must_use] - pub fn has_large_context(&self) -> bool { - self.context_window.is_some_and(|window| window >= 400_000) - } - - #[must_use] - pub fn prefers_full_tool_surface(&self) -> bool { - matches!(self.tool_surface_budget, ToolSurfaceBudget::Full) - } - - #[must_use] - pub fn suitable_for_broad_fleet_worker(&self) -> bool { - self.has_large_context() - && !matches!(self.native_tool_calls, SupportState::Unsupported) - && matches!( - self.tool_surface_budget, - ToolSurfaceBudget::Standard | ToolSurfaceBudget::Full - ) - } } /// Build an intrinsic profile for any model string. @@ -472,28 +447,6 @@ mod tests { assert!(profile.provenance.contains(&FactProvenance::UserOverride)); } - #[test] - fn capability_predicates_are_not_provider_string_checks() { - let broad = resolved_capability_profile(ApiProvider::Deepseek, "deepseek-v4-pro"); - let compact = resolved_capability_profile_with_overrides( - ApiProvider::Openrouter, - "unknown-small-model", - CapabilityOverride { - context_window: Some(32_000), - native_tool_calls: Some(SupportState::Unknown), - tool_surface_budget: Some(ToolSurfaceBudget::Compact), - ..CapabilityOverride::default() - }, - ); - - assert!(broad.has_large_context()); - assert!(broad.prefers_full_tool_surface()); - assert!(broad.suitable_for_broad_fleet_worker()); - assert!(!compact.has_large_context()); - assert!(!compact.prefers_full_tool_surface()); - assert!(!compact.suitable_for_broad_fleet_worker()); - } - #[test] fn exact_route_facts_override_legacy_provider_heuristics() { let profile = resolved_capability_profile_for_route( @@ -564,7 +517,7 @@ mod tests { }, RouteLimits::default(), ); - assert!(sourced.supports_image_input()); + assert_eq!(sourced.image_input, SupportState::Supported); let overridden = resolved_capability_profile_for_route_with_overrides( ApiProvider::Openai, @@ -576,6 +529,6 @@ mod tests { ..CapabilityOverride::default() }, ); - assert!(overridden.supports_image_input()); + assert_eq!(overridden.image_input, SupportState::Supported); } } diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 4b3995ee86..fe3ec7350d 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -36,8 +36,6 @@ pub const DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS: u32 = 1_048_576; /// models resolve to their own scaled value via /// `compaction_threshold_for_model` (#664). pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400; -#[cfg(test)] -const COMPACTION_THRESHOLD_PERCENT: u32 = 80; // === Core Message Types === @@ -655,34 +653,6 @@ fn explicit_context_window_hint(model_lower: &str) -> Option { None } -/// Derive a compaction token threshold from model context and a caller-supplied -/// percentage. -#[must_use] -#[cfg(test)] -pub fn compaction_threshold_for_model_at_percent(model: &str, percent: f64) -> usize { - let Some(window) = context_window_for_model(model) else { - return DEFAULT_COMPACTION_TOKEN_THRESHOLD; - }; - - let percent = percent.clamp(10.0, 100.0); - let threshold = (f64::from(window) * percent / 100.0).round(); - let threshold = if threshold.is_finite() && threshold > 0.0 { - threshold as u64 - } else { - u64::from(window) * u64::from(COMPACTION_THRESHOLD_PERCENT) / 100 - }; - usize::try_from(threshold).unwrap_or(DEFAULT_COMPACTION_TOKEN_THRESHOLD) -} - -/// Whether auto-compaction should be enabled when the user did not explicitly -/// configure it. Known model windows default automatic continuity on; an -/// explicit `auto_compact = false` remains authoritative at the call sites. -#[must_use] -#[cfg(test)] -pub fn auto_compact_default_for_model(model: &str) -> bool { - context_window_for_model(model).is_some() -} - // === Streaming Structures === #[allow(dead_code)] @@ -1019,10 +989,6 @@ mod tests { assert_eq!(context_window_for_model(model), Some(1_050_000)); assert_eq!(max_output_tokens_for_model(model), Some(128_000)); assert!(model_supports_reasoning(model)); - assert_eq!( - compaction_threshold_for_model_at_percent(model, 80.0), - 840_000 - ); } for model in [ @@ -1034,10 +1000,6 @@ mod tests { assert_eq!(context_window_for_model(model), Some(1_050_000)); assert_eq!(max_output_tokens_for_model(model), Some(128_000)); assert!(model_supports_reasoning(model)); - assert_eq!( - compaction_threshold_for_model_at_percent(model, 80.0), - 840_000 - ); } for model in [ @@ -1055,10 +1017,6 @@ mod tests { assert_eq!(context_window_for_model(model), Some(400_000)); assert_eq!(max_output_tokens_for_model(model), Some(128_000)); assert!(model_supports_reasoning(model)); - assert_eq!( - compaction_threshold_for_model_at_percent(model, 80.0), - 320_000 - ); } assert_eq!(context_window_for_model("gpt-5.5-nano"), None); @@ -1450,51 +1408,4 @@ mod tests { Some(LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS) ); } - - #[test] - fn compaction_threshold_scales_with_context_window() { - assert_eq!( - compaction_threshold_for_model_at_percent("deepseek-v3.2-128k", 80.0), - 102_400 - ); - // v0.8.11 (#664): unknown-model fallback also resolves to 80% of - // `LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS` (128K legacy DeepSeek - // fallback) — same late-trigger discipline as the V4 path. Was - // `50_000` pre-v0.8.11; that hardcoded value compacted at ~5% of a - // 1M window when model detection silently fell through, which is - // exactly the prefix-cache-burning behaviour we're getting away from. - assert_eq!( - compaction_threshold_for_model_at_percent("unknown-model", 80.0), - 102_400 - ); - } - - #[test] - fn compaction_scales_for_deepseek_v4_1m_context() { - assert_eq!( - compaction_threshold_for_model_at_percent("deepseek-v4-pro", 80.0), - 800_000 - ); - } - - #[test] - fn compaction_threshold_honors_configured_percent() { - assert_eq!( - compaction_threshold_for_model_at_percent("deepseek-v4-pro", 75.0), - 750_000 - ); - assert_eq!( - compaction_threshold_for_model_at_percent("trinity-large-thinking", 80.0), - 209_715 - ); - } - - #[test] - fn auto_compaction_defaults_on_for_known_supported_model_windows() { - assert!(auto_compact_default_for_model("trinity-large-thinking")); - assert!(auto_compact_default_for_model("deepseek-v3.2-128k")); - assert!(auto_compact_default_for_model("deepseek-v4-pro")); - assert!(auto_compact_default_for_model("mimo-v2.5-pro")); - assert!(!auto_compact_default_for_model("unknown-model")); - } } diff --git a/crates/tui/src/palette/mod.rs b/crates/tui/src/palette/mod.rs index 7ba2771456..fc1429534b 100644 --- a/crates/tui/src/palette/mod.rs +++ b/crates/tui/src/palette/mod.rs @@ -34,4 +34,5 @@ pub use osc11::*; #[allow(unused_imports)] pub use themes::*; pub use tokens::*; +#[allow(unused_imports)] pub use user_theme::*; diff --git a/crates/tui/src/palette/tests.rs b/crates/tui/src/palette/tests.rs index 8971e9aa11..ec9df7f858 100644 --- a/crates/tui/src/palette/tests.rs +++ b/crates/tui/src/palette/tests.rs @@ -11,20 +11,19 @@ use super::themes::{ CATPPUCCIN_MOCHA_UI_THEME, GRAYSCALE_UI_THEME, LIGHT_UI_THEME, MATRIX_UI_THEME, SELECTABLE_THEMES, SOLARIZED_LIGHT_UI_THEME, TERMINAL_UI_THEME, TOKYO_NIGHT_UI_THEME, ThemeId, UI_THEME, UiTheme, normalize_hex_rgb_color, normalize_theme_name, parse_hex_rgb_color, - theme_label_for_mode, ui_theme_from_settings, + theme_label_for_mode, }; use super::tokens::{ - ACCENT_REASONING_LIVE, DIFF_ADDED, DIFF_ADDED_BG, DIFF_DELETED_BG, GRAYSCALE_BORDER, - GRAYSCALE_ELEVATED, GRAYSCALE_PANEL, GRAYSCALE_REASONING, GRAYSCALE_SURFACE, - GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, GRAYSCALE_TEXT_SOFT, LIGHT_ACTION, LIGHT_BORDER, - LIGHT_DANGER, LIGHT_ELEVATED, LIGHT_HUMAN, LIGHT_LIVE, LIGHT_PANEL, LIGHT_REASONING, - LIGHT_SELECTION_BG, LIGHT_SUCCESS_FG, LIGHT_SURFACE, LIGHT_TEXT_BODY, LIGHT_TEXT_BODY_RGB, - LIGHT_TEXT_HINT, LIGHT_WARNING, MODE_AGENT, MODE_PLAN, MODE_YOLO, SELECTION_BG, - SOLARIZED_PANEL, SOLARIZED_SURFACE, SOLARIZED_TEXT_BODY, SOLARIZED_TEXT_HINT, STATUS_ERROR, - STATUS_WARNING, SURFACE_ERROR, SURFACE_REASONING, SURFACE_REASONING_TINT, SURFACE_TOOL_ACTIVE, - TEXT_BODY, TEXT_HINT, TEXT_REASONING, TEXT_TOOL_OUTPUT, WHALE_ACTION, WHALE_BG, WHALE_ERROR, - WHALE_HUMAN, WHALE_LIVE, WHALE_PANEL, WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, - WHALE_TEXT_BODY_RGB, + ACCENT_REASONING_LIVE, DIFF_ADDED, DIFF_ADDED_BG, DIFF_DELETED_BG, GRAYSCALE_PANEL, + GRAYSCALE_REASONING, GRAYSCALE_SURFACE, GRAYSCALE_TEXT_BODY, GRAYSCALE_TEXT_HINT, + GRAYSCALE_TEXT_SOFT, LIGHT_ACTION, LIGHT_BORDER, LIGHT_DANGER, LIGHT_ELEVATED, LIGHT_HUMAN, + LIGHT_LIVE, LIGHT_PANEL, LIGHT_REASONING, LIGHT_SELECTION_BG, LIGHT_SUCCESS_FG, LIGHT_SURFACE, + LIGHT_TEXT_BODY, LIGHT_TEXT_BODY_RGB, LIGHT_TEXT_HINT, LIGHT_WARNING, MODE_AGENT, MODE_PLAN, + MODE_YOLO, SELECTION_BG, SOLARIZED_PANEL, SOLARIZED_SURFACE, SOLARIZED_TEXT_BODY, + SOLARIZED_TEXT_HINT, STATUS_ERROR, STATUS_WARNING, SURFACE_ERROR, SURFACE_REASONING, + SURFACE_REASONING_TINT, SURFACE_TOOL_ACTIVE, TEXT_BODY, TEXT_HINT, TEXT_REASONING, + TEXT_TOOL_OUTPUT, WHALE_ACTION, WHALE_BG, WHALE_ERROR, WHALE_HUMAN, WHALE_LIVE, WHALE_PANEL, + WHALE_REASONING_TEXT_RGB, WHALE_REASONING_TINT_RGB, WHALE_TEXT_BODY_RGB, }; use ratatui::style::Color; @@ -404,18 +403,6 @@ fn grayscale_luma_handles_bright_rgb_without_overflow() { ); } -#[test] -fn ui_theme_from_settings_applies_theme_and_background() { - let theme = ui_theme_from_settings("grayscale", Some("#111111")); - assert_eq!(theme.mode, PaletteMode::Grayscale); - assert_eq!(theme.surface_bg, Color::Rgb(17, 17, 17)); - assert_eq!(theme.header_bg, Color::Rgb(17, 17, 17)); - assert_eq!(theme.footer_bg, Color::Rgb(17, 17, 17)); - assert_eq!(theme.panel_bg, GRAYSCALE_PANEL); - assert_eq!(theme.elevated_bg, GRAYSCALE_ELEVATED); - assert_eq!(theme.border, GRAYSCALE_BORDER); -} - #[test] fn adapt_color_passes_through_truecolor() { let c = Color::Rgb(53, 120, 229); diff --git a/crates/tui/src/palette/themes.rs b/crates/tui/src/palette/themes.rs index da6f9634f1..8dace902e8 100644 --- a/crates/tui/src/palette/themes.rs +++ b/crates/tui/src/palette/themes.rs @@ -1116,14 +1116,6 @@ pub fn theme_label_for_mode(mode: PaletteMode) -> &'static str { } } -#[must_use] -#[cfg_attr(not(test), allow(dead_code))] -pub fn ui_theme_from_settings(theme: &str, background_color: Option<&str>) -> UiTheme { - super::resolve_theme_setting(theme, background_color) - .map(|(_, _, theme)| theme) - .unwrap_or_else(|_| UiTheme::detect()) -} - #[must_use] pub fn parse_hex_rgb_color(value: &str) -> Option { let hex = value.trim().strip_prefix('#').unwrap_or(value.trim()); diff --git a/crates/tui/src/prompt_zones.rs b/crates/tui/src/prompt_zones.rs index 06a850ab40..54ebb22dea 100644 --- a/crates/tui/src/prompt_zones.rs +++ b/crates/tui/src/prompt_zones.rs @@ -201,10 +201,6 @@ impl AppendLog { } } - pub fn from_messages(messages: Vec) -> Self { - Self { messages } - } - /// Append a message to the log. A single-message push is the cheapest /// mutation for prefix-cache stability — it extends the byte sequence /// without disturbing earlier turns. @@ -555,14 +551,6 @@ mod tests { assert_eq!(messages.len(), 2); } - #[test] - fn append_log_from_messages() { - let msgs = vec![make_message("user", "a"), make_message("assistant", "b")]; - let log = AppendLog::from_messages(msgs); - assert_eq!(log.len(), 2); - assert_eq!(log.as_slice().len(), 2); - } - // ── TurnScratch ─────────────────────────────────────────────── #[test] diff --git a/crates/tui/src/provider_lake.rs b/crates/tui/src/provider_lake.rs index d1298e5fb7..82fb0f78dc 100644 --- a/crates/tui/src/provider_lake.rs +++ b/crates/tui/src/provider_lake.rs @@ -1389,27 +1389,6 @@ mod tests { ); } - #[test] - fn configured_providers_matches_provider_predicate() { - let _env_lock = crate::test_support::lock_test_env(); - let tmp = tempfile::tempdir().expect("tempdir"); - let _auth_file = crate::test_support::EnvVarGuard::set( - "OPENAI_CODEX_AUTH_FILE", - tmp.path().join("missing-auth.json"), - ); - let _openai_token = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN"); - let _codex_token = crate::test_support::EnvVarGuard::remove("CODEX_ACCESS_TOKEN"); - let config = Config::default(); - let active = ApiProvider::Deepseek; - let expected: Vec<_> = ApiProvider::sorted_for_display() - .into_iter() - .filter(|provider| { - crate::config::provider_is_configured_for_active(&config, *provider, active) - }) - .collect(); - assert_eq!(configured_providers(&config, active), expected); - } - #[test] fn models_for_provider_filters_unconfigured_gateways() { let _env_lock = crate::test_support::lock_test_env(); diff --git a/crates/tui/src/remote_setup/bundle.rs b/crates/tui/src/remote_setup/bundle.rs index 4e3c335f39..9d487538db 100644 --- a/crates/tui/src/remote_setup/bundle.rs +++ b/crates/tui/src/remote_setup/bundle.rs @@ -539,25 +539,6 @@ mod tests { assert_eq!(files.len(), 5); } - #[test] - fn runtime_and_bridge_share_the_token() { - let inputs = sample_inputs(&AZURE, &TELEGRAM, "openai"); - let files = render_bundle(&inputs); - let runtime = &files - .iter() - .find(|f| f.relative_path == "runtime.env") - .unwrap() - .contents; - let bridge = &files - .iter() - .find(|f| f.relative_path == "telegram.env") - .unwrap() - .contents; - let token_line = format!("CODEWHALE_RUNTIME_TOKEN={}", inputs.runtime_token); - assert!(runtime.contains(&token_line), "runtime.env missing token"); - assert!(bridge.contains(&token_line), "bridge env missing token"); - } - #[test] fn env_files_lead_with_codewhale_keys() { let inputs = sample_inputs(&DIGITALOCEAN, &TELEGRAM, "deepseek"); diff --git a/crates/tui/src/resource_telemetry.rs b/crates/tui/src/resource_telemetry.rs index e70c07ead7..5cae28e549 100644 --- a/crates/tui/src/resource_telemetry.rs +++ b/crates/tui/src/resource_telemetry.rs @@ -194,22 +194,9 @@ impl TokenThroughput { }) } - pub fn from_estimated_text(text: &str, elapsed: Duration) -> Option { - Self::new(estimate_output_tokens_from_text(text), elapsed) - } - pub fn tokens_per_second(self) -> f64 { self.output_tokens as f64 / self.elapsed_seconds } - - pub fn compact_rate(self) -> String { - let rate = self.tokens_per_second(); - if rate < 10.0 { - format!("{rate:.1}") - } else { - format!("{rate:.0}") - } - } } /// Estimate output tokens from streamed text before provider usage arrives. @@ -313,13 +300,9 @@ mod tests { // ---- throughput ------------------------------------------------------- #[test] - fn token_throughput_formats_compact_rates() { + fn token_throughput_formats_tokens_per_second() { let throughput = TokenThroughput::new(120, Duration::from_secs(6)).expect("throughput"); assert_eq!(throughput.tokens_per_second(), 20.0); - assert_eq!(throughput.compact_rate(), "20"); - - let slow = TokenThroughput::new(15, Duration::from_secs(4)).expect("throughput"); - assert_eq!(slow.compact_rate(), "3.8"); } #[test] @@ -334,12 +317,6 @@ mod tests { assert_eq!(estimate_output_tokens_from_text("abc"), 1); assert_eq!(estimate_output_tokens_from_text("abcd"), 1); assert_eq!(estimate_output_tokens_from_text("abcde"), 2); - - let throughput = - TokenThroughput::from_estimated_text(&"x".repeat(400), Duration::from_secs(10)) - .expect("estimated throughput"); - assert_eq!(throughput.output_tokens, 100); - assert_eq!(throughput.compact_rate(), "10"); } // ---- fraction / percent ---------------------------------------------- diff --git a/crates/tui/src/route_billing.rs b/crates/tui/src/route_billing.rs index a7a28ef613..5c6268943a 100644 --- a/crates/tui/src/route_billing.rs +++ b/crates/tui/src/route_billing.rs @@ -1595,27 +1595,6 @@ mod tests { ); } - /// A dual-mode child provider with no dispatch config is *unknown*, not a - /// subscription. It still never shows dollars, but the distinction is what - /// keeps its spend inside `/cost`'s coverage denominator instead of being - /// excused as quota-billed (#4318). - #[test] - fn routed_zai_child_never_claims_api_dollars_without_full_route_config() { - let billing = for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - ApiProvider::Zai, - None, - ); - assert_eq!( - billing, - BillingPresentation::Unknown, - "without the child's route truth, fail closed instead of guessing a quota" - ); - assert!(!billing.shows_money()); - assert_eq!(billing.label(), Some("unknown")); - } - /// Child-route billing for each shape a child can take. Without the /// child's own provenance, only a local runtime is exactly non-metered; /// every other cross-provider child fails closed to Unknown, and Unknown @@ -1660,6 +1639,7 @@ mod tests { ); assert_eq!(billing, BillingPresentation::Unknown, "{provider:?}"); assert!(!billing.shows_money(), "{provider:?}"); + assert_eq!(billing.label(), Some("unknown"), "{provider:?}"); let audit = crate::pricing::audit_turn_cost_for_route( provider, "some-model", @@ -2031,50 +2011,6 @@ mod tests { } } - #[test] - fn unknown_cross_provider_oauth_capable_child_never_invents_dollars() { - assert!( - !for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - ApiProvider::Xai, - None, - ) - .shows_money() - ); - // Identity alone no longer claims metered dollars either: without the - // child's own route truth a cross-provider child fails closed. - assert!( - !for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - ApiProvider::Openrouter, - None, - ) - .shows_money() - ); - // Unknown, not an invented "provider quota" subscription. - assert_eq!( - for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - ApiProvider::Xai, - None, - ), - BillingPresentation::Unknown - ); - // The child's own metered provenance is what prices the route. - assert!( - for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - ApiProvider::Openrouter, - Some(BillingPresentation::Metered), - ) - .shows_money() - ); - } - #[test] fn standard_mimo_env_key_uses_metered_presentation() { let _lock = crate::test_support::lock_test_env(); @@ -2556,36 +2492,6 @@ mod tests { ); } - #[test] - fn cross_provider_child_without_provenance_fails_closed_unknown() { - // Moonshot and MiniMax both run metered AND subscription routes, so - // identity alone must never guess either direction. - for child in [ApiProvider::Moonshot, ApiProvider::Minimax] { - assert_eq!( - for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - child, - None, - ), - BillingPresentation::Unknown, - "{child:?} identity must not guess subscription or metered billing" - ); - } - // Local routes are the one identity-derived fact that stays truthful. - for child in [ApiProvider::Ollama, ApiProvider::Sglang, ApiProvider::Vllm] { - assert_eq!( - for_child_route( - ApiProvider::Deepseek, - BillingPresentation::Metered, - child, - None, - ), - BillingPresentation::Local - ); - } - } - #[test] fn child_provenance_wins_over_parent_route_and_provider_identity() { // Direct-platform Moonshot child under a Kimi Code membership diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index dc14ea6777..ac220076f3 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -10628,7 +10628,8 @@ fn create_managed_skill(root_dir: &std::path::Path, name: &str) -> Result<(PathB )?; // Write an `.installed-from` marker so the mutation module considers it // managed (and therefore eligible for update/remove/trust). - let digest = crate::skills::audit::compute_package_digest(&skill_dir).expect("package digest"); + let digest = + crate::skills::package_digest::compute_package_digest(&skill_dir).expect("package digest"); crate::skills::install::write_installed_from_v2( &skill_dir, &format!("github:test/{name}"), diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index a62be8084a..2ba60080c8 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -1061,15 +1061,6 @@ mod tests { // ── Parity tests (#2187) ────────────────────────────────────────────── - #[test] - fn test_parity_platform_sandbox_detection() { - let sandbox_type = get_platform_sandbox(); - let available = is_sandbox_available(); - if available { - assert!(sandbox_type.is_some()); - } - } - #[test] #[cfg(target_os = "macos")] fn test_parity_macos_seatbelt_available() { diff --git a/crates/tui/src/sandbox/seccomp.rs b/crates/tui/src/sandbox/seccomp.rs index 6f89c00ae2..c5f94072e7 100644 --- a/crates/tui/src/sandbox/seccomp.rs +++ b/crates/tui/src/sandbox/seccomp.rs @@ -396,12 +396,4 @@ mod tests { assert!(!detect_denial(0, "Success")); assert!(!detect_denial(1, "File not found")); } - - #[test] - fn test_detect_denial_non_linux() { - #[cfg(not(target_os = "linux"))] - { - assert!(!detect_denial(31, "Bad system call")); - } - } } diff --git a/crates/tui/src/session_control_acceptance.rs b/crates/tui/src/session_control_acceptance.rs index ef25589e2d..64713442c7 100644 --- a/crates/tui/src/session_control_acceptance.rs +++ b/crates/tui/src/session_control_acceptance.rs @@ -841,48 +841,16 @@ mod tests { #[test] fn session_and_thread_archive_filters_share_one_resolution() { - use crate::runtime_threads::ThreadListFilter; - - for (include, only, expected_session, expected_thread) in [ - ( - None, - None, - SessionListFilter::ActiveOnly, - ThreadListFilter::ActiveOnly, - ), - ( - Some(true), - None, - SessionListFilter::IncludeArchived, - ThreadListFilter::IncludeArchived, - ), - ( - None, - Some(true), - SessionListFilter::ArchivedOnly, - ThreadListFilter::ArchivedOnly, - ), - ( - Some(true), - Some(true), - SessionListFilter::ArchivedOnly, - ThreadListFilter::ArchivedOnly, - ), + for (include, only, expected_session) in [ + (None, None, SessionListFilter::ActiveOnly), + (Some(true), None, SessionListFilter::IncludeArchived), + (None, Some(true), SessionListFilter::ArchivedOnly), + (Some(true), Some(true), SessionListFilter::ArchivedOnly), ] { assert_eq!( SessionListFilter::from_query(include, only), expected_session ); - // Thread-side expectation is spelled out so a future change to - // either resolver breaks this test rather than drifting quietly. - let thread = if only.unwrap_or(false) { - ThreadListFilter::ArchivedOnly - } else if include.unwrap_or(false) { - ThreadListFilter::IncludeArchived - } else { - ThreadListFilter::ActiveOnly - }; - assert_eq!(thread, expected_thread); } } diff --git a/crates/tui/src/session_manager.rs b/crates/tui/src/session_manager.rs index d22ebb4c70..40317e066c 100644 --- a/crates/tui/src/session_manager.rs +++ b/crates/tui/src/session_manager.rs @@ -3744,31 +3744,6 @@ mod tests { assert_eq!(scoped.id, "current-workspace"); } - #[test] - fn latest_session_for_workspace_ignores_invalid_parent_git_marker() { - let tmp = tempdir().expect("tempdir"); - let manager = SessionManager::new(tmp.path().join("sessions")).expect("new"); - let workspace_a = tmp.path().join("aa").join("aaa"); - let workspace_b = tmp.path().join("bb").join("bbb"); - fs::create_dir_all(&workspace_a).expect("mkdir workspace a"); - fs::create_dir_all(&workspace_b).expect("mkdir workspace b"); - fs::create_dir_all(tmp.path().join(".git")).expect("mkdir invalid git marker"); - - write_session_record( - &manager, - "current-workspace", - &workspace_a, - Utc::now() - chrono::Duration::minutes(10), - ); - write_session_record(&manager, "other-workspace", &workspace_b, Utc::now()); - - let scoped = manager - .get_latest_session_for_workspace(&workspace_a) - .expect("latest for workspace") - .expect("scoped latest"); - assert_eq!(scoped.id, "current-workspace"); - } - #[test] fn latest_session_for_workspace_matches_same_git_repository() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6858c4eaeb..27ce9735cb 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -3744,17 +3744,6 @@ mod tests { ))); } - #[test] - fn default_settings_show_footer_water_strip() { - let settings = Settings::default(); - assert!( - settings.fancy_animations, - "underwater presentation is the default" - ); - assert!(!settings.low_motion); - assert_eq!(settings.transcript_spacing, "comfortable"); - } - #[test] fn retired_launch_screen_setting_is_accepted_and_dropped_on_save() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs index 6c47757e57..a90058f1b6 100644 --- a/crates/tui/src/shell_dispatcher.rs +++ b/crates/tui/src/shell_dispatcher.rs @@ -97,13 +97,6 @@ impl ShellKind { } } - /// Whether this shell needs an extra `-Command` flag after the profile - /// flag (PowerShell-specific). Only exercised by shell-flag unit tests. - #[cfg(test)] - pub fn needs_command_flag(&self) -> bool { - matches!(self, ShellKind::Pwsh | ShellKind::WindowsPowerShell) - } - /// Returns true when this is a PowerShell-family shell. pub fn is_powershell(&self) -> bool { match self { @@ -411,16 +404,6 @@ impl ShellDispatcher { Some((program, args)) } - /// Build a `Command` from separate program + args (bypasses the shell). - /// Used when the caller already has a resolved executable and argument - /// vector — e.g. `ExecEnv` from the sandbox. - #[cfg(test)] - pub fn build_direct(&self, program: &str, args: &[String]) -> Command { - let mut cmd = Command::new(program); - cmd.args(args); - cmd - } - /// Execute a foreground command with raw-mode save/restore. /// /// A scope guard ensures raw mode is restored even if the command fails @@ -670,12 +653,6 @@ mod tests { assert_eq!(output.stdout, b"path-independent"); } - #[test] - fn detect_returns_some_shell() { - let dispatcher = global_dispatcher(); - let _kind = dispatcher.kind(); - } - #[test] fn powershell_build_command_includes_no_profile_and_command_flags() { let dispatcher = ShellDispatcher { @@ -864,28 +841,6 @@ mod tests { assert!(args.contains(&"echo hello")); } - #[cfg(test)] - #[test] - fn build_direct_preserves_args() { - let dispatcher = ShellDispatcher { - kind: ShellKind::Cmd, - }; - let args = vec!["-m".to_string(), "commit message".to_string()]; - let cmd = dispatcher.build_direct("git", &args); - let cmd_args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert_eq!(cmd_args, vec!["-m", "commit message"]); - } - - #[cfg(test)] - #[test] - fn powershell_flags_are_correct() { - assert!(ShellKind::Pwsh.needs_command_flag()); - assert!(ShellKind::WindowsPowerShell.needs_command_flag()); - assert!(!ShellKind::Cmd.needs_command_flag()); - assert!(!ShellKind::Sh.needs_command_flag()); - assert!(!ShellKind::Bash.needs_command_flag()); - } - #[cfg(test)] #[test] fn is_powershell_detects_both_variants() { @@ -928,17 +883,6 @@ mod tests { ); } - #[cfg(test)] - #[test] - fn build_direct_handles_empty_args() { - let dispatcher = ShellDispatcher { - kind: ShellKind::Sh, - }; - let cmd = dispatcher.build_direct("echo", &[]); - let args: Vec<&str> = cmd.get_args().map(|a| a.to_str().unwrap()).collect(); - assert!(args.is_empty()); - } - #[cfg(windows)] #[test] fn find_exe_finds_cmd_on_path() { diff --git a/crates/tui/src/skill_state.rs b/crates/tui/src/skill_state.rs index 26c3c687e2..fc82db1c5e 100644 --- a/crates/tui/src/skill_state.rs +++ b/crates/tui/src/skill_state.rs @@ -67,11 +67,6 @@ impl SkillStateStore { Ok(()) } - #[allow(dead_code)] - pub fn disabled(&self) -> Vec { - self.disabled.iter().cloned().collect() - } - fn set_enabled_with_persist( &mut self, skill_name: &str, @@ -261,7 +256,6 @@ mod tests { fn missing_file_defaults_to_everything_enabled() { let (_dir, store) = fresh(); assert!(store.is_enabled("anything")); - assert!(store.disabled().is_empty()); } #[test] @@ -281,14 +275,6 @@ mod tests { store.set_enabled("foo", false).unwrap(); store.set_enabled("foo", true).unwrap(); assert!(store.is_enabled("foo")); - assert!(store.disabled().is_empty()); - } - - #[test] - fn redundant_toggle_is_noop() { - let (_dir, mut store) = fresh(); - store.set_enabled("foo", true).unwrap(); - assert!(store.disabled().is_empty()); } #[test] @@ -305,18 +291,6 @@ mod tests { ); } - #[test] - fn disabled_list_is_deterministic_order() { - let (_dir, mut store) = fresh(); - store.set_enabled("zeta", false).unwrap(); - store.set_enabled("alpha", false).unwrap(); - store.set_enabled("mu", false).unwrap(); - assert_eq!( - store.disabled(), - vec!["alpha".to_string(), "mu".to_string(), "zeta".to_string()] - ); - } - #[test] fn stale_stores_merge_independent_toggles() { let dir = TempDir::new().unwrap(); diff --git a/crates/tui/src/skills/audit.rs b/crates/tui/src/skills/audit.rs index 056075b44e..c97980f2fc 100644 --- a/crates/tui/src/skills/audit.rs +++ b/crates/tui/src/skills/audit.rs @@ -641,12 +641,6 @@ struct PackageAnalysis { warnings: Vec, } -/// Compute the bounded package content digest used by audit and mutation. -#[allow(dead_code)] // public wrapper; mutation uses package_digest directly -pub fn compute_package_digest(package_dir: &Path) -> Result { - package_digest::compute_package_digest(package_dir).map_err(digest_error_to_unknown) -} - fn digest_error_to_unknown(err: PackageDigestError) -> DigestUnknownReason { match err { PackageDigestError::Unreadable => DigestUnknownReason::Unreadable, diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 0869a33905..494d548dcb 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -517,27 +517,6 @@ pub fn uninstall(name: &str, skills_dir: &Path) -> Result<()> { Ok(()) } -/// Mark a community-installed skill as trusted, binding the marker to the -/// current package content digest (schema v2). -/// -/// Refuses to mark system skills (no `.installed-from`) so the bundled -/// `skill-creator` doesn't accidentally inherit elevated tool privileges. -#[cfg(test)] -pub fn trust(name: &str, skills_dir: &Path) -> Result<()> { - let target = skill_target_path(name, skills_dir)?; - if !target.exists() { - bail!("skill '{name}' is not installed at {}", target.display()); - } - ensure_target_within_skills_dir(&target, skills_dir)?; - if !target.join(INSTALLED_FROM_MARKER).exists() { - return Err(InstallError::NotInstalledHere(name.to_string()).into()); - } - let content_digest = super::package_digest::compute_package_digest(&target) - .with_context(|| format!("cannot compute content digest for {}", target.display()))?; - write_trust_v2(&target, &content_digest)?; - Ok(()) -} - /// Fetch the curated registry and return the parsed entries. /// /// Honours `network` (skipping the call entirely on Deny / Prompt). @@ -1859,7 +1838,6 @@ mod tests { "two words", ] { assert!(uninstall(bad, &skills_dir).is_err()); - assert!(trust(bad, &skills_dir).is_err()); } } diff --git a/crates/tui/src/skills/mod.rs b/crates/tui/src/skills/mod.rs index 3b9a6f00c1..c24bd941db 100644 --- a/crates/tui/src/skills/mod.rs +++ b/crates/tui/src/skills/mod.rs @@ -6,7 +6,7 @@ pub mod audit; mod catalog_matrix; pub mod install; pub mod mutation; -mod package_digest; +pub(crate) mod package_digest; pub mod recommend; pub mod roots; mod system; diff --git a/crates/tui/src/snapshot/paths.rs b/crates/tui/src/snapshot/paths.rs index 10ee5bddd1..a8d6fab9de 100644 --- a/crates/tui/src/snapshot/paths.rs +++ b/crates/tui/src/snapshot/paths.rs @@ -143,15 +143,6 @@ mod tests { assert_ne!(main_components.last(), wt_components.last()); } - #[test] - fn ensure_snapshot_dir_creates_path() { - let tmp = tempdir().expect("tempdir"); - // Use scoped HOME so we don't pollute the real one. - let dir = snapshot_dir_with_home(tmp.path(), Some(tmp.path().to_path_buf())); - std::fs::create_dir_all(&dir).unwrap(); - assert!(dir.exists()); - } - #[test] fn snapshot_git_dir_appends_dot_git() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/snapshot/repo.rs b/crates/tui/src/snapshot/repo.rs index 628ec66a47..fa4b7cce90 100644 --- a/crates/tui/src/snapshot/repo.rs +++ b/crates/tui/src/snapshot/repo.rs @@ -2873,30 +2873,6 @@ mod tests { ); } - #[test] - fn unsafe_workspace_rejects_home_directory_workspace() { - let tmp = tempdir().unwrap(); - let home = tmp.path(); - - assert_eq!( - unsafe_workspace_snapshot_reason(home, Some(home)), - Some("home directory") - ); - } - - #[test] - fn unsafe_workspace_rejects_home_collection_directories() { - let tmp = tempdir().unwrap(); - let home = tmp.path(); - let desktop = tmp.path().join("Desktop"); - std::fs::create_dir_all(&desktop).unwrap(); - - assert_eq!( - unsafe_workspace_snapshot_reason(&desktop, Some(home)), - Some("home collection directory") - ); - } - #[test] fn unsafe_workspace_allows_project_directories_under_home() { let tmp = tempdir().unwrap(); diff --git a/crates/tui/src/tool_output_receipts.rs b/crates/tui/src/tool_output_receipts.rs index bbffba56ff..81fa2885e5 100644 --- a/crates/tui/src/tool_output_receipts.rs +++ b/crates/tui/src/tool_output_receipts.rs @@ -16,7 +16,6 @@ pub const RAW_TOOL_OUTPUT_RECEIPT_THRESHOLD_CHARS: usize = 12_000; pub struct ToolOutputReceiptStats { pub compacted_count: usize, pub artifact_receipts: usize, - pub sha_receipts: usize, pub unavailable_receipts: usize, pub original_chars: usize, } @@ -401,7 +400,6 @@ mod tests { }; assert_eq!(stats.compacted_count, 1); - assert_eq!(stats.sha_receipts, 0); assert_eq!(stats.unavailable_receipts, 1); assert!(!content.contains("NO_ARTIFACT_RAW")); assert!(!content.contains("detail_handle")); diff --git a/crates/tui/src/tools/automation.rs b/crates/tui/src/tools/automation.rs index a242a5ee3b..74a50a5b00 100644 --- a/crates/tui/src/tools/automation.rs +++ b/crates/tui/src/tools/automation.rs @@ -27,11 +27,9 @@ const ALL_ACTIONS: &[&str] = &[ /// Unified automation tool. /// /// One struct, one input schema per surface: the canonical `automation` -/// tool (all actions, or the read-only subset via [`AutomationTool::read_only`]) -/// plus hidden legacy aliases carrying a `forced_action`. +/// tool (all actions, or the read-only subset via [`AutomationTool::read_only`]). pub struct AutomationTool { name: &'static str, - forced_action: Option<&'static str>, read_only: bool, } @@ -39,7 +37,6 @@ impl AutomationTool { pub const fn new(name: &'static str) -> Self { Self { name, - forced_action: None, read_only: false, } } @@ -48,20 +45,10 @@ impl AutomationTool { pub const fn read_only(name: &'static str) -> Self { Self { name, - forced_action: None, read_only: true, } } - #[cfg(test)] - pub const fn alias(name: &'static str, action: &'static str) -> Self { - Self { - name, - forced_action: Some(action), - read_only: false, - } - } - fn allowed_actions(&self) -> &'static [&'static str] { if self.read_only { READ_ACTIONS @@ -71,15 +58,12 @@ impl AutomationTool { } fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> { - let action = match self.forced_action { - Some(action) => action, - None => input.get("action").and_then(Value::as_str).ok_or_else(|| { - ToolError::invalid_input(format!( - "automation: missing `action` (one of: {})", - self.allowed_actions().join(", ") - )) - })?, - }; + let action = input.get("action").and_then(Value::as_str).ok_or_else(|| { + ToolError::invalid_input(format!( + "automation: missing `action` (one of: {})", + self.allowed_actions().join(", ") + )) + })?; if self.allowed_actions().contains(&action) { Ok(action) } else { @@ -102,40 +86,18 @@ impl ToolSpec for AutomationTool { } fn model_visible(&self) -> bool { - self.forced_action.is_none() + true } fn description(&self) -> &'static str { - match self.forced_action { - Some("create") => { - "Create a durable scheduled automation. Creation requires approval. Supported schedules: FREQ=ONCE;AT=YYYY-MM-DDTHH:MM[:SS] (local time) or RFC3339, FREQ=HOURLY..., FREQ=WEEKLY..., and FREQ=CRON;EXPR=. delivery_mode=watcher is for condition checks: return EXACTLY NOTHING_TO_REPORT when there is no change." - } - Some("list") => { - "List durable automations with status, next run, and last run timestamps." - } - Some("read") => "Read one durable automation plus recent run records.", - Some("update") => { - "Update a durable automation. Requires approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms." - } - Some("pause") => "Pause a durable automation. Requires approval.", - Some("resume") => "Resume a paused durable automation. Requires approval.", - Some("delete") => "Delete a durable automation and its run history. Requires approval.", - Some("run") => { - "Run an automation now. The run enqueues a normal durable task and returns linked task/thread/turn ids as they become available." - } - _ if self.read_only => { - "Inspect durable scheduled automations. Actions: \"list\" (status, next run, last run) and \"read\" (one automation plus recent run records)." - } - _ => { - "Manage durable scheduled automations. Actions: \"create\" (approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms; watcher mode uses EXACT NOTHING_TO_REPORT for no-change checks), \"list\", \"read\", \"update\" (approval), \"pause\" (approval), \"resume\" (approval), \"delete\" (approval), \"run\" (approval)." - } + if self.read_only { + "Inspect durable scheduled automations. Actions: \"list\" (status, next run, last run) and \"read\" (one automation plus recent run records)." + } else { + "Manage durable scheduled automations. Actions: \"create\" (approval; schedules support ONCE, HOURLY, WEEKLY, and 5-field CRON forms; watcher mode uses EXACT NOTHING_TO_REPORT for no-change checks), \"list\", \"read\", \"update\" (approval), \"pause\" (approval), \"resume\" (approval), \"delete\" (approval), \"run\" (approval)." } } fn input_schema(&self) -> Value { - if let Some(action) = self.forced_action { - return legacy_action_schema(action); - } let actions: Vec<&str> = self.allowed_actions().to_vec(); let mut properties = serde_json::Map::new(); properties.insert( @@ -228,32 +190,27 @@ impl ToolSpec for AutomationTool { } fn capabilities(&self) -> Vec { - match self.forced_action { - Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly], + if self.read_only { + vec![ToolCapability::ReadOnly] + } else { // `run` executes a stored automation now; the other mutating // actions schedule one to execute later, with its own prompt, cwd, // and task mode. Declaring only `RequiresApproval` described the // *approval* consequence and hid the *execution* one, which left // every capability-derived policy — including the child execution // envelope — unable to see that this family spawns agent runs. - Some(_) => vec![ + vec![ ToolCapability::ExecutesCode, ToolCapability::RequiresApproval, - ], - None if self.read_only => vec![ToolCapability::ReadOnly], - None => vec![ - ToolCapability::ExecutesCode, - ToolCapability::RequiresApproval, - ], + ] } } fn approval_requirement(&self) -> ApprovalRequirement { - match self.forced_action { - Some(action) if Self::action_is_read(action) => ApprovalRequirement::Auto, - Some(_) => ApprovalRequirement::Required, - None if self.read_only => ApprovalRequirement::Auto, - None => ApprovalRequirement::Required, + if self.read_only { + ApprovalRequirement::Auto + } else { + ApprovalRequirement::Required } } @@ -465,85 +422,6 @@ impl AutomationTool { } } -/// The exact schema the legacy per-action tool exposed, kept so hidden alias -/// registrations report an identical contract to the pre-unification tools. -fn legacy_action_schema(action: &str) -> Value { - match action { - "create" => json!({ - "type": "object", - "properties": { - "name": { "type": "string" }, - "prompt": { "type": "string" }, - "rrule": { - "type": "string", - "description": "Supported: FREQ=ONCE;AT=2026-08-03T14:30 (local time or RFC3339), FREQ=HOURLY;INTERVAL=N[;BYDAY=MO,TU][;BYHOUR=9][;BYMINUTE=30], FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=30, or FREQ=CRON;EXPR=*/17 * * * *. Cron uses standard 5-field local time. For HOURLY, BYHOUR/BYMINUTE choose the initial local wall-clock anchor and INTERVAL advances from that anchor; BYHOUR is not a daily-only filter. Anchored wall times skip nonexistent clock times and use the first occurrence of ambiguous clock times." - }, - "cwds": { "type": "array", "items": { "type": "string" } }, - "model": { "type": "string", "description": "Model id for scheduled runs." }, - "model_provider": { "type": "string", "description": "Provider kind for the pinned model." }, - "model_provider_id": { "type": "string", "description": "Exact configured provider id." }, - "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." }, - "allow_shell": { "type": "boolean", "default": false }, - "trust_mode": { "type": "boolean", "default": false }, - "auto_approve": { "type": "boolean", "default": false }, - "delivery_mode": { - "type": "string", - "enum": ["task", "watcher"], - "default": "task", - "description": "Delivery mode. watcher prompts must return EXACTLY NOTHING_TO_REPORT when there is no change." - }, - "paused": { "type": "boolean", "default": false } - }, - "required": ["name", "prompt", "rrule"], - "additionalProperties": false - }), - "list" => json!({ - "type": "object", - "properties": { - "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 50 } - }, - "additionalProperties": false - }), - "update" => json!({ - "type": "object", - "properties": { - "automation_id": { "type": "string" }, - "name": { "type": "string" }, - "prompt": { "type": "string" }, - "rrule": { "type": "string" }, - "cwds": { "type": "array", "items": { "type": "string" } }, - "model": { "type": "string", "description": "Model id for scheduled runs." }, - "model_provider": { "type": "string", "description": "Provider kind for the pinned model." }, - "model_provider_id": { "type": "string", "description": "Exact configured provider id." }, - "mode": { "type": "string", "description": "Task mode for scheduled runs. Defaults to agent when omitted." }, - "allow_shell": { "type": "boolean" }, - "trust_mode": { "type": "boolean" }, - "auto_approve": { "type": "boolean" }, - "delivery_mode": { "type": "string", "enum": ["task", "watcher"] }, - "status": { "type": "string", "enum": ["active", "paused"] } - }, - "required": ["automation_id"], - "additionalProperties": false - }), - // read / pause / resume / delete / run share the id-only schema. - _ => automation_id_schema(true), - } -} - -fn automation_id_schema(require_id: bool) -> Value { - let mut schema = json!({ - "type": "object", - "properties": { - "automation_id": { "type": "string" } - }, - "additionalProperties": false - }); - if require_id { - schema["required"] = json!(["automation_id"]); - } - schema -} - fn string_array(input: &Value, field: &str) -> Result, ToolError> { Ok(input .get(field) @@ -590,13 +468,6 @@ mod tests { use super::*; use crate::tools::spec::ToolSpec; - #[test] - fn create_schema_exposes_rrule() { - let schema = AutomationTool::alias("automation_create", "create").input_schema(); - assert!(schema["properties"]["rrule"].is_object()); - assert_eq!(schema["required"][0], "name"); - } - #[test] fn update_status_rejects_unknown_values_instead_of_coercing_to_active() { assert!(matches!( @@ -616,29 +487,6 @@ mod tests { } } - #[test] - fn create_schema_auto_approve_defaults_to_false() { - let schema = AutomationTool::alias("automation_create", "create").input_schema(); - let auto_approve = &schema["properties"]["auto_approve"]; - assert_eq!(auto_approve["type"], "boolean"); - assert_eq!(auto_approve["default"], false); - } - - #[test] - fn create_schema_exposes_delivery_mode_and_new_schedule_forms() { - let schema = AutomationTool::alias("automation_create", "create").input_schema(); - assert!(schema["properties"]["model"].is_object()); - assert_eq!( - schema["properties"]["delivery_mode"]["enum"], - json!(["task", "watcher"]) - ); - let description = schema["properties"]["rrule"]["description"] - .as_str() - .expect("rrule description"); - assert!(description.contains("FREQ=ONCE")); - assert!(description.contains("FREQ=CRON")); - } - #[test] fn canonical_schema_lists_all_actions_and_union_fields() { let schema = AutomationTool::new("automation").input_schema(); @@ -684,17 +532,7 @@ mod tests { } #[test] - fn aliases_hide_from_model_and_force_action() { - let create = AutomationTool::alias("automation_create", "create"); - assert!(!create.model_visible()); - assert_eq!(create.name(), "automation_create"); - assert_eq!(create.approval_requirement(), ApprovalRequirement::Required); - - let list = AutomationTool::alias("automation_list", "list"); - assert!(!list.model_visible()); - assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto); - assert!(list.is_read_only_for(&json!({}))); - + fn canonical_visibility_and_per_action_approval() { let canonical = AutomationTool::new("automation"); assert!(canonical.model_visible()); // Approval routing stays per action: read actions auto, writes required. diff --git a/crates/tui/src/tools/js_execution.rs b/crates/tui/src/tools/js_execution.rs index c75c084d0b..8fbe2710b2 100644 --- a/crates/tui/src/tools/js_execution.rs +++ b/crates/tui/src/tools/js_execution.rs @@ -382,8 +382,9 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn timeout_kills_the_node_child_instead_of_orphaning_it() { + use std::io::Write; if !node_present() { - eprintln!("skipping: node not present"); + let _ = writeln!(std::io::stderr(), "skipping: node not present"); return; } let workspace = tempdir().expect("workspace tempdir"); @@ -424,8 +425,9 @@ mod tests { #[cfg(unix)] #[tokio::test] async fn timeout_returns_promptly_even_when_a_grandchild_holds_the_pipes() { + use std::io::Write; if !node_present() { - eprintln!("skipping: node not present"); + let _ = writeln!(std::io::stderr(), "skipping: node not present"); return; } let workspace = tempdir().expect("workspace tempdir"); diff --git a/crates/tui/src/tools/read_media.rs b/crates/tui/src/tools/read_media.rs index 1b6280ffed..32ff68d9a2 100644 --- a/crates/tui/src/tools/read_media.rs +++ b/crates/tui/src/tools/read_media.rs @@ -1427,27 +1427,6 @@ mod tests { ); } - #[tokio::test] - async fn read_media_respects_cancel_token() { - let dir = tempdir().unwrap(); - let img_path = dir.path().join("cancel.png"); - let png_data = create_test_png(10, 10, [1, 2, 3, 255]); - std::fs::write(&img_path, &png_data).unwrap(); - - let cancel_token = tokio_util::sync::CancellationToken::new(); - cancel_token.cancel(); - let mut ctx = ToolContext::new(dir.path()).with_cancel_token(cancel_token); - ctx.route_capabilities.image_input = CapabilityState::Supported; - - let tool = ReadMediaTool; - let err = tool - .execute_rich(json!({ "path": "cancel.png" }), &ctx) - .await - .unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("aborted") || msg.contains("cancel"), "{msg}"); - } - #[tokio::test] async fn read_media_cancellation_before_dispatch_fails_without_read() { let dir = tempdir().unwrap(); diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index 77e3e30343..ae76e39488 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -932,24 +932,6 @@ mod tests { )) } - #[test] - fn schema_uses_new_tool_names() { - assert_eq!( - RlmTool::alias("rlm_session_objects", "session_objects", None).name(), - "rlm_session_objects" - ); - assert_eq!(RlmTool::alias("rlm_open", "open", None).name(), "rlm_open"); - assert_eq!(RlmTool::alias("rlm_eval", "eval", None).name(), "rlm_eval"); - assert_eq!( - RlmTool::alias("rlm_configure", "configure", None).name(), - "rlm_configure" - ); - assert_eq!( - RlmTool::alias("rlm_close", "close", None).name(), - "rlm_close" - ); - } - #[test] fn rlm_tool_is_compatibility_only_not_model_visible() { let canonical = RlmTool::new("rlm", None); diff --git a/crates/tui/src/tools/runtime_mcp.rs b/crates/tui/src/tools/runtime_mcp.rs index 926891caee..b38f430c9e 100644 --- a/crates/tui/src/tools/runtime_mcp.rs +++ b/crates/tui/src/tools/runtime_mcp.rs @@ -673,13 +673,6 @@ mod tests { assert_eq!(parsed.name, "my-server"); } - #[test] - fn infer_name_only_command_no_args_path() { - // Absolute path, no args — uses file_stem of command - let parsed = parse_mcp_command("/usr/local/bin/my-server").unwrap(); - assert_eq!(parsed.name, "my-server"); - } - // === sanitize_name tests === #[test] @@ -755,20 +748,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] diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 5a4b855829..18f03dae1d 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -10828,7 +10828,7 @@ async fn publish_live_subagent_transcript( /// /// Returns the (possibly bounded) content and the spillover path when one was /// written. Spillover write failures degrade to passing the original content -/// through, mirroring `apply_spillover`. +/// through, mirroring `apply_spillover_with_artifact`'s passthrough posture. fn bound_subagent_tool_result( agent_id: &str, tool_id: &str, @@ -16218,7 +16218,8 @@ chars shown. This is the child's self-report; {retrieval}]\n\n{tail}", /// Persist a final report that exceeds the summary budget so the truncated /// summary can name a retrievable artifact instead of dropping the elided /// middle. Returns the `retrieve_tool_result` ref on success; write failures -/// degrade to no ref, mirroring `apply_spillover`'s passthrough posture. The +/// degrade to no ref, mirroring `apply_spillover_with_artifact`'s passthrough +/// posture. The /// artifact lands under the shared session root (`state_namespace`), which /// every agent in the spawn tree clones, so the parent and any sibling can /// resolve it. Same-id writes carry identical bytes, so a synthesized diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index a2ecc97172..2d86f69948 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -69,7 +69,6 @@ fn task_shell_wait_input(mut input: Value) -> Value { /// which differs from every other action in this family. pub struct TasksTool { name: &'static str, - forced_action: Option<&'static str>, read_only: bool, } @@ -94,7 +93,6 @@ impl TasksTool { pub const fn new(name: &'static str) -> Self { Self { name, - forced_action: None, read_only: false, } } @@ -103,20 +101,10 @@ impl TasksTool { pub const fn read_only(name: &'static str) -> Self { Self { name, - forced_action: None, read_only: true, } } - #[cfg(test)] - pub const fn alias(name: &'static str, action: &'static str) -> Self { - Self { - name, - forced_action: Some(action), - read_only: false, - } - } - fn allowed_actions(&self) -> &'static [&'static str] { if self.read_only { READ_ACTIONS @@ -126,15 +114,12 @@ impl TasksTool { } fn resolve_action<'a>(&'a self, input: &'a Value) -> Result<&'a str, ToolError> { - let action = match self.forced_action { - Some(action) => action, - None => input.get("action").and_then(Value::as_str).ok_or_else(|| { - ToolError::invalid_input(format!( - "tasks: missing `action` (one of: {})", - self.allowed_actions().join(", ") - )) - })?, - }; + let action = input.get("action").and_then(Value::as_str).ok_or_else(|| { + ToolError::invalid_input(format!( + "tasks: missing `action` (one of: {})", + self.allowed_actions().join(", ") + )) + })?; if self.allowed_actions().contains(&action) { Ok(action) } else { @@ -149,12 +134,6 @@ impl TasksTool { READ_ACTIONS.contains(&action) } - /// Whether this action executes code (drives static capabilities and the - /// Plan-mode "no ExecutesCode tools" invariant). - fn action_executes_code(action: &str) -> bool { - action == "gate_run" - } - fn action_requires_approval(action: &str) -> bool { !Self::action_is_read(action) } @@ -167,49 +146,18 @@ impl ToolSpec for TasksTool { } fn model_visible(&self) -> bool { - self.forced_action.is_none() + true } fn description(&self) -> &'static str { - match self.forced_action { - Some("create") => { - "Create/enqueue a durable background task through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents." - } - Some("list") => { - "List recent durable tasks with status, linked thread/turn ids, and concise summaries." - } - Some("read") => { - "Read durable task detail including timeline, checklist, gate evidence, artifacts, and PR attempts." - } - Some("cancel") => { - "Cancel a queued or running durable task through TaskManager. Requires approval because it changes work state." - } - Some("gate_run") => { - "Run an approved verification gate command and return structured evidence. When inside a durable task, the gate result and log artifact are attached to that task. Dangerous commands are BLOCKED unless auto-approve is enabled; default timeout 120s." - } - Some("pr_attempt_record") => { - "Capture current git diff as a durable PR work attempt with patch artifact, changed files, and verification notes. Requires approval because it records work state." - } - Some("pr_attempt_list") => "List PR attempts recorded on a durable task.", - Some("pr_attempt_read") => { - "Read one recorded PR attempt and its patch artifact reference." - } - Some("pr_attempt_preflight") => { - "Run `git apply --check` for a recorded attempt patch. This is a no-mutation preflight and itself requires approval; the actual apply stays a separate explicit step." - } - _ if self.read_only => { - "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"." - } - _ => { - "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Use task_shell_start for long-running shell work." - } + if self.read_only { + "Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"." + } else { + "Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Use task_shell_start for long-running shell work." } } fn input_schema(&self) -> Value { - if let Some(action) = self.forced_action { - return legacy_action_schema(action); - } let actions: Vec<&str> = self.allowed_actions().to_vec(); let mut properties = serde_json::Map::new(); properties.insert( @@ -318,29 +266,21 @@ impl ToolSpec for TasksTool { } fn capabilities(&self) -> Vec { - match self.forced_action { - Some(action) if Self::action_executes_code(action) => { - vec![ - ToolCapability::ExecutesCode, - ToolCapability::RequiresApproval, - ] - } - Some(action) if Self::action_is_read(action) => vec![ToolCapability::ReadOnly], - Some(_) => vec![ToolCapability::RequiresApproval], - None if self.read_only => vec![ToolCapability::ReadOnly], - None => vec![ + if self.read_only { + vec![ToolCapability::ReadOnly] + } else { + vec![ ToolCapability::ExecutesCode, ToolCapability::RequiresApproval, - ], + ] } } fn approval_requirement(&self) -> ApprovalRequirement { - match self.forced_action { - Some(action) if Self::action_requires_approval(action) => ApprovalRequirement::Required, - Some(_) => ApprovalRequirement::Auto, - None if self.read_only => ApprovalRequirement::Auto, - None => ApprovalRequirement::Required, + if self.read_only { + ApprovalRequirement::Auto + } else { + ApprovalRequirement::Required } } @@ -377,83 +317,6 @@ impl ToolSpec for TasksTool { } } -/// The exact schema the legacy per-action tool exposed, kept so hidden alias -/// registrations report an identical contract to the pre-unification tools. -fn legacy_action_schema(action: &str) -> Value { - match action { - "create" => json!({ - "type": "object", - "properties": { - "prompt": { "type": "string", "description": "Work prompt for the durable task." }, - "model": { "type": "string" }, - "model_provider": { "type": "string", "description": "Provider kind for the pinned model." }, - "model_provider_id": { "type": "string", "description": "Exact configured provider id." }, - "workspace": { "type": "string", "description": "Workspace path; defaults to current workspace." }, - "mode": { "type": "string", "enum": ["agent", "plan", "operate"] }, - "allow_shell": { "type": "boolean" }, - "trust_mode": { "type": "boolean" }, - "auto_approve": { "type": "boolean" } - }, - "required": ["prompt"], - "additionalProperties": false - }), - "list" => json!({ - "type": "object", - "properties": { - "limit": { "type": "integer", "minimum": 1, "maximum": 100, "default": 20 } - }, - "additionalProperties": false - }), - "read" | "cancel" => json!({ - "type": "object", - "properties": { - "task_id": { "type": "string", "description": "Full task id or unambiguous prefix." } - }, - "required": ["task_id"], - "additionalProperties": false - }), - "gate_run" => json!({ - "type": "object", - "properties": { - "gate": { - "type": "string", - "enum": ["fmt", "check", "clippy", "test", "custom"], - "description": "Gate category." - }, - "command": { "type": "string", "description": "Command to run." }, - "cwd": { "type": "string", "description": "Optional working directory within the workspace." }, - "timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000 } - }, - "required": ["gate", "command"], - "additionalProperties": false - }), - "pr_attempt_record" => json!({ - "type": "object", - "properties": { - "task_id": { "type": "string", "description": "Task to attach to; defaults to active task." }, - "attempt_group_id": { "type": "string" }, - "attempt_index": { "type": "integer", "minimum": 1 }, - "attempt_count": { "type": "integer", "minimum": 1 }, - "summary": { "type": "string" }, - "verification": { "type": "array", "items": { "type": "string" } } - }, - "required": ["summary"], - "additionalProperties": false - }), - "pr_attempt_list" => task_id_schema(), - // pr_attempt_read / pr_attempt_preflight share the attempt-id schema. - _ => json!({ - "type": "object", - "properties": { - "task_id": { "type": "string", "description": "Task id; defaults to active task." }, - "attempt_id": { "type": "string" } - }, - "required": ["attempt_id"], - "additionalProperties": false - }), - } -} - impl TasksTool { async fn execute_create( &self, @@ -1229,16 +1092,6 @@ fn task_id_from_input_or_context( }) } -fn task_id_schema() -> Value { - json!({ - "type": "object", - "properties": { - "task_id": { "type": "string", "description": "Task id; defaults to active task." } - }, - "additionalProperties": false - }) -} - async fn git_output(workspace: &Path, args: &[&str]) -> Result { let args_owned: Vec = args.iter().map(|s| (*s).to_owned()).collect(); let cwd = workspace.to_path_buf(); @@ -1409,11 +1262,12 @@ mod tests { // fabricated `spawn_error` (the classifier reads that field as an // environment failure): record the 127 instead. let tmp = tempfile::tempdir().expect("tempdir"); - let gate = TasksTool::alias("task_gate_run", "gate_run"); + let gate = TasksTool::new("tasks"); let context = ToolContext::new(tmp.path().to_path_buf()); let result = gate .execute( json!({ + "action": "gate_run", "gate": "test", "command": "exec definitely-not-a-real-shell-binary", "timeout_ms": 5000, @@ -1437,11 +1291,12 @@ mod tests { // record carries `timed_out: true` as the timeout signal instead, // and the hook surface must read the absence as "no exit code". let tmp = tempfile::tempdir().expect("tempdir"); - let gate = TasksTool::alias("task_gate_run", "gate_run"); + let gate = TasksTool::new("tasks"); let context = ToolContext::new(tmp.path().to_path_buf()); let result = gate .execute( json!({ + "action": "gate_run", "gate": "test", "command": "sleep 30", "timeout_ms": 500, @@ -1455,20 +1310,8 @@ mod tests { assert_eq!(meta["timed_out"].as_bool(), Some(true), "{meta}"); } - #[test] - fn durable_task_schema_requires_prompt() { - let schema = TasksTool::alias("task_create", "create").input_schema(); - assert_eq!(schema["required"][0], "prompt"); - assert!(schema["properties"]["prompt"].is_object()); - } - #[test] fn create_mode_enum_advertises_operate_not_yolo() { - let create = TasksTool::alias("task_create", "create").input_schema(); - assert_eq!( - create["properties"]["mode"]["enum"], - json!(["agent", "plan", "operate"]) - ); let canonical = TasksTool::new("tasks").input_schema(); assert_eq!( canonical["properties"]["mode"]["enum"], @@ -1542,20 +1385,7 @@ mod tests { } #[test] - fn aliases_hide_from_model_and_force_action() { - let create = TasksTool::alias("task_create", "create"); - assert!(!create.model_visible()); - assert_eq!(create.name(), "task_create"); - assert_eq!(create.approval_requirement(), ApprovalRequirement::Required); - - let gate = TasksTool::alias("task_gate_run", "gate_run"); - assert_eq!(gate.approval_requirement(), ApprovalRequirement::Required); - assert!(gate.capabilities().contains(&ToolCapability::ExecutesCode)); - - let list = TasksTool::alias("task_list", "list"); - assert_eq!(list.approval_requirement(), ApprovalRequirement::Auto); - assert!(list.is_read_only_for(&json!({}))); - + fn canonical_visibility_and_per_action_approval() { let canonical = TasksTool::new("tasks"); assert!(canonical.model_visible()); assert_eq!( diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index b3f0a7423e..3ad0be59be 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -20,7 +20,7 @@ //! //! ## Live callers //! -//! * [`apply_spillover`] — invoked from the engine's tool-execution +//! * [`apply_spillover_with_artifact`] — invoked from the engine's tool-execution //! path (`turn_loop.rs`) so any successful tool result over //! [`SPILLOVER_THRESHOLD_BYTES`] spills to disk and the model //! receives a bounded plain preview: a [`SPILLOVER_HEAD_BYTES`] head, @@ -319,7 +319,7 @@ pub fn maybe_spillover( Ok(Some((content[..cut].to_string(), path))) } -/// Inline head retained when [`apply_spillover`] truncates a tool +/// Inline head retained when [`apply_spillover_with_artifact`] truncates a tool /// result. 32 KiB is large enough for the model to keep meaningful /// context (a long stack trace, a `git diff` head, a directory /// listing of typical depth) without consuming the lion's share of @@ -442,29 +442,6 @@ fn truncated_preview( ) } -/// Apply spillover to a tool result in place. If the result's -/// content exceeds [`SPILLOVER_THRESHOLD_BYTES`], writes the full -/// content to a sibling file under `~/.codewhale/tool_outputs/`, -/// replaces `result.content` with a [`SPILLOVER_HEAD_BYTES`] head -/// plus a footer naming the spillover path and how to read the -/// omitted range back, and stamps `metadata.spillover_path` so the -/// UI can render its expand annotation. -/// -/// Returns the spillover path on success, `None` if no spillover -/// happened (content small enough, error result, write failure). -/// Failures are logged but never bubble up — a tool that produced a -/// result shouldn't be marked failed because the spillover writer -/// couldn't reach disk; we degrade to no-op and the model gets the -/// original (large) content. -/// -/// Error results (`success == false`) are skipped: error messages -/// are typically short, and turning them into a truncated preview -/// would just hide the error from the model's reasoning. -#[allow(dead_code)] -pub fn apply_spillover(result: &mut ToolResult, tool_id: &str) -> Option { - apply_spillover_inner(result, tool_id, None) -} - /// Apply adaptive routing and publish session-scoped exact evidence. /// /// The default path writes one immutable payload under the origin session and @@ -1202,7 +1179,7 @@ mod tests { let tmp = tempdir().unwrap(); with_test_home(tmp.path(), || { let mut result = ToolResult::success("small payload"); - let path = apply_spillover(&mut result, "call-small"); + let path = apply_spillover_inner(&mut result, "call-small", None); assert!(path.is_none()); assert_eq!(result.content, "small payload"); assert!(result.metadata.is_none()); @@ -1218,7 +1195,7 @@ mod tests { // truncating an error would hide it from the model. let big_err = "boom\n".repeat(50_000); let mut result = ToolResult::error(big_err.clone()); - let path = apply_spillover(&mut result, "call-err"); + let path = apply_spillover_inner(&mut result, "call-err", None); assert!(path.is_none()); assert_eq!(result.content, big_err); }); @@ -1232,7 +1209,7 @@ mod tests { // 200 KiB body — well above the 100 KiB threshold. let big = "X".repeat(200 * 1024); let mut result = ToolResult::success(big.clone()); - let path = apply_spillover(&mut result, "call-big").expect("should spill"); + let path = apply_spillover_inner(&mut result, "call-big", None).expect("should spill"); // Inline content shrunk to head + honest preview footer. assert!(result.content.len() < big.len()); @@ -1543,7 +1520,7 @@ mod tests { let big = "Y".repeat(200 * 1024); let mut result = ToolResult::success(big) .with_metadata(serde_json::json!({"prior_key": "prior_value"})); - let path = apply_spillover(&mut result, "call-meta").expect("should spill"); + let path = apply_spillover_inner(&mut result, "call-meta", None).expect("should spill"); let metadata = result.metadata.expect("metadata present"); // Prior keys survive. @@ -1579,7 +1556,7 @@ mod tests { "array", "payload" ])); - let path = apply_spillover(&mut result, "call-arr").expect("should spill"); + let path = apply_spillover_inner(&mut result, "call-arr", None).expect("should spill"); let metadata = result.metadata.expect("metadata stamped"); // Prior payload re-homed under `_prior`. diff --git a/crates/tui/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs index 1fd2a15a4b..d69a17163f 100644 --- a/crates/tui/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -1567,7 +1567,7 @@ fn url_encode(input: &str) -> String { #[cfg(test)] mod tests { use super::*; - use crate::tools::web::scrape::{parse_bing_results, parse_duckduckgo_results}; + use crate::tools::web::scrape::parse_duckduckgo_results; use std::path::PathBuf; use tokio::sync::{Mutex, MutexGuard}; @@ -1868,25 +1868,6 @@ mod tests { assert!(ResponseLength::Medium.max_results() <= usize::from(MAX_SEARCH_RESULTS)); } - #[test] - fn parses_bing_results_and_decodes_redirect_url() { - let html = r#" -
    -
  1. -

    Example & Result

    -

    A useful snippet.

    -
  2. -
- "#; - - let results = parse_bing_results(html, 5); - - assert_eq!(results.len(), 1); - assert_eq!(results[0].title, "Example & Result"); - assert_eq!(results[0].url, "https://example.com/path?q=1"); - assert_eq!(results[0].snippet.as_deref(), Some("A useful snippet.")); - } - #[test] fn web_run_search_path_filters_known_spam_domain() { // The shared scraper used by web_run filters the known #964 spam family. diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index 286677a020..867cbc6950 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -2460,7 +2460,6 @@ mod tests { BackendId, BackendSearch, CapabilityState, DegradedReason, QueryCapabilities, QueryKnob, Recency, SearchQuery, SearchResult, }; - use crate::tools::web::scrape::{decode_html_entities, normalize_bing_url}; use serde_json::json; use std::time::{Duration, Instant}; @@ -2550,50 +2549,6 @@ mod tests { ); } - #[test] - fn bing_ckurl_with_html_entities_decodes_real_url() { - let href = "https://www.bing.com/ck/a?!&&p=abc&u=a1aHR0cHM6Ly9ydXN0LWxhbmcub3JnLw&ntb=1"; - assert_eq!(normalize_bing_url(href), "https://rust-lang.org/"); - } - - #[test] - fn decode_html_entities_handles_named_entities() { - assert_eq!(decode_html_entities("&"), "&"); - assert_eq!(decode_html_entities("<"), "<"); - assert_eq!(decode_html_entities(">"), ">"); - assert_eq!(decode_html_entities("""), "\""); - assert_eq!(decode_html_entities("'"), "'"); - assert_eq!(decode_html_entities(" "), " "); - assert_eq!(decode_html_entities("©"), "\u{00A9}"); - assert_eq!(decode_html_entities("—"), "\u{2014}"); - } - - #[test] - fn decode_html_entities_handles_decimal_numeric_references() { - assert_eq!(decode_html_entities("A"), "A"); - assert_eq!(decode_html_entities("<"), "<"); - assert_eq!(decode_html_entities("–"), "\u{2013}"); - } - - #[test] - fn decode_html_entities_handles_hex_numeric_references() { - assert_eq!(decode_html_entities("A"), "A"); - assert_eq!(decode_html_entities("<"), "<"); - assert_eq!(decode_html_entities("—"), "\u{2014}"); - } - - #[test] - fn decode_html_entities_passthrough_unknown() { - assert_eq!(decode_html_entities("&unknown;"), "&unknown;"); - } - - #[test] - fn decode_html_entities_mixed_content() { - let input = "Hello & welcome to "Rust's world" — enjoy!"; - let expected = "Hello & welcome to \"Rust's world\" \u{2014} enjoy!"; - assert_eq!(decode_html_entities(input), expected); - } - #[test] fn extract_search_query_accepts_legacy_query() { let query = diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9a091acfe2..2cf029b474 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -852,21 +852,6 @@ impl Default for ComposerState { } } -/// Compatibility name retained for the first Tideline header slice. New -/// surfaces register [`crate::tui::tideline::InteractionAction`] directly. -pub type HeaderActionTarget = crate::tui::tideline::InteractionAction; - -/// A header target painted in the latest frame. -/// -/// The visible chrome owns placement; input owns dispatch. Keeping the -/// rectangular target alongside its typed action gives mouse and keyboard -/// routes one shared destination without a second navigation system. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct HeaderHitbox { - pub area: Rect, - pub target: HeaderActionTarget, -} - /// Viewport/scroll state — fields related to transcript scrolling and caching. pub struct ViewportState { pub transcript_scroll: TranscriptScroll, @@ -3203,14 +3188,6 @@ impl App { self.report_mode_selection(next, outcome); } - /// Cycle through modes in reverse. - #[allow(dead_code)] - pub fn cycle_mode_reverse(&mut self) { - let next = self.mode.previous(); - let outcome = self.select_mode(next); - self.report_mode_selection(next, outcome); - } - /// Show the startup-default receipt for a selection that did not move live /// mode. `Changed` and `Refused` already have their own messaging (the mode /// chip, and `reject_setting_change_while_busy` respectively). @@ -3695,13 +3672,6 @@ impl App { } } - /// Add `delta` to the parent-turn session cost and bump the displayed - /// high-water mark so the footer total never reverses (#244). - #[allow(dead_code)] - pub fn accrue_session_cost(&mut self, delta: f64) { - self.accrue_session_cost_estimate(CostEstimate::usd_only(delta)); - } - /// Record what a turn's pricing attempt actually produced. /// /// Called with the same audit that feeds [`Self::accrue_session_cost_estimate`], @@ -3919,13 +3889,6 @@ impl App { self.session.pending_turn_cost_cny = 0.0; } - /// Add `delta` to the running sub-agent cost and bump the displayed - /// high-water mark so the footer total never reverses (#244). - #[allow(dead_code)] - pub fn accrue_subagent_cost(&mut self, delta: f64) { - self.accrue_subagent_cost_estimate(CostEstimate::usd_only(delta)); - } - /// Add a dual-currency sub-agent/background cost estimate. pub fn accrue_subagent_cost_estimate(&mut self, estimate: CostEstimate) { let total = CostEstimate { @@ -4001,14 +3964,6 @@ impl App { } } - /// Read the visible session+sub-agent cost. Guaranteed monotonic across - /// reconciliation events (cache adjustments, provisional → final swaps) - /// for the lifetime of one session (#244). - #[allow(dead_code)] - pub fn displayed_session_cost(&self) -> f64 { - self.displayed_session_cost_for_currency(CostCurrency::Usd) - } - /// Read the visible session+sub-agent cost in the chosen currency. pub fn displayed_session_cost_for_currency(&self, currency: CostCurrency) -> f64 { match self.cost_display_currency(currency) { @@ -5439,16 +5394,6 @@ impl App { true } - /// Park a legacy pending steer. New keyboard handling routes running-turn - /// drafts through Ctrl+Enter (same-turn steer) or Enter (next-turn - /// follow-up). - #[allow(dead_code)] - pub fn push_pending_steer(&mut self, message: QueuedMessage) { - self.pending_steers.push_back(message); - self.submit_pending_steers_after_interrupt = true; - self.needs_redraw = true; - } - /// Drain the pending-steer queue and clear the resend flag. Returns the /// messages in submit order (oldest first). pub fn drain_pending_steers(&mut self) -> Vec { diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 10afc3707e..31664ae338 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -3477,7 +3477,7 @@ fn app_mode_helpers_centralize_parse_labels_and_cycle_order() { #[test] fn test_cycle_scenario() { - // Scenario consolidation of: test_cycle_mode_transitions, test_cycle_mode_reverse_transitions + // Scenario consolidation of: test_cycle_mode_transitions // from test_cycle_mode_transitions { let mut app = App::new(test_options(false), &Config::default()); @@ -3486,22 +3486,6 @@ fn test_cycle_scenario() { // Mode should have changed assert_ne!(app.mode, initial_mode); } - // from test_cycle_mode_reverse_transitions - { - let mut app = App::new(test_options(false), &Config::default()); - - app.mode = AppMode::Plan; - app.cycle_mode_reverse(); - assert_eq!(app.mode, AppMode::Operate); - - app.mode = AppMode::Operate; - app.cycle_mode_reverse(); - assert_eq!(app.mode, AppMode::Agent); - - app.mode = AppMode::Agent; - app.cycle_mode_reverse(); - assert_eq!(app.mode, AppMode::Plan); - } } #[test] @@ -4434,14 +4418,6 @@ fn tool_run_expansion_toggle_handles_active_run() { assert!(!app.expanded_tool_runs.contains(&active_start)); } -#[test] -fn test_scroll_operations() { - let mut app = App::new(test_options(false), &Config::default()); - // Just verify scroll methods can be called without panic - app.scroll_up(5); - app.scroll_down(3); -} - #[test] fn resize_preserves_scrolled_transcript_position() { let mut app = App::new(test_options(false), &Config::default()); @@ -4996,7 +4972,9 @@ fn pasted_text_and_image_placeholders_survive_history_and_queue_paths() { Some("Use this skill") ); - app.push_pending_steer(QueuedMessage::new(submitted.clone(), None)); + app.pending_steers + .push_back(QueuedMessage::new(submitted.clone(), None)); + app.submit_pending_steers_after_interrupt = true; let steers = app.drain_pending_steers(); assert_eq!(steers[0].display, submitted); } @@ -5356,24 +5334,19 @@ fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() { assert!(app.sticky_status.is_none()); } -#[test] -fn push_pending_steer_arms_resend_flag() { - let mut app = App::new(test_options(false), &Config::default()); - assert!(!app.submit_pending_steers_after_interrupt); - app.push_pending_steer(QueuedMessage::new("steer me".to_string(), None)); - assert_eq!(app.pending_steers.len(), 1); - assert!(app.submit_pending_steers_after_interrupt); -} - #[test] fn drain_pending_scenario() { // Scenario consolidation of: drain_pending_steers_clears_flag_and_returns_in_order, drain_pending_steers_when_empty_is_safe // from drain_pending_steers_clears_flag_and_returns_in_order { let mut app = App::new(test_options(false), &Config::default()); - app.push_pending_steer(QueuedMessage::new("first".to_string(), None)); - app.push_pending_steer(QueuedMessage::new("second".to_string(), None)); - app.push_pending_steer(QueuedMessage::new("third".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("first".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("second".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("third".to_string(), None)); + app.submit_pending_steers_after_interrupt = true; let drained = app.drain_pending_steers(); assert_eq!(drained.len(), 3); @@ -5393,15 +5366,6 @@ fn drain_pending_scenario() { } } -#[test] -fn double_push_pending_steer_is_idempotent_on_flag() { - let mut app = App::new(test_options(false), &Config::default()); - app.push_pending_steer(QueuedMessage::new("a".to_string(), None)); - app.push_pending_steer(QueuedMessage::new("b".to_string(), None)); - assert!(app.submit_pending_steers_after_interrupt); - assert_eq!(app.pending_steers.len(), 2); -} - #[test] fn pop_last_scenario() { // Scenario consolidation of: pop_last_queued_into_draft_pops_back_and_arms_draft, pop_last_queued_into_draft_noop_when_composer_dirty, pop_last_queued_into_draft_noop_when_draft_already_armed, pop_last_queued_into_draft_noop_when_queue_empty @@ -7171,7 +7135,6 @@ fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() { assert_eq!(app.select_mode(AppMode::Operate), SettingSelection::Changed); assert!(SettingSelection::Changed.changed_live_state()); - assert!(SettingSelection::Changed.accepted()); assert_eq!( app.select_mode(AppMode::Operate), @@ -7181,14 +7144,8 @@ fn mode_selection_reports_refusal_change_and_persisted_same_distinctly() { !SettingSelection::PersistedSame.changed_live_state(), "a persisted-same selection must not resync the engine" ); - assert!( - SettingSelection::PersistedSame.accepted(), - "a persisted-same selection did write the startup default" - ); - app.is_loading = true; assert_eq!(app.select_mode(AppMode::Plan), SettingSelection::Refused); - assert!(!SettingSelection::Refused.accepted()); assert_eq!(app.mode, AppMode::Operate, "a refusal changes nothing"); } diff --git a/crates/tui/src/tui/app/types.rs b/crates/tui/src/tui/app/types.rs index e270cff28e..688e7bf92a 100644 --- a/crates/tui/src/tui/app/types.rs +++ b/crates/tui/src/tui/app/types.rs @@ -35,13 +35,6 @@ impl SettingSelection { pub fn changed_live_state(self) -> bool { matches!(self, Self::Changed) } - - /// Whether the selection was accepted at all (either case that persisted). - #[must_use] - #[cfg(test)] - pub fn accepted(self) -> bool { - !matches!(self, Self::Refused) - } } /// The user-facing operating mode. Defined in codewhale-config; re-exported diff --git a/crates/tui/src/tui/approval/elevation.rs b/crates/tui/src/tui/approval/elevation.rs index f090909575..239fe7d0d1 100644 --- a/crates/tui/src/tui/approval/elevation.rs +++ b/crates/tui/src/tui/approval/elevation.rs @@ -29,34 +29,6 @@ pub enum ElevationOption { } impl ElevationOption { - /// Get the display label for this option. - #[cfg(test)] - pub fn label(&self) -> &'static str { - match self { - ElevationOption::WithNetwork => "Allow outbound network", - ElevationOption::WithWriteAccess(_) => "Allow extra write access", - ElevationOption::FullAccess => "Full access (filesystem + network)", - ElevationOption::Abort => "Abort", - } - } - - /// Get a short description. - #[cfg(test)] - pub fn description(&self) -> &'static str { - match self { - ElevationOption::WithNetwork => { - "Retry this tool call with outbound network access for downloads and HTTP requests" - } - ElevationOption::WithWriteAccess(_) => { - "Retry this tool call with additional writable filesystem scope" - } - ElevationOption::FullAccess => { - "Retry without sandbox limits; grants unrestricted filesystem and network access" - } - ElevationOption::Abort => "Cancel this tool execution", - } - } - /// Convert to a sandbox policy. pub fn to_policy(&self, base_cwd: &Path) -> SandboxPolicy { match self { @@ -115,22 +87,6 @@ impl ElevationRequest { options, } } - - /// Create a generic elevation request. - #[allow(dead_code)] - pub fn generic(tool_id: &str, tool_name: &str, denial_reason: &str) -> Self { - Self { - tool_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - command: None, - denial_reason: denial_reason.to_string(), - options: vec![ - ElevationOption::WithNetwork, - ElevationOption::FullAccess, - ElevationOption::Abort, - ], - } - } } /// Elevation overlay state managed by the modal view stack. @@ -173,18 +129,6 @@ impl ElevationView { option, }) } - - /// Get the request for rendering. - #[allow(dead_code)] - pub fn request(&self) -> &ElevationRequest { - &self.request - } - - /// Get the currently selected index. - #[allow(dead_code)] - pub fn selected(&self) -> usize { - self.selected - } } impl ModalView for ElevationView { diff --git a/crates/tui/src/tui/approval/tests.rs b/crates/tui/src/tui/approval/tests.rs index 52d63e1d56..0c2c8bd5f1 100644 --- a/crates/tui/src/tui/approval/tests.rs +++ b/crates/tui/src/tui/approval/tests.rs @@ -2239,39 +2239,6 @@ fn test_elevation_render_zh_hant_has_translated_copy() { // ElevationOption Tests // ======================================================================== -#[test] -fn test_elevation_option_labels() { - assert_eq!( - ElevationOption::WithNetwork.label(), - "Allow outbound network" - ); - assert_eq!( - ElevationOption::FullAccess.label(), - "Full access (filesystem + network)" - ); - assert!( - ElevationOption::WithWriteAccess(vec![]) - .label() - .contains("write") - ); - assert_eq!(ElevationOption::Abort.label(), "Abort"); -} - -#[test] -fn test_elevation_option_descriptions() { - assert!( - ElevationOption::WithNetwork - .description() - .contains("network") - ); - assert!( - ElevationOption::FullAccess - .description() - .contains("filesystem and network access") - ); - assert!(ElevationOption::Abort.description().contains("Cancel")); -} - #[test] fn test_elevation_option_to_policy() { let cwd = PathBuf::from("/tmp/test"); @@ -2333,33 +2300,6 @@ fn test_elevation_request_for_shell_with_write_block() { ); } -#[test] -fn test_elevation_request_generic() { - let request = ElevationRequest::generic("test-id", "some_tool", "permission denied"); - - assert_eq!(request.tool_id, "test-id"); - assert_eq!(request.tool_name, "some_tool"); - assert!(request.command.is_none()); - assert!( - request - .options - .iter() - .any(|o| matches!(o, ElevationOption::WithNetwork)) - ); - assert!( - request - .options - .iter() - .any(|o| matches!(o, ElevationOption::FullAccess)) - ); - assert!( - request - .options - .iter() - .any(|o| matches!(o, ElevationOption::Abort)) - ); -} - // ======================================================================== // Workflow elevated plan approval card (#4126) // ======================================================================== diff --git a/crates/tui/src/tui/background_indicator.rs b/crates/tui/src/tui/background_indicator.rs index f4869cfbb3..cfd75ff3a8 100644 --- a/crates/tui/src/tui/background_indicator.rs +++ b/crates/tui/src/tui/background_indicator.rs @@ -257,15 +257,6 @@ mod tests { } } - #[test] - fn empty_pending_work_hides_the_indicator() { - let work = PendingWork::default(); - assert!(work.items.is_empty()); - assert_eq!(work.count(PendingItemKind::Shell), 0); - assert_eq!(work.count(PendingItemKind::Task), 0); - assert_eq!(work.count(PendingItemKind::Agent), 0); - } - #[test] fn counts_cover_shells_tasks_and_agents_with_pluralization() { let work = PendingWork { @@ -290,16 +281,6 @@ mod tests { assert_eq!(work.count(PendingItemKind::Agent), 1); } - #[test] - fn completion_clears_the_line() { - let mut work = PendingWork { - items: vec![shell("cargo test")], - }; - assert!(!work.items.is_empty()); - work.items.clear(); - assert!(work.items.is_empty()); - } - #[test] fn long_labels_are_pre_truncated() { let long = "cargo test -p codewhale-tui --lib background_indicator -- --exact this is long"; diff --git a/crates/tui/src/tui/command_palette.rs b/crates/tui/src/tui/command_palette.rs index 949c157ac1..f3a22552f1 100644 --- a/crates/tui/src/tui/command_palette.rs +++ b/crates/tui/src/tui/command_palette.rs @@ -1639,38 +1639,6 @@ mod tests { ); } - #[test] - fn command_palette_hidden_canonical_shadow_exposes_no_discovery_row() { - // A hidden user command claiming a built-in canonical token must not - // surface either row: the hidden command is excluded from output while - // still owning the token (AT-008 boundary in the palette). - let tmp = TempDir::new().expect("tempdir"); - let workspace = tmp.path().join("workspace"); - let commands_dir = workspace.join(".codewhale").join("commands"); - std::fs::create_dir_all(&commands_dir).expect("create commands dir"); - std::fs::write( - commands_dir.join("private-help.md"), - "---\nname: help\nhidden: true\n---\nprivate help", - ) - .expect("write hidden user command"); - - let entries = build_entries( - Locale::En, - tmp.path().join("skills").as_path(), - false, - workspace.as_path(), - tmp.path().join("mcp.json").as_path(), - None, - ); - - assert!( - !entries - .iter() - .any(|entry| entry.section == PaletteSection::Command && entry.label == "/help"), - "neither the hidden user command nor the shadowed built-in may appear" - ); - } - #[test] fn command_palette_alias_shadow_preserves_canonical_row_without_claimed_alias() { // A user command claiming only one built-in alias must leave the diff --git a/crates/tui/src/tui/display_refresh.rs b/crates/tui/src/tui/display_refresh.rs index 24463db794..d02d7e55ab 100644 --- a/crates/tui/src/tui/display_refresh.rs +++ b/crates/tui/src/tui/display_refresh.rs @@ -442,15 +442,6 @@ mod tests { ); } - #[test] - fn probe_is_infallible_and_cached() { - let a = probe_display_refresh(); - let b = probe_display_refresh(); - assert_eq!(a, b); - // Either measured or skipped — never panics. - assert!(a.outcome() == "ok" || a.outcome() == "skipped" || a.outcome() == "error"); - } - /// The real host probe, which `probe_display_refresh` no longer reaches /// under test. Its result is whatever this machine reports, so assert only /// the invariants that hold everywhere — but keep calling it, so the FFI diff --git a/crates/tui/src/tui/file_mention.rs b/crates/tui/src/tui/file_mention.rs index efc6b68d30..edceaf234e 100644 --- a/crates/tui/src/tui/file_mention.rs +++ b/crates/tui/src/tui/file_mention.rs @@ -1626,24 +1626,6 @@ mod tests { assert!(!content.contains("` - /// so the user gets an explicit signal instead of silent failure. - #[test] - fn truly_missing_mention_still_renders_missing_file() { - let tmp = TempDir::new().expect("tempdir"); - - let content = user_request_with_file_mentions( - "huh @does/not/exist.txt", - tmp.path(), - Some(tmp.path().to_path_buf()), - ); - - assert!( - content.contains(" 0 && self.cursor >= self.scroll_offset + visible { - self.scroll_offset = self.cursor.saturating_add(1).saturating_sub(visible); - } - } } // --------------------------------------------------------------------------- @@ -674,19 +663,6 @@ mod tests { ); } - #[test] - fn adjust_scroll_keeps_the_cursor_inside_the_visible_window() { - let ws = fixture_workspace(); - let mut state = FileTreeState::new(ws.path()); - state.cursor = state.entries.len().saturating_sub(1); - state.adjust_scroll(3); - assert!(state.cursor < state.scroll_offset + 3); - - state.cursor = 0; - state.adjust_scroll(3); - assert_eq!(state.scroll_offset, 0); - } - #[test] fn stale_expand_results_are_discarded() { let ws = fixture_workspace(); diff --git a/crates/tui/src/tui/footer_ui.rs b/crates/tui/src/tui/footer_ui.rs index 5f30bcaa18..c7ae8acdef 100644 --- a/crates/tui/src/tui/footer_ui.rs +++ b/crates/tui/src/tui/footer_ui.rs @@ -127,27 +127,6 @@ pub(crate) fn format_token_count_compact(tokens: u64) -> String { } } -#[cfg(test)] -pub(crate) fn format_context_budget(used: i64, max: u32) -> String { - let max_u64 = u64::from(max); - let max_i64 = i64::from(max); - - if used > max_i64 { - return format!( - ">{}/{}", - format_token_count_compact(max_u64), - format_token_count_compact(max_u64) - ); - } - - let used_u64 = u64::try_from(used.max(0)).unwrap_or(0); - format!( - "{}/{}", - format_token_count_compact(used_u64), - format_token_count_compact(max_u64) - ) -} - #[cfg(test)] mod tests { use super::one_line_summary; diff --git a/crates/tui/src/tui/git_status.rs b/crates/tui/src/tui/git_status.rs index d90f12d67e..383f1af34d 100644 --- a/crates/tui/src/tui/git_status.rs +++ b/crates/tui/src/tui/git_status.rs @@ -241,52 +241,6 @@ fn repository_name_from_common_dir(worktree_root: &Path, common_dir: &Path) -> O .map(|name| name.to_string_lossy().into_owned()) } -/// Compact chrome label: `CodeWhale · main* ↑2` or -/// `CodeWhale/feature · feature*` for a linked worktree. -/// -/// Omits the segment when Git has not named a location or ref. A known -/// location without a branch still renders — the header must not invent a -/// ref to fill the slot. -#[must_use] -pub fn chrome_label(snap: &GitStatusSnapshot) -> Option { - let worktree_name = snap - .root - .as_deref() - .and_then(Path::file_name) - .map(|name| name.to_string_lossy()); - let location = match (snap.repository_name.as_deref(), worktree_name.as_deref()) { - (Some(repository), Some(worktree)) if repository != worktree => { - Some(format!("{repository}/{worktree}")) - } - (Some(repository), _) => Some(repository.to_string()), - (None, Some(worktree)) => Some(worktree.to_string()), - (None, None) => None, - }; - let mut label = match (location, snap.branch.as_deref()) { - (Some(location), Some(branch)) => format!("{location} · {branch}"), - (Some(location), None) => location, - (None, Some(branch)) => branch.to_string(), - (None, None) => return None, - }; - if snap.dirty { - label.push('*'); - } - if snap.ahead > 0 { - label.push_str(&format!(" ↑{}", snap.ahead)); - } - if snap.behind > 0 { - label.push_str(&format!(" ↓{}", snap.behind)); - } - Some(label) -} - -/// Status-bar ink for repository chrome. Location is metadata, not a -/// failure — dirtiness is the `*` on the same gray string. -#[must_use] -pub fn chrome_ink() -> crate::palette::ChromeInk { - crate::palette::ChromeInk::Metadata -} - /// Create a new worktree at `path` tracking `branch` (or a new branch name). pub fn create_worktree( repo: &Path, @@ -385,80 +339,6 @@ locked assert_eq!(entries[1].branch.as_deref(), Some("feat")); } - #[test] - fn chrome_label_marks_dirty_and_divergence() { - let snap = GitStatusSnapshot { - root: Some("/repo".into()), - repository_name: Some("repo".into()), - branch: Some("main".into()), - dirty: true, - ahead: 2, - behind: 1, - ..GitStatusSnapshot::default() - }; - assert_eq!(chrome_label(&snap).as_deref(), Some("repo · main* ↑2 ↓1")); - } - - #[test] - fn chrome_label_identifies_a_linked_worktree() { - let snap = GitStatusSnapshot { - root: Some("/repo/.cw-worktrees/feature".into()), - repository_name: Some("repo".into()), - branch: Some("feature".into()), - dirty: true, - ..GitStatusSnapshot::default() - }; - - assert_eq!( - chrome_label(&snap).as_deref(), - Some("repo/feature · feature*") - ); - } - - #[test] - fn chrome_label_omits_dirty_marker_when_clean() { - let snap = GitStatusSnapshot { - root: Some("/repo".into()), - repository_name: Some("repo".into()), - branch: Some("main".into()), - dirty: false, - ..GitStatusSnapshot::default() - }; - assert_eq!(chrome_label(&snap).as_deref(), Some("repo · main")); - } - - #[test] - fn chrome_label_keeps_location_when_the_ref_is_unknown() { - let snap = GitStatusSnapshot { - root: Some("/repo/.cw-worktrees/feature".into()), - repository_name: Some("repo".into()), - branch: None, - dirty: true, - ..GitStatusSnapshot::default() - }; - assert_eq!(chrome_label(&snap).as_deref(), Some("repo/feature*")); - } - - #[test] - fn chrome_label_is_absent_without_a_repo_or_ref() { - assert_eq!( - chrome_label(&GitStatusSnapshot { - error: Some("not a git repository".into()), - ..GitStatusSnapshot::default() - }), - None - ); - } - - #[test] - fn chrome_ink_is_metadata_not_failure() { - assert_eq!(chrome_ink(), crate::palette::ChromeInk::Metadata); - assert_eq!( - chrome_ink().family(), - crate::palette::SemanticFamily::Metadata - ); - } - #[test] fn repository_name_uses_the_common_git_directory_for_worktrees() { assert_eq!( diff --git a/crates/tui/src/tui/hotbar/actions.rs b/crates/tui/src/tui/hotbar/actions.rs index e1d3a9892c..8249775497 100644 --- a/crates/tui/src/tui/hotbar/actions.rs +++ b/crates/tui/src/tui/hotbar/actions.rs @@ -47,20 +47,6 @@ impl HotbarActionCategory { Self::Plugin => "plugin", } } - - #[must_use] - #[cfg_attr(not(test), expect(dead_code))] - pub fn parse(value: &str) -> Option { - match value { - "app" => Some(Self::App), - "route" => Some(Self::Route), - "slash" => Some(Self::Slash), - "mcp" => Some(Self::Mcp), - "skill" => Some(Self::Skill), - "plugin" => Some(Self::Plugin), - _ => None, - } - } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -120,37 +106,6 @@ pub struct HotbarActionMetadata { pub recommendation: HotbarRecommendation, } -impl HotbarActionMetadata { - #[must_use] - pub fn validation_errors(&self) -> Vec { - let mut errors = Vec::new(); - if self.id.trim().is_empty() { - errors.push("id must not be empty".to_string()); - } - if self.source_id.trim().is_empty() { - errors.push(format!("{} source_id must not be empty", self.id)); - } - if self.display_name.trim().is_empty() { - errors.push(format!("{} display_name must not be empty", self.id)); - } - if self.compact_label.trim().is_empty() { - errors.push(format!("{} compact_label must not be empty", self.id)); - } - if unicode_width::UnicodeWidthStr::width(self.compact_label.as_str()) - > HOTBAR_COMPACT_LABEL_MAX_WIDTH - { - errors.push(format!( - "{} compact_label {:?} exceeds {} display cells", - self.id, self.compact_label, HOTBAR_COMPACT_LABEL_MAX_WIDTH - )); - } - if self.description.trim().is_empty() { - errors.push(format!("{} description must not be empty", self.id)); - } - errors - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct HotbarRecommendationEntry { pub metadata: HotbarActionMetadata, @@ -384,24 +339,6 @@ pub fn recommend_hotbar_actions( selected } -#[must_use] -#[cfg_attr(not(test), expect(dead_code))] -pub fn recommended_hotbar_bindings( - app: &App, - options: HotbarRecommendationOptions, -) -> Vec { - recommend_hotbar_actions(app, options) - .into_iter() - .take(usize::from(codewhale_config::HOTBAR_SLOT_COUNT)) - .enumerate() - .map(|(idx, entry)| codewhale_config::HotbarBindingToml { - slot: u8::try_from(idx + 1).expect("recommended hotbar slot fits in u8"), - action: entry.metadata.id, - label: Some(entry.metadata.compact_label), - }) - .collect() -} - fn default_hotbar_position(action_id: &str) -> Option { codewhale_config::DEFAULT_HOTBAR_ACTIONS .iter() @@ -789,46 +726,6 @@ impl HotbarActionRegistry { pub fn iter(&self) -> impl Iterator { self.actions.values().map(Arc::as_ref) } - - #[cfg_attr(not(test), expect(dead_code))] - #[must_use] - pub fn metadata(&self, locale: Locale) -> Vec { - self.iter().map(|action| action.metadata(locale)).collect() - } - - #[cfg_attr(not(test), expect(dead_code))] - #[must_use] - pub fn metadata_validation_errors(&self, locale: Locale) -> Vec { - let mut errors = Vec::new(); - for action in self.iter() { - let metadata = action.metadata(locale); - if metadata.id != action.id() { - errors.push(format!( - "{} metadata id {:?} does not match action id", - action.id(), - metadata.id - )); - } - if metadata.compact_label != action.short_label() { - errors.push(format!( - "{} metadata compact_label {:?} does not match short_label {:?}", - action.id(), - metadata.compact_label, - action.short_label() - )); - } - if metadata.category.as_str() != action.category() { - errors.push(format!( - "{} metadata category {:?} does not match category {:?}", - action.id(), - metadata.category.as_str(), - action.category() - )); - } - errors.extend(metadata.validation_errors()); - } - errors - } } fn dispatch_command_result(app: &mut App, result: CommandResult) -> HotbarDispatch { @@ -1594,49 +1491,6 @@ mod tests { }); } - #[test] - fn registry_metadata_contract_covers_registered_actions() { - let registry = HotbarActionRegistry::with_builtins(); - let errors = registry.metadata_validation_errors(Locale::En); - assert!(errors.is_empty(), "metadata validation failed: {errors:?}"); - - let metadata = registry.metadata(Locale::En); - assert_eq!(metadata.len(), registry.len()); - - let ids = metadata - .iter() - .map(|entry| entry.id.as_str()) - .collect::>(); - let mut sorted_ids = ids.clone(); - sorted_ids.sort_unstable(); - assert_eq!( - ids, sorted_ids, - "registry metadata should have stable id order" - ); - assert_eq!( - ids.iter().copied().collect::>().len(), - ids.len(), - "metadata ids must be unique" - ); - - for entry in metadata { - assert_eq!( - HotbarActionCategory::parse(entry.category.as_str()), - Some(entry.category) - ); - let entry_errors = entry.validation_errors(); - assert!( - entry_errors.is_empty(), - "metadata entry failed validation: {entry_errors:?}" - ); - assert!( - unicode_width::UnicodeWidthStr::width(entry.compact_label.as_str()) - <= HOTBAR_COMPACT_LABEL_MAX_WIDTH, - "compact label should be validated: {entry:?}" - ); - } - } - #[test] fn source_descriptors_cover_dispatch_boundaries() { let descriptors = hotbar_source_descriptors(); @@ -1994,46 +1848,6 @@ mod tests { assert_eq!(eligible_slash, 1); } - #[test] - fn recommended_hotbar_bindings_serialize_action_ids_and_labels() { - let app = test_app(); - - let bindings = recommended_hotbar_bindings(&app, HotbarRecommendationOptions::default()); - - assert_eq!( - bindings - .iter() - .map(|binding| binding.action.as_str()) - .collect::>(), - codewhale_config::DEFAULT_HOTBAR_ACTIONS - ); - assert_eq!( - bindings - .iter() - .map(|binding| (binding.slot, binding.label.as_deref())) - .collect::>(), - vec![ - (1, Some("wf")), - (2, Some("goal")), - (3, Some("auto")), - (4, Some("plan")), - (5, Some("agent")), - (6, Some("operate")), - (7, Some("palette")), - (8, Some("side")), - ] - ); - - let config = codewhale_config::ConfigToml { - hotbar: Some(bindings.clone()), - ..Default::default() - }; - let serialized = toml::to_string_pretty(&config).expect("serialize hotbar recommendations"); - let round_tripped: codewhale_config::ConfigToml = - toml::from_str(&serialized).expect("deserialize hotbar recommendations"); - assert_eq!(round_tripped.hotbar, Some(bindings)); - } - #[test] fn builtins_register_expected_actions() { let mut registry = HotbarActionRegistry::new(); @@ -2276,7 +2090,6 @@ mod tests { assert_eq!(metadata.args, HotbarArgsBehavior::None); assert_eq!(metadata.safety, HotbarSafetyClass::ExistingCommand); assert_eq!(metadata.recommendation, HotbarRecommendation::Eligible); - assert!(registry.metadata_validation_errors(Locale::En).is_empty()); } #[test] @@ -2400,7 +2213,6 @@ mod tests { assert_eq!(metadata.args, HotbarArgsBehavior::Required); assert_eq!(metadata.safety, HotbarSafetyClass::RequiresApproval); assert_eq!(metadata.recommendation, HotbarRecommendation::Advanced); - assert!(registry.metadata_validation_errors(Locale::En).is_empty()); } #[test] diff --git a/crates/tui/src/tui/markdown_render.rs b/crates/tui/src/tui/markdown_render.rs index 6940034495..b93078b663 100644 --- a/crates/tui/src/tui/markdown_render.rs +++ b/crates/tui/src/tui/markdown_render.rs @@ -3771,17 +3771,6 @@ mod tests { assert_eq!(combined.matches('y').count(), 120); } - #[test] - fn paragraph_wrap_handles_zero_width_gracefully() { - // Width 0 should not panic or hang; it returns the input as-is or - // empty, but never produces a line wider than 0 (when 0 means "no - // budget at all"). This pins the early-return path against future - // regressions. - let rendered = render_paragraph_for_test("hello world", 0); - // Any output is acceptable (the path is degenerate); assert no panic. - let _ = rendered; - } - fn rendered_text(rendered: &[Line<'static>]) -> String { rendered .iter() diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 95690b0954..d32980cb65 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -280,29 +280,6 @@ mod tests { ) } - #[test] - fn working_marker_uses_the_live_work_status_role() { - let mut app = test_app(); - // Match Terminal intentionally aliases both roles to ANSI Cyan. Use - // the branded palette here to prove the renderer selects the working - // slot rather than merely observing an equal terminal color. - app.ui_theme = crate::palette::UI_THEME; - assert_eq!(ShellPhase::Working.color(&app), app.ui_theme.status_working); - assert_ne!(ShellPhase::Working.color(&app), app.ui_theme.info); - assert_eq!( - crate::tui::underwater::phase_ink(ShellPhase::Working), - ChromeInk::Active - ); - assert_eq!( - crate::tui::underwater::phase_ink(ShellPhase::Failed), - ChromeInk::Failure - ); - assert_ne!( - crate::tui::underwater::phase_ink(ShellPhase::Working).family(), - crate::palette::SemanticFamily::Failure - ); - } - #[test] fn boot_activity_levels_keep_plugin_attention_and_failure_distinct() { use crate::tui::session_boot::SessionBootActivityLevel; @@ -1117,18 +1094,3 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo #[cfg(test)] mod tideline_tests; - -#[cfg(test)] -mod neutrality_tests { - #[test] - fn session_metrics_strip_is_on_by_default() { - assert!( - crate::config::StatusItem::default_footer() - .contains(&crate::config::StatusItem::SessionMetrics) - ); - assert_eq!( - crate::config::StatusItem::from_key("session_metrics"), - Some(crate::config::StatusItem::SessionMetrics) - ); - } -} diff --git a/crates/tui/src/tui/setup/remote.rs b/crates/tui/src/tui/setup/remote.rs index 0ab9f4c29c..6e1059bfc6 100644 --- a/crates/tui/src/tui/setup/remote.rs +++ b/crates/tui/src/tui/setup/remote.rs @@ -199,23 +199,6 @@ impl SetupRemoteFacts { } } - /// The mode the step defaults to. Local-only is always the default and is - /// always skippable in one key — a user who never wants remote access is - /// done here immediately. Asserted by test rather than branched on: the - /// wizard's Enter path is unconditional precisely because of this. - #[cfg(test)] - pub(super) fn default_mode(&self) -> RemoteMode { - RemoteMode::LocalOnly - } - - #[cfg(test)] - pub(super) fn status_for(&self, mode: RemoteMode) -> RemoteModeStatus { - self.modes - .iter() - .find(|fact| fact.mode == mode) - .map_or(RemoteModeStatus::NeedsAction, |fact| fact.status) - } - /// True when something is missing but nothing is broken. Callers record /// `NeedsAction` — which by contract never blocks the ready screen. pub(super) fn needs_action(&self) -> bool { @@ -455,6 +438,19 @@ mod tests { by_mode(RemoteMode::ChatBridge), RemoteModeStatus::NeedsAction ); + + // Direct call-site pin: the production ready gate reads NeedsAction + // from the same bare-install facts (`needs_action` has no other test). + let facts = SetupRemoteFacts { + modes: observe_modes(&env(&[])), + clouds_result: String::new(), + bridges_result: String::new(), + providers_result: String::new(), + mode_result: String::new(), + command_provider: "deepseek".to_string(), + result: String::new(), + }; + assert!(facts.needs_action()); } #[test] @@ -559,36 +555,6 @@ mod tests { ); } - /// A user who wants none of this must be able to leave in one key: the - /// default mode is local-only and local-only is always usable. - #[test] - fn the_default_mode_is_local_only_and_is_never_blocking() { - let facts = SetupRemoteFacts { - modes: observe_modes(&env(&[])), - clouds_result: String::new(), - bridges_result: String::new(), - providers_result: String::new(), - mode_result: String::new(), - command_provider: "deepseek".to_string(), - result: String::new(), - }; - - assert_eq!(facts.default_mode(), RemoteMode::LocalOnly); - assert_eq!( - facts.status_for(RemoteMode::LocalOnly), - RemoteModeStatus::Ready - ); - // Missing tokens/config are surfaced, but only ever as NeedsAction. - assert!(facts.needs_action()); - assert!( - facts - .modes - .iter() - .all(|fact| fact.status != RemoteModeStatus::Disabled - || fact.mode == RemoteMode::MobileLan) - ); - } - #[test] fn mode_rows_render_in_every_complete_pack_without_placeholders() { for locale in Locale::shipped_complete() { diff --git a/crates/tui/src/tui/sidebar.rs b/crates/tui/src/tui/sidebar.rs index 1ffcafa78f..9586990e2d 100644 --- a/crates/tui/src/tui/sidebar.rs +++ b/crates/tui/src/tui/sidebar.rs @@ -262,7 +262,7 @@ mod tests { use crate::localization::Locale; use crate::tui::app::{ AgentCurrentActivity, AgentCurrentActivityStatus, AgentProgressMeta, App, - SidebarHoverSection, SidebarHoverState, TuiOptions, + SidebarHoverState, TuiOptions, }; use std::path::PathBuf; @@ -284,44 +284,6 @@ mod tests { assert!(state.sections.is_empty()); } - #[test] - fn sidebar_hover_section_stores_lines() { - use ratatui::layout::Rect; - let section = SidebarHoverSection { - content_area: Rect::new(1, 1, 38, 8), - lines: vec!["line 1".to_string(), "line 2".to_string()], - rows: vec![], - }; - assert_eq!(section.lines.len(), 2); - assert_eq!(section.lines[0], "line 1"); - assert!(section.content_area.x > 0); - } - - #[test] - fn hover_line_matching_respects_content_area_offset() { - use ratatui::layout::Rect; - let section = SidebarHoverSection { - content_area: Rect::new(62, 2, 36, 6), - lines: vec![ - "first".to_string(), - "second".to_string(), - "third".to_string(), - ], - rows: vec![], - }; - - // Mouse within content area, first line - let line_idx = (2u16.saturating_sub(section.content_area.y)) as usize; - assert_eq!(section.lines[line_idx], "first"); - - // Mouse within content area, second line - let line_idx = (3u16.saturating_sub(section.content_area.y)) as usize; - assert_eq!(section.lines[line_idx], "second"); - - // Mouse outside content area (above) — row < content_area.y - assert!((1u16) < section.content_area.y); - } - // ── #3030: stable labels instead of raw internal ids ─────────────────── #[test] diff --git a/crates/tui/src/tui/tideline.rs b/crates/tui/src/tui/tideline.rs index 0a3dbbdde3..a5e263d896 100644 --- a/crates/tui/src/tui/tideline.rs +++ b/crates/tui/src/tui/tideline.rs @@ -269,7 +269,7 @@ mod tests { use super::{ ContextBudgetSnapshot, InspectDetail, InteractionAction, InteractionFocus, InteractionRegistry, InteractionTarget, InteractionTargetId, SettingApplySemantics, - SettingAuthority, SettingFact, UiSnapshot, + SettingAuthority, UiSnapshot, }; use crate::config::ApiProvider; use ratatui::layout::Rect; @@ -289,26 +289,6 @@ mod tests { } } - #[test] - fn topbar_route_target_is_typed_without_copying_route_facts() { - let target = InteractionTarget { - id: InteractionTargetId::HEADER_ROUTE, - area: Rect::new(20, 0, 24, 1), - focus: InteractionFocus::Direct, - keyboard_action: Some(InteractionAction::OpenProviderPicker), - mouse_action: Some(InteractionAction::OpenProviderPicker), - inspect_detail: InspectDetail::Route, - }; - - assert_eq!(target.id, InteractionTargetId::HEADER_ROUTE); - assert_eq!( - target.keyboard_action, - Some(InteractionAction::OpenProviderPicker) - ); - assert_eq!(target.mouse_action, target.keyboard_action); - assert_eq!(target.inspect_detail, InspectDetail::Route); - } - #[test] fn ui_snapshot_uses_active_route_without_claiming_saved_defaults() { let mut app = @@ -344,43 +324,6 @@ mod tests { assert!(snapshot.percent_basis_points <= 10_000); } - #[test] - fn setting_fact_keeps_live_startup_and_saved_lanes_distinct() { - let fact = SettingFact { - current: Some("session"), - effective: Some("managed"), - startup: Some("next"), - saved: Some("disk"), - authority: SettingAuthority::ManagedPolicy, - apply: SettingApplySemantics::NextSession, - }; - - assert_eq!(fact.current, Some("session")); - assert_eq!(fact.effective, Some("managed")); - assert_eq!(fact.startup, Some("next")); - assert_eq!(fact.saved, Some("disk")); - } - - #[test] - fn apply_semantics_distinguish_reload_and_partial_restart_from_full_restart() { - let reload = SettingFact { - current: Some("live"), - effective: None, - startup: None, - saved: Some("disk"), - authority: SettingAuthority::UserSettings, - apply: SettingApplySemantics::ReloadRequired, - }; - let partial = SettingFact { - apply: SettingApplySemantics::UiNowEngineRestart, - ..reload.clone() - }; - assert_ne!(reload.apply, SettingApplySemantics::RestartRequired); - assert_ne!(partial.apply, SettingApplySemantics::RestartRequired); - assert_ne!(reload.apply, partial.apply); - assert_ne!(reload.apply, SettingApplySemantics::Immediate); - } - #[test] fn registry_ignores_empty_geometry_and_prefers_the_topmost_target() { let mut registry = InteractionRegistry::default(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 08bd49573e..4a190c174e 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -24,9 +24,7 @@ use crate::tui::file_mention::{ apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor, try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; -use crate::tui::footer_ui::{ - format_context_budget, format_token_count_compact, friendly_subagent_progress, -}; +use crate::tui::footer_ui::{format_token_count_compact, friendly_subagent_progress}; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, SubAgentCell, ToolCell, ToolStatus, }; @@ -2940,8 +2938,10 @@ fn loading_mouse_filter_keeps_hover_and_active_drags() { assert!(!should_drop_loading_mouse_motion(&app, drag)); app.viewport.transcript_scrollbar_dragging = false; - app.work_surface = crate::tui::work_surface::WorkSurfaceState::with_placement( + app.work_surface = crate::tui::work_surface::WorkSurfaceState::with_layout( crate::tui::work_surface::WorkSurfacePlacement::Top, + 3, + 30, ); app.work_surface.last_area = Some(Rect::new(0, 0, 80, 3)); let started = crate::tui::work_surface::handle_mouse( @@ -10275,7 +10275,10 @@ async fn lost_strict_message_submit_executor_keeps_dispatch_atomic_and_recoverab // The failed gate leaves no poisoned dispatch state: a later ordinary // message can start and reaches the engine exactly once. - app.hooks = crate::hooks::HookExecutor::disabled(); + app.hooks = crate::hooks::HookExecutor::new( + crate::hooks::HooksConfig::default(), + std::path::PathBuf::from("."), + ); dispatch_user_message( &mut app, &Config::default(), @@ -11534,10 +11537,12 @@ fn engine_event_disconnect_recovers_live_turn_immediately() { }); let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app); crate::tui::streaming_thinking::append(&mut app, thinking_idx, "partial reasoning"); - app.push_pending_steer(crate::tui::app::QueuedMessage::new( - "please continue after recovery".to_string(), - None, - )); + app.pending_steers + .push_back(crate::tui::app::QueuedMessage::new( + "please continue after recovery".to_string(), + None, + )); + app.submit_pending_steers_after_interrupt = true; let recovered = recover_engine_event_disconnect(&mut app); @@ -12739,12 +12744,6 @@ fn format_token_count_compact_formats_units() { assert_eq!(format_token_count_compact(1_000_000), "1.0M"); } -#[test] -fn format_context_budget_caps_overflow_display() { - assert_eq!(format_context_budget(5_000, 128_000), "5.0k/128.0k"); - assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k"); -} - #[test] fn event_poll_timeout_has_nonzero_floor() { assert_eq!( @@ -21767,10 +21766,11 @@ fn merge_pending_steers_returns_none_when_empty() { #[test] fn merge_pending_steers_passes_through_single_message() { let mut app = create_test_app(); - app.push_pending_steer(QueuedMessage::new( + app.pending_steers.push_back(QueuedMessage::new( "lone steer".to_string(), Some("skill body".to_string()), )); + app.submit_pending_steers_after_interrupt = true; let merged = merge_pending_steers(&mut app).expect("merge yields a message"); assert_eq!(merged.display, "lone steer"); assert_eq!(merged.skill_instruction.as_deref(), Some("skill body")); @@ -21781,9 +21781,13 @@ fn merge_pending_steers_passes_through_single_message() { #[test] fn merge_pending_steers_concatenates_multiple_with_blank_line() { let mut app = create_test_app(); - app.push_pending_steer(QueuedMessage::new("first".to_string(), None)); - app.push_pending_steer(QueuedMessage::new("second".to_string(), None)); - app.push_pending_steer(QueuedMessage::new("third".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("first".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("second".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("third".to_string(), None)); + app.submit_pending_steers_after_interrupt = true; let merged = merge_pending_steers(&mut app).expect("merge yields a message"); assert_eq!(merged.display, "first\n\nsecond\n\nthird"); @@ -21793,14 +21797,15 @@ fn merge_pending_steers_concatenates_multiple_with_blank_line() { #[test] fn merge_pending_steers_keeps_first_skill_instruction_only() { let mut app = create_test_app(); - app.push_pending_steer(QueuedMessage::new( + app.pending_steers.push_back(QueuedMessage::new( "a".to_string(), Some("first skill".to_string()), )); - app.push_pending_steer(QueuedMessage::new( + app.pending_steers.push_back(QueuedMessage::new( "b".to_string(), Some("second skill".to_string()), )); + app.submit_pending_steers_after_interrupt = true; let merged = merge_pending_steers(&mut app).expect("merge yields a message"); assert_eq!(merged.skill_instruction.as_deref(), Some("first skill")); assert_eq!(merged.display, "a\n\nb"); @@ -21828,8 +21833,11 @@ fn restored_queued_message_crosses_bounded_message_submit_serialization() { #[test] fn merged_pending_steers_cross_bounded_message_submit_serialization() { let mut app = create_test_app(); - app.push_pending_steer(QueuedMessage::new("first \"quoted\"\n".repeat(5_000), None)); - app.push_pending_steer(QueuedMessage::new("second 用户\n".repeat(5_000), None)); + app.pending_steers + .push_back(QueuedMessage::new("first \"quoted\"\n".repeat(5_000), None)); + app.pending_steers + .push_back(QueuedMessage::new("second 用户\n".repeat(5_000), None)); + app.submit_pending_steers_after_interrupt = true; let merged = merge_pending_steers(&mut app).expect("merged message"); let payload = crate::hooks::message_submit_payload(&app.base_hook_context(), &merged.display); let encoded = serde_json::to_vec(&payload).expect("serialize merged payload"); @@ -21851,7 +21859,9 @@ fn merged_pending_steers_cross_bounded_message_submit_serialization() { #[test] fn build_pending_input_preview_populates_all_three_buckets() { let mut app = create_test_app(); - app.push_pending_steer(QueuedMessage::new("steer-msg".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("steer-msg".to_string(), None)); + app.submit_pending_steers_after_interrupt = true; app.rejected_steers.push_back("rejected-msg".to_string()); app.queue_message(QueuedMessage::new("queued-msg".to_string(), None)); @@ -22001,23 +22011,23 @@ fn openrouter_credits_map_remaining_usd() { #[test] fn displayed_session_cost_is_monotonic_under_negative_reconciliation() { let mut app = create_test_app(); - app.accrue_subagent_cost(0.50); - let after_first = app.displayed_session_cost(); + app.accrue_subagent_cost_estimate(crate::pricing::CostEstimate::usd_only(0.50)); + let after_first = app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd); assert!((after_first - 0.50).abs() < 1e-6); // Simulate reconciliation that lowers the underlying counter (e.g. a // cache discount applied after the fact). The underlying value drops, // but the displayed cost must not. app.session.subagent_cost = 0.20; - let after_second = app.displayed_session_cost(); + let after_second = app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd); assert!( after_second >= after_first, "displayed cost regressed: {after_second} < {after_first}" ); // Adding more cost should still bump above the high-water. - app.accrue_session_cost(0.10); - let after_add = app.displayed_session_cost(); + app.accrue_session_cost_estimate(crate::pricing::CostEstimate::usd_only(0.10)); + let after_add = app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd); assert!(after_add >= after_first); } @@ -22038,14 +22048,16 @@ fn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() { }, }; handle_subagent_mailbox(&mut app, 11, &usage); - let baseline = app.displayed_session_cost(); + let baseline = app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd); assert!(baseline > 0.0); // Re-emit the same response under a fresh mailbox sequence — it must still // dedupe and leave displayed cost unchanged. handle_subagent_mailbox(&mut app, 12, &usage); assert!( - (app.displayed_session_cost() - baseline).abs() < 1e-9, + (app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd) - baseline) + .abs() + < 1e-9, "duplicate provider response must not move displayed cost" ); @@ -22056,7 +22068,7 @@ fn duplicate_mailbox_token_usage_does_not_regress_displayed_cost() { }; *source_id = "response-y".to_string(); handle_subagent_mailbox(&mut app, 13, &distinct); - assert!(app.displayed_session_cost() > baseline); + assert!(app.displayed_session_cost_for_currency(crate::pricing::CostCurrency::Usd) > baseline); } #[test] fn checklist_write_renders_dedicated_card() { diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index 37ed294cc0..04c90b40fe 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -11,21 +11,17 @@ use std::borrow::Cow; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ - buffer::Buffer, layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, - widgets::{Block, Paragraph, Widget}, }; use unicode_width::UnicodeWidthStr; -use crate::config::HeaderItem; use crate::localization::{Locale, MessageId, tr}; -use crate::palette::{ChromeInk, chrome_style}; +use crate::palette::ChromeInk; use crate::tui::{ - app::{App, AppMode, HeaderActionTarget, HeaderHitbox, OnboardingState}, + app::{App, AppMode, OnboardingState}, approval::ApprovalMode, - footer_ui::format_token_count_compact, ocean::COMPLETION_BREATH_MS, views::ModalKind, }; @@ -572,28 +568,6 @@ impl ShellPhase { Self::Failed => tr(locale, MessageId::PhaseFailed), } } - - #[must_use] - #[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell - // (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. - pub fn color(self, app: &App) -> Color { - phase_ink(self).color(&app.ui_theme) - } -} - -/// Status-bar phase ink. Failure red is only `Failed`. -#[must_use] -pub(crate) fn phase_ink(phase: ShellPhase) -> ChromeInk { - match phase { - ShellPhase::Idle => ChromeInk::Metadata, - ShellPhase::Done => ChromeInk::Outcome, - ShellPhase::Typing => ChromeInk::Identity, - // Verifying shares the live seafoam hue; the tick-vs-bubble - // marker carries the checking/searching distinction. - ShellPhase::Working | ShellPhase::Verifying => ChromeInk::Active, - ShellPhase::Waiting | ShellPhase::Approval => ChromeInk::Waiting, - ShellPhase::Failed => ChromeInk::Failure, - } } /// Exhaustive on purpose: a new [`AppMode`] must be handed a Policy ink @@ -614,12 +588,6 @@ fn header_permission_ink(mode: ApprovalMode) -> ChromeInk { } } -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -fn header_fg(app: &App, ink: ChromeInk) -> Style { - chrome_style(&app.ui_theme, ink) -} - /// One posture word with its ink — the unit the classic header's lockup was /// made of, now carried as merged-footer chips. pub(crate) type PostureChip = (Cow<'static, str>, ChromeInk); @@ -928,10 +896,6 @@ fn filesystem_scope_notice(app: &App) -> Option> { } } -fn span_width(spans: &[Span<'_>]) -> usize { - spans.iter().map(|span| span.content.width()).sum() -} - fn truncate_to_width(text: &str, width: usize) -> String { if text.width() <= width { return text.to_string(); @@ -956,432 +920,6 @@ fn truncate_to_width(text: &str, width: usize) -> String { result } -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -fn compact_tokens(tokens: i64) -> String { - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - format!("{:.0}K", tokens as f64 / 1_000.0) - } else { - tokens.to_string() - } -} - -#[allow(dead_code)] -// classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -/// The context meter is one measured fact: an exact percentage for scanning, -/// a token fraction for auditability when room permits, and a short bar for -/// peripheral vision. It is deliberately the final header fact so its rect -/// stays stable and can point at the inspector without parsing rendered text. -fn header_context_meter(app: &App, tier: ShellTier) -> Option> { - crate::tui::ui::context_usage_snapshot(app).map(|(used, max, percent)| { - let filled = ((percent / 100.0) * 5.0).ceil().clamp(0.0, 5.0) as usize; - let percentage = format!("{percent:.0}%"); - let text = match tier { - ShellTier::Compact => format!("ctx {percentage}"), - ShellTier::Normal | ShellTier::Wide => format!( - "context {percentage} {}/{} {}{}", - compact_tokens(used), - compact_tokens(i64::from(max)), - "▰".repeat(filled), - "▱".repeat(5usize.saturating_sub(filled)), - ), - }; - Span::styled(text, header_fg(app, ChromeInk::Info)) - }) -} - -/// Return concrete, typed header targets for the latest frame. -/// -/// The context meter is right-aligned and always the final header span, so -/// its visible geometry does not depend on optional git/token facts. The -/// keyboard route remains `Alt+C`; this gives that same inspectable fact a -/// mouse route without inventing another context screen or state owner. -#[allow(dead_code)] -// classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -// Its posture-floor guard (a hitbox never claims overlapped cells) is the -// discipline `topbar::context_meter_hitbox` carries forward. -#[must_use] -pub(crate) fn header_hitboxes(area: Rect, app: &App) -> Vec { - if area.width == 0 || area.height == 0 { - return Vec::new(); - } - let tier = ShellTier::for_chrome_width(area.width); - let Some(meter) = header_context_meter(app, tier) else { - return Vec::new(); - }; - let width = u16::try_from(span_width(&[meter])) - .unwrap_or(area.width) - .min(area.width); - if width == 0 { - return Vec::new(); - } - // The posture lockup is the header's guaranteed floor and is never - // truncated to make room for the right cluster (see - // render_header_with_git_status). At compact widths that floor can run - // into the meter's columns, so a hitbox anchored blindly at the right - // edge would claim cells the posture actually paints (review finding 5). - // Recompute the floor's width with the same spans the renderer composes - // and refuse the hitbox when the two would overlap. - let mut posture_width = 0usize; - if let Some(indicator) = crate::tui::widgets::header_status_indicator_frame( - (!app.low_motion && app.fancy_animations) - .then_some(app.turn_started_at) - .flatten(), - &app.status_indicator, - ) { - posture_width += indicator.width() + GROUP_GAP.len(); - } - posture_width += mode_label(app.ui_locale, app.mode).width(); - posture_width += FIELD_JOIN.len() + permission_label(app).width(); - if let Some(scope) = filesystem_scope_notice(app) { - posture_width += FIELD_JOIN.len() + scope.width(); - } - let meter_start = usize::from(area.width.saturating_sub(width)); - if meter_start <= posture_width.saturating_add(usize::from(width > 0)) { - return Vec::new(); - } - vec![HeaderHitbox { - area: Rect { - x: area.x.saturating_add(area.width.saturating_sub(width)), - y: area.y, - width, - height: 1, - }, - target: HeaderActionTarget::InspectContext, - }] -} - -fn session_token_breakdown(app: &App) -> Option> { - app.header_items.contains(&HeaderItem::Tokens).then(|| { - Span::styled( - format!( - "{} in · {} cch · {} out", - format_token_count_compact(u64::from(app.session.displayed_total_input_tokens())), - format_token_count_compact(u64::from( - app.session.displayed_total_cache_hit_tokens(), - )), - format_token_count_compact(u64::from(app.session.displayed_total_output_tokens())), - ), - header_fg(app, ChromeInk::Info), - ) - }) -} - -/// The header speaks with exactly two separators, and each one means one -/// thing. -/// -/// [`FIELD_JOIN`] binds words that qualify one another into a single phrase: -/// `work · ask` is one statement of posture, not two facts. [`GROUP_GAP`] -/// stands between whole facts — posture, then the goal chip, then the update -/// notice; workspace, then the context meter. -/// -/// Before this, every one of those boundaries was the same dotted separator at -/// the same dim ink, so the header read as an undifferentiated list and there -/// was nothing for the eye to group on. The gap is deliberately wider than the -/// visual whitespace inside `" · "` — four blank columns against one — because -/// that ratio is the only thing carrying the grouping. -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -const FIELD_JOIN: &str = " · "; -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -const GROUP_GAP: &str = " "; - -/// Append one chrome element, inserting the group separator only between -/// elements so an absent element never leaves trailing padding. -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -fn push_chrome(spans: &mut Vec>, span: Span<'static>) { - if !spans.is_empty() { - spans.push(Span::raw(GROUP_GAP)); - } - spans.push(span); -} - -/// Render the one-line shell header. Immediate operating posture and workspace -/// truth live here; quieter route identity lives beside the phase footer. -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) { - let git_status = crate::tui::git_status::cached_status(); - render_header_with_git_status(area, buf, app, &git_status); -} - -#[allow(dead_code)] // classic header/band renderer: superseded by the Tideline shell -// (topbar + merged footer, spec §3, 2026-08-29); deletion is its own slice. -fn render_header_with_git_status( - area: Rect, - buf: &mut Buffer, - app: &App, - git_status: &crate::tui::git_status::GitStatusSnapshot, -) { - if area.width == 0 || area.height == 0 { - return; - } - let tier = ShellTier::for_chrome_width(area.width); - Block::default() - .style(Style::default().bg(app.ui_theme.header_bg)) - .render(area, buf); - - let mode_color = header_mode_ink(app.mode).color(&app.ui_theme); - // Match the composer's warm top edge exactly: Ask amber, Auto-Review - // Signal Gold, and Full Access coral. - let permission_color = header_permission_ink(app.approval_mode).color(&app.ui_theme); - let dim = header_fg(app, ChromeInk::MetadataDim); - // `status_indicator` owns the single header mark. It used to be filtered - // against the literal "cw" because the header also hardcoded a leading - // "cw" span, and `header_status_indicator_frame` collapses `cw`, the - // legacy `whale` opt-in, and unknown values onto that same mark — so the - // filter silently discarded three of the setting's four documented values - // and left `off` with nothing to turn off (#5512). There is one mark now, - // and this setting decides what occupies it. - let status_indicator = crate::tui::widgets::header_status_indicator_frame( - (!app.low_motion && app.fancy_animations) - .then_some(app.turn_started_at) - .flatten(), - &app.status_indicator, - ); - // The posture lockup: mark, then mode and permission (and the filesystem - // scope when it deviates) joined into one phrase. This is the guaranteed - // floor of the header — everything after it is sheddable — so it is built - // once and reused by the cramped rebuild below rather than spelled twice. - let mut left = Vec::new(); - if let Some(indicator) = status_indicator { - left.push(Span::styled( - indicator, - header_fg(app, ChromeInk::Identity).add_modifier(Modifier::BOLD), - )); - left.push(Span::raw(GROUP_GAP)); - } - left.push(Span::styled( - mode_label(app.ui_locale, app.mode), - Style::default().fg(mode_color), - )); - // Permission is safety state, not optional chrome. Compact terminals shed - // auxiliary detail, but keep mode and the effective posture. - left.push(Span::styled(FIELD_JOIN, dim)); - left.push(Span::styled( - permission_label(app), - Style::default().fg(permission_color), - )); - let scope_notice = filesystem_scope_notice(app); - if let Some(scope) = scope_notice.clone() { - left.push(Span::styled(FIELD_JOIN, dim)); - left.push(Span::styled(scope, Style::default().fg(permission_color))); - } - let posture = left.clone(); - // Active-goal chip (#39): the ocean shell has no sidebar, so the topbar - // is the only always-on surface where a goal set via `create_goal` can - // live. Objective truncated to a fixed budget; terminal goals render - // nothing. The cramped-layout rebuild below keeps the chip in `suffix`. - let goal_chip = - crate::tui::footer_ui::active_goal_chip_state(app).map(|(objective, paused)| { - let budget = if paused { 22 } else { 26 }; - let flat = objective.trim().replace(['\n', '\r'], " "); - let text = if paused { - format!("goal paused {}", truncate_to_width(&flat, budget)) - } else { - format!("goal {}", truncate_to_width(&flat, budget)) - }; - let color = if paused { - ChromeInk::Attention.color(&app.ui_theme) - } else { - ChromeInk::Active.color(&app.ui_theme) - }; - (text, color) - }); - if let Some((text, color)) = &goal_chip { - left.push(Span::raw(GROUP_GAP)); - left.push(Span::styled( - text.clone(), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - // Workflow-run chip (#5040): the same `WorkflowPanel::top_bar_chip` the - // classic header shows, so a collapsed run stays visible on the ocean - // shell too. No workflow panel means no chip. The cramped-layout rebuild - // below keeps the chip in `suffix` alongside the goal chip. - let workflow_chip = app.workflow_panel.as_ref().map(|panel| { - let ink = if matches!( - panel.lifecycle, - crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded - ) { - ChromeInk::Attention - } else { - ChromeInk::Info - }; - (panel.top_bar_chip(), ink.color(&app.ui_theme)) - }); - if let Some((text, color)) = &workflow_chip { - left.push(Span::raw(GROUP_GAP)); - left.push(Span::styled( - text.clone(), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - // Update-available chip (#14): a quiet, persistent affordance set once by - // the startup version check. Gets the workflow chip's treatment: last in - // the left cluster, the route label yields its budget first, and the chip - // drops cleanly when even a minimal chip cannot fit — never a modal, - // never mid-chip clipping. - let update_chip = app - .update_available - .as_ref() - .map(|label| (label.clone(), ChromeInk::Attention.color(&app.ui_theme))); - if let Some((text, color)) = &update_chip { - left.push(Span::raw(GROUP_GAP)); - left.push(Span::styled( - text.clone(), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - - let context_meter = header_context_meter(app, tier); - let token_breakdown = (tier != ShellTier::Compact) - .then(|| session_token_breakdown(app)) - .flatten(); - // Cached repository/worktree status only — never probe from the render path. - // Background refresh is scheduled from the event loop / idle ticks. - let git_label = crate::tui::git_status::chrome_label(git_status).map(|label| { - let max_width = match tier { - ShellTier::Compact => 24, - ShellTier::Normal => 36, - ShellTier::Wide => 52, - }; - Span::styled( - truncate_to_width(&label, max_width), - header_fg(app, crate::tui::git_status::chrome_ink()), - ) - }); - - // Baseline right-hand chrome: git, then the context meter. - // - // The build version used to close this cluster. It was already the first - // thing the header sacrificed — present only on `Wide`, gone below 110 - // columns — which is the layout admitting it was never load-bearing. It is - // a fact you check deliberately (`codewhale --version`, `codewhale - // doctor`, the launch screen) exactly once, and the half of it that *is* - // worth reading mid-session — "your build is stale" — already has its own - // chip on the left. Fifteen columns of the primary chrome on every screen - // forever bought a numeral nobody was reading. - let mut right = Vec::new(); - if let Some(git_label) = git_label.clone() { - push_chrome(&mut right, git_label); - } - if let Some(context_meter) = context_meter.clone() { - push_chrome(&mut right, context_meter); - } - - // The posture lockup is the header's floor: mark, mode, permission, and a - // deviating filesystem scope never yield their columns to anything on the - // right. It is measured, not re-derived, so the floor cannot drift away - // from what actually gets drawn. - let minimum_left_width = span_width(&posture); - let available = usize::from(area.width); - // The optional token breakdown is the only elidable element: it is added - // between the git label and the context meter when the terminal is wide - // enough to keep the whole baseline plus the guaranteed-left minimum. - if let Some(token_breakdown) = token_breakdown { - let mut enhanced_right = Vec::new(); - if let Some(git_label) = git_label.clone() { - push_chrome(&mut enhanced_right, git_label); - } - push_chrome(&mut enhanced_right, token_breakdown); - if let Some(context_meter) = context_meter.clone() { - push_chrome(&mut enhanced_right, context_meter); - } - let enhanced_width = span_width(&enhanced_right); - let gap = usize::from(enhanced_width > 0); - if minimum_left_width - .saturating_add(gap) - .saturating_add(enhanced_width) - <= available - { - right = enhanced_right; - } - } - - let right_width = span_width(&right); - let left_budget = available.saturating_sub(right_width + usize::from(right_width > 0)); - if span_width(&left) > left_budget { - // Cramped: keep the posture lockup exactly as composed and re-hang the - // chips behind it. Rebuilding the lockup by hand here is how the two - // passes used to disagree about what the header guarantees. - let mut compact_left = posture.clone(); - // The goal chip survives cramped layouts too — it is operator state, - // not decoration. The route label yields its budget first (down to - // nothing, as it always has); below that the goal itself truncates, - // and when even a minimal chip cannot fit it drops rather than - // clipping mid-word (#39). - let base_fixed = span_width(&compact_left); - if let Some((text, color)) = &goal_chip { - let goal_room = left_budget - .saturating_sub(base_fixed) - .saturating_sub(GROUP_GAP.len()); - if goal_room >= 8 { - compact_left.push(Span::raw(GROUP_GAP)); - compact_left.push(Span::styled( - truncate_to_width(text, goal_room), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - } - // The workflow chip (#5040) is operator state too, so it gets the - // goal chip's treatment: whatever room remains after the chips ahead - // of it, clean truncation, and a clean drop when even a minimal chip - // cannot fit. The route label still yields its budget first. - if let Some((text, color)) = &workflow_chip { - let workflow_room = left_budget - .saturating_sub(span_width(&compact_left)) - .saturating_sub(GROUP_GAP.len()); - if workflow_room >= 8 { - compact_left.push(Span::raw(GROUP_GAP)); - compact_left.push(Span::styled( - truncate_to_width(text, workflow_room), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - } - // The update chip (#14) gets the same treatment, last in line: it is - // useful, but it yields to every piece of operator state ahead of it. - if let Some((text, color)) = &update_chip { - let update_room = left_budget - .saturating_sub(span_width(&compact_left)) - .saturating_sub(GROUP_GAP.len()); - if update_room >= 8 { - compact_left.push(Span::raw(GROUP_GAP)); - compact_left.push(Span::styled( - truncate_to_width(text, update_room), - Style::default().fg(*color).add_modifier(Modifier::BOLD), - )); - } - } - left = compact_left; - } - let left_width = span_width(&left); - let gap = available.saturating_sub(left_width + right_width); - left.push(Span::raw(" ".repeat(gap))); - left.extend(right); - let title_area = Rect { height: 1, ..area }; - Paragraph::new(Line::from(left)).render(title_area, buf); - if area.height > 1 { - let rule_area = Rect { - y: area.y.saturating_add(1), - height: 1, - ..area - }; - Paragraph::new(Line::from(Span::styled( - "─".repeat(usize::from(area.width)), - Style::default().fg(app.ui_theme.border), - ))) - .render(rule_area, buf); - } -} - /// The transcript rows the idle brand mark needs before it will draw at all. /// /// Named so the *layout* can honour it before the frame is split. Anything that reserves rows above @@ -1831,15 +1369,9 @@ mod empty_state_caption_tests { #[cfg(test)] mod header_tests { - use super::{ - FIELD_JOIN, GROUP_GAP, filesystem_scope_notice, header_hitboxes, - render_header_with_git_status, - }; - use crate::palette::ChromeInk; + use super::filesystem_scope_notice; use crate::tui::app::{App, AppMode}; use crate::tui::approval::ApprovalMode; - use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle}; - use ratatui::{buffer::Buffer, layout::Rect}; fn app() -> App { let mut app = crate::test_support::test_app_with_options( @@ -1853,22 +1385,6 @@ mod header_tests { app } - fn header_line(app: &App, width: u16) -> String { - let area = Rect::new(0, 0, width, 1); - let mut buf = Buffer::empty(area); - render_header_with_git_status( - area, - &mut buf, - app, - &crate::tui::git_status::GitStatusSnapshot::default(), - ); - (0..width) - .map(|x| buf[(x, 0)].symbol()) - .collect::() - .trim_end() - .to_string() - } - #[test] fn default_posture_spends_no_columns_on_the_expected_scope() { // `files: workspace` used to be printed on every frame of every @@ -1876,11 +1392,6 @@ mod header_tests { // default. A notice that never turns off cannot warn. let app = app(); assert!(filesystem_scope_notice(&app).is_none()); - let line = header_line(&app, 120); - assert!(!line.contains("files:"), "{line:?}"); - assert!(line.starts_with("codewhale"), "{line:?}"); - assert!(line.contains("work"), "{line:?}"); - assert!(line.contains("ask"), "{line:?}"); } #[test] @@ -1892,15 +1403,6 @@ mod header_tests { app.approval_mode = ApprovalMode::Bypass; app.configured_sandbox_mode = Some("danger-full-access".to_string()); assert!(filesystem_scope_notice(&app).is_none()); - let line = header_line(&app, 120); - assert!(!line.contains("files:"), "{line:?}"); - assert!( - line.contains(&*super::tr( - app.ui_locale, - super::MessageId::ChipPermissionFullAccess - )), - "{line:?}" - ); } #[test] @@ -1915,8 +1417,6 @@ mod header_tests { let notice = filesystem_scope_notice(&full) .expect("Full Access must never appear without a scope beside it"); assert_eq!(notice, "files: workspace"); - let line = header_line(&full, 120); - assert!(line.contains("files: workspace"), "{line:?}"); // And the default posture still stays quiet. let mut quiet = app(); @@ -1930,97 +1430,6 @@ mod header_tests { let mut app = app(); app.mode = AppMode::Plan; assert!(filesystem_scope_notice(&app).is_none()); - let line = header_line(&app, 120); - assert!(line.contains("read only"), "{line:?}"); - assert!(!line.contains("files: read-only"), "{line:?}"); - } - - #[test] - fn the_build_version_is_not_permanent_chrome() { - // It was already `Wide`-only, which is the layout admitting it was - // never load-bearing; `codewhale --version`, `codewhale doctor` and - // the launch screen are where a version is actually looked up, and - // the half worth reading mid-session is the update chip. - let app = app(); - for width in [60u16, 80, 120, 200] { - let line = header_line(&app, width); - assert!( - !line.contains(concat!("v", env!("CODEWHALE_BUILD_VERSION"))), - "width {width}: {line:?}", - ); - } - } - - #[test] - fn chips_are_separated_from_posture_by_a_wider_gap_than_the_posture_join() { - // One weight per meaning: `" · "` binds words into one phrase, the - // group gap stands between whole facts. If a goal chip hangs off the - // same dotted separator that joins mode to permission, the header is - // an undifferentiated list again. - let mut app = app(); - app.update_available = Some("update 0.9.11".to_string()); - let line = header_line(&app, 120); - assert!( - line.contains(&format!("ask{GROUP_GAP}update 0.9.11")), - "{line:?}", - ); - assert!(line.contains(&format!("work{FIELD_JOIN}ask")), "{line:?}"); - assert!( - unicode_width::UnicodeWidthStr::width(GROUP_GAP) - > unicode_width::UnicodeWidthStr::width(FIELD_JOIN), - "the group gap must out-space the phrase join or nothing groups", - ); - } - - #[test] - fn collapsed_degraded_workflow_chip_uses_attention_ink() { - let mut app = app(); - let mut panel = WorkflowPanel::new("workflow-partial", "review release", 1_000); - panel.lifecycle = WorkflowPanelLifecycle::Degraded; - panel.expanded = false; - panel.completed_at_ms = Some(2_000); - app.workflow_panel = Some(panel); - - let width = 200; - let area = Rect::new(0, 0, width, 1); - let mut buf = Buffer::empty(area); - render_header_with_git_status( - area, - &mut buf, - &app, - &crate::tui::git_status::GitStatusSnapshot::default(), - ); - let text = (0..width).map(|x| buf[(x, 0)].symbol()).collect::(); - let start = text.find("wf degraded").expect("degraded workflow chip"); - let expected = ChromeInk::Attention.color(&app.ui_theme); - for x in start..start + "wf degraded".len() { - assert_eq!( - buf[(x as u16, 0)].fg, - expected, - "collapsed degraded chip must stay amber at column {x}: {text:?}" - ); - } - } - - #[test] - fn the_context_meter_states_its_percentage_and_registers_an_inspector_target() { - // The percentage is the direct operator question ("how full am I?"). - // Fraction remains the auditable fact and the bar is the glance. - let mut app = app(); - app.session.total_input_tokens = 3_000; - let line = header_line(&app, 120); - if line.contains('▱') || line.contains('▰') { - assert!(!line.contains('['), "{line:?}"); - assert!(line.contains("context"), "{line:?}"); - assert!(line.contains('%'), "{line:?}"); - let hitboxes = header_hitboxes(Rect::new(0, 0, 120, 1), &app); - assert_eq!(hitboxes.len(), 1); - assert_eq!(hitboxes[0].area.right(), 120); - assert_eq!( - hitboxes[0].target, - crate::tui::app::HeaderActionTarget::InspectContext - ); - } } } diff --git a/crates/tui/src/tui/views/extensions.rs b/crates/tui/src/tui/views/extensions.rs index 02762e2f04..5e40fbafdf 100644 --- a/crates/tui/src/tui/views/extensions.rs +++ b/crates/tui/src/tui/views/extensions.rs @@ -1805,17 +1805,6 @@ mod tests { assert_eq!(tr(Locale::En, recovery.label_key()).as_ref(), "re-auth"); } - #[test] - fn a_healthy_server_offers_no_recovery_action() { - // Founder live-test: "even the ones that are connected say diagnose - // lol". Enabled, inspected, connected and erroring on nothing is not - // a state anything repairs. - assert_eq!( - crate::mcp::mcp_recovery_kind(true, true, true, None, false), - None - ); - } - #[test] fn mcp_item_action_for_disconnected_server_is_reconnect() { let recovery = crate::mcp::mcp_recovery_kind(true, true, false, None, false) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 07b505890b..0c04a1e3bc 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -6936,7 +6936,9 @@ mod tests { #[test] fn subagent_view_agents_includes_live_fanout_workers_when_cache_is_empty() { let mut app = create_test_app(); - let mut card = FanoutCard::new("rlm").with_workers(["chunk_1", "chunk_2"]); + let mut card = FanoutCard::new("rlm"); + card.upsert_worker("chunk_1", AgentLifecycle::Pending); + card.upsert_worker("chunk_2", AgentLifecycle::Pending); card.upsert_worker("chunk_1", AgentLifecycle::Completed); card.upsert_worker("chunk_2", AgentLifecycle::Running); app.add_message(HistoryCell::SubAgent(SubAgentCell::Fanout(card))); diff --git a/crates/tui/src/tui/views/skills_manager.rs b/crates/tui/src/tui/views/skills_manager.rs index 371db788d7..9071ad2e40 100644 --- a/crates/tui/src/tui/views/skills_manager.rs +++ b/crates/tui/src/tui/views/skills_manager.rs @@ -856,7 +856,7 @@ mod tests { "---\nname: demo\ndescription: d\n---\nbody\n", ) .unwrap(); - let digest = crate::skills::audit::compute_package_digest(&skill_dir).unwrap(); + let digest = crate::skills::package_digest::compute_package_digest(&skill_dir).unwrap(); crate::skills::install::write_installed_from_v2( &skill_dir, "github:o/r", diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 3a6f57c97a..e36b2a2b85 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -347,20 +347,6 @@ impl FanoutCard { } } - /// Pre-seed worker slots when the fanout size is known up front. - #[allow(dead_code)] - pub fn with_workers(mut self, ids: I) -> Self - where - I: IntoIterator, - S: Into, - { - for id in ids { - self.workers - .push(WorkerSlot::new(id.into(), AgentLifecycle::Pending)); - } - self - } - /// Update or insert a worker by id. Returns whether the visible state /// changed and the card should be redrawn. pub fn upsert_worker(&mut self, agent_id: &str, status: AgentLifecycle) -> bool { @@ -862,8 +848,10 @@ mod tests { #[test] fn fanout_card_dot_grid_renders_stateful_worker_slots() { - let mut card = FanoutCard::new("fanout") - .with_workers(["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"]); + let mut card = FanoutCard::new("fanout"); + for id in ["w_1", "w_2", "w_3", "w_4", "w_5", "w_6", "w_7"] { + card.upsert_worker(id, AgentLifecycle::Pending); + } card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Running); @@ -879,7 +867,10 @@ mod tests { #[test] fn fanout_card_header_and_dot_grid_surface_aggregate_state() { - let mut card = FanoutCard::new("rlm").with_workers(["w_1", "w_2", "w_3", "w_4"]); + let mut card = FanoutCard::new("rlm"); + for id in ["w_1", "w_2", "w_3", "w_4"] { + card.upsert_worker(id, AgentLifecycle::Pending); + } card.upsert_worker("w_1", AgentLifecycle::Completed); card.upsert_worker("w_2", AgentLifecycle::Completed); card.upsert_worker("w_3", AgentLifecycle::Completed); @@ -919,7 +910,9 @@ mod tests { #[test] fn fanout_started_claims_seeded_pending_slot_without_growing_grid() { - let mut card = FanoutCard::new("fanout").with_workers(["task:a", "task:b"]); + let mut card = FanoutCard::new("fanout"); + card.upsert_worker("task:a", AgentLifecycle::Pending); + card.upsert_worker("task:b", AgentLifecycle::Pending); let started = MailboxMessage::started("agent_live", crate::tools::subagent::FleetRole::Worker); @@ -940,7 +933,8 @@ mod tests { #[test] fn fanout_apply_transitions_worker_through_lifecycle() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1"]); + let mut card = FanoutCard::new("fanout"); + card.upsert_worker("w_1", AgentLifecycle::Pending); let started = MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker); apply_to_fanout(&mut card, &started); assert_eq!(card.workers[0].status, AgentLifecycle::Running); @@ -969,7 +963,10 @@ mod tests { ]; for (total, done, expected) in cases { let ids: Vec = (0..*total).map(|i| format!("w_{i}")).collect(); - let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); + let mut card = FanoutCard::new("fanout"); + for id in ids.iter() { + card.upsert_worker(id, AgentLifecycle::Pending); + } for id in ids.iter().take(*done) { card.upsert_worker(id, AgentLifecycle::Completed); } @@ -1006,7 +1003,9 @@ mod tests { #[test] fn fanout_interrupted_worker_leaves_running_counts() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]); + let mut card = FanoutCard::new("fanout"); + card.upsert_worker("w_1", AgentLifecycle::Pending); + card.upsert_worker("w_2", AgentLifecycle::Pending); apply_to_fanout( &mut card, &MailboxMessage::started("w_1", crate::tools::subagent::FleetRole::Worker), @@ -1051,7 +1050,10 @@ mod tests { #[test] fn fanout_card_omits_redundant_counts_line_when_header_and_grid_present() { let ids: Vec = (0..16).map(|i| format!("w_{i}")).collect(); - let mut card = FanoutCard::new("fanout").with_workers(ids.iter().cloned()); + let mut card = FanoutCard::new("fanout"); + for id in ids.iter() { + card.upsert_worker(id, AgentLifecycle::Pending); + } for id in ids.iter().take(12) { card.upsert_worker(id, AgentLifecycle::Completed); } @@ -1337,7 +1339,9 @@ mod tests { #[test] fn fanout_card_does_not_project_any_workers_todo() { - let mut card = FanoutCard::new("fanout").with_workers(["w_1", "w_2"]); + let mut card = FanoutCard::new("fanout"); + card.upsert_worker("w_1", AgentLifecycle::Pending); + card.upsert_worker("w_2", AgentLifecycle::Pending); assert!(!apply_to_fanout( &mut card, &work_state( diff --git a/crates/tui/src/tui/widgets/key_hint.rs b/crates/tui/src/tui/widgets/key_hint.rs index a9017894f0..7a4b551a1e 100644 --- a/crates/tui/src/tui/widgets/key_hint.rs +++ b/crates/tui/src/tui/widgets/key_hint.rs @@ -58,9 +58,4 @@ mod tests { assert!(has_ctrl_or_alt(KeyModifiers::ALT)); assert!(!has_ctrl_or_alt(KeyModifiers::NONE)); } - - #[test] - fn alt_prefix_uses_deterministic_test_spelling() { - assert_eq!(alt_prefix(), "⌥+"); - } } diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 6fd5dbc92c..a561be602a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -6,7 +6,6 @@ mod renderable; pub mod tool_card; pub mod workflow_panel; -pub use header::header_status_indicator_frame; pub use renderable::Renderable; use std::borrow::Cow; @@ -1125,22 +1124,6 @@ fn fish_flee_offset(elapsed_ms: u128) -> u16 { crate::tui::ambient_life::fish_flee_offset(elapsed_ms) } -#[cfg(test)] -fn fish_mark(facing_right: bool) -> &'static str { - if facing_right { "><>" } else { "<><" } -} - -#[cfg(test)] -fn fish_heading(previous_x: u16, current_x: u16, next_x: u16, fallback_right: bool) -> bool { - if next_x != current_x { - next_x > current_x - } else if current_x != previous_x { - current_x > previous_x - } else { - fallback_right - } -} - fn jump_to_latest_button_rect(area: Rect, has_scrollbar: bool) -> Option { if area.width < JUMP_TO_LATEST_BUTTON_WIDTH + u16::from(has_scrollbar) || area.height < JUMP_TO_LATEST_BUTTON_HEIGHT @@ -4519,11 +4502,10 @@ mod tests { 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, + enclosed_composer_panel_fits, fish_flee_offset, 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::localization::Locale; @@ -7482,20 +7464,6 @@ mod tests { ); } - #[test] - fn fish_glyph_always_matches_screen_direction() { - assert_eq!(fish_mark(true), "><>"); - assert_eq!(fish_mark(false), "<><"); - assert!(fish_heading(8, 9, 10, false)); - assert!(!fish_heading(10, 9, 8, true)); - assert!(fish_heading(8, 9, 9, false)); - assert!(!fish_heading(10, 9, 9, true)); - - // Mirrored tracks are the regression case: a forward path flag can - // correspond to decreasing screen x. Heading follows x, not the flag. - assert!(!fish_heading(74, 73, 72, true)); - } - /// Render a chat field carrying `rows` of history and return its rows. fn history_field_rows(rows: usize) -> Vec { let mut app = create_test_app(); @@ -8888,32 +8856,6 @@ diff --git a/src/b.rs b/src/b.rs\n\ ); } - #[test] - fn ghost_text_hidden_when_no_suggestion() { - let mut app = create_test_app(); - app.prompt_suggestion = None; - let slash_menu_entries = Vec::::new(); - let mention_menu_entries = Vec::::new(); - let widget = ComposerWidget::new(&app, 5, &slash_menu_entries, &mention_menu_entries); - let area = Rect { - x: 0, - y: 0, - width: 80, - height: 5, - }; - let mut buf = Buffer::empty(area); - widget.render(area, &mut buf); - - // When no suggestion and input is empty, placeholder text should appear - // instead. The exact placeholder text is locale-dependent, so we check - // that the suggestion text is NOT present. - let has_placeholder_like_text = buf.content.iter().any(|c| !c.symbol().trim().is_empty()); - assert!( - has_placeholder_like_text, - "some non-empty text should render as placeholder" - ); - } - #[test] fn receipt_settle_cascade_is_bounded_and_ordered() { assert!(receipt_is_settling(0, 0)); diff --git a/crates/tui/src/tui/widgets/workflow_panel.rs b/crates/tui/src/tui/widgets/workflow_panel.rs index 219667d65f..eafcaf4a28 100644 --- a/crates/tui/src/tui/widgets/workflow_panel.rs +++ b/crates/tui/src/tui/widgets/workflow_panel.rs @@ -967,24 +967,6 @@ impl WorkflowPanel { truncate_line_to_width(&raw, width.max(1)) } - /// One-chip summary for the top status bar (#5040): lifecycle, - /// done/total children, failures, elapsed. Intentionally terse — the - /// expanded panel and history card carry the detail. - #[must_use] - pub fn top_bar_chip(&self) -> String { - let (done, total) = self.done_total(); - let (failed, _cancelled) = self.failure_cancel_counts(); - let mut chip = format!( - "wf {} {done}/{total}", - self.lifecycle.display_label(self.locale) - ); - if failed > 0 { - chip.push_str(&format!(" · {failed} fail")); - } - chip.push_str(&format!(" · {}", self.elapsed_label())); - chip - } - /// Elapsed label shared with direct sub-agent cards. #[must_use] pub fn elapsed_label(&self) -> String { diff --git a/crates/tui/src/tui/work_surface/model.rs b/crates/tui/src/tui/work_surface/model.rs index 17ffd124b8..33c02ccb31 100644 --- a/crates/tui/src/tui/work_surface/model.rs +++ b/crates/tui/src/tui/work_surface/model.rs @@ -381,12 +381,6 @@ pub struct WorkSurfaceState { pub(crate) session_owner_probe_dir: Option, } -impl Default for WorkSurfaceState { - fn default() -> Self { - Self::with_placement(WorkSurfacePlacement::Bottom) - } -} - impl WorkSurfaceState { #[must_use] pub(crate) fn is_resizing(&self) -> bool { @@ -400,11 +394,6 @@ impl WorkSurfaceState { self.effective_placement } - #[must_use] - pub fn with_placement(placement: WorkSurfacePlacement) -> Self { - Self::with_layout(placement, 3, 30) - } - #[must_use] pub fn with_layout(placement: WorkSurfacePlacement, top_height: u16, side_width: u16) -> Self { Self { @@ -2790,7 +2779,7 @@ mod tests { } fn surface() -> WorkSurfaceState { - WorkSurfaceState::default() + WorkSurfaceState::with_layout(WorkSurfacePlacement::Bottom, 3, 30) } fn operation(state: NodeState, suffix: &str) -> WorkNode { diff --git a/crates/tui/tests/README.md b/crates/tui/tests/README.md index 17a76179aa..bb46d5f69d 100644 --- a/crates/tui/tests/README.md +++ b/crates/tui/tests/README.md @@ -16,8 +16,8 @@ the `codewhale-tui` bin unit tests): | harness | binary | what lives there | why it stays separate | |---|---|---|---| -| `tests/integration/main.rs` | `integration` | 17 plain `#[test]`/`#[tokio::test]` suites: `adaptive_evidence_acceptance`, `cache_guard`, `coordination_acceptance`, `diagnostic_read_only`, `dotenv_authority`, `eval_harness`, `exec_persistent_service`, `exec_stream_drop_acceptance`, `exec_turn_usage`, `integration_mock_llm`, `palette_audit`, `protocol_recovery`, `reasoning_content_replayed_after_tool_call`, `skill_cli`, `telemetry_contract`, `verifiers_harness_contract`, `workflow_tool_stream_acceptance` | All are process-level but require no PTY or Gherkin runner; they share `wiremock`/`tempfile` and link the TUI once instead of 17 times. `crate::` for `eval`/`models`/`llm_client`/`palette`/`network_policy`/`config`/`install` is satisfied by `integration/main.rs` re-exporting those `#[path]` modules at the harness crate root so `crate::config` etc. resolve. | -| `tests/cucumber/main.rs` | `cucumber` | 6 Gherkin runners: `core_session_command_extraction`, `directory_listing_acceptance`, `epic_acceptance_harness`, `eval_smoke_acceptance`, `plugin_e2e_acceptance`, `tool_lifecycle_acceptance` | Each defines a distinct `cucumber::World`; steps are registered per-World via inventory, so merging is safe and cuts 6 `cucumber` link jobs to 1. `plugin_e2e`’s PTY part is `#[cfg(all(unix, feature="long-running-tests"))]` and stays dormant in the default run. | +| `tests/integration/main.rs` | `integration` | 15 plain `#[test]`/`#[tokio::test]` suites: `adaptive_evidence_acceptance`, `diagnostic_read_only`, `dotenv_authority`, `eval_harness`, `exec_persistent_service`, `exec_stream_drop_acceptance`, `exec_turn_usage`, `integration_mock_llm`, `lifecycle_outbox_exec`, `palette_audit`, `protocol_recovery`, `skill_cli`, `telemetry_contract`, `verifiers_harness_contract`, `workflow_tool_stream_acceptance` | All are process-level but require no PTY or Gherkin runner; they share `wiremock`/`tempfile` and link the TUI once instead of 15 times. `crate::` for `eval`/`models`/`llm_client`/`palette`/`network_policy`/`config`/`install` is satisfied by `integration/main.rs` re-exporting those `#[path]` modules at the harness crate root so `crate::config` etc. resolve. | +| `tests/cucumber/main.rs` | `cucumber` | 5 Gherkin runners: `core_session_command_extraction`, `directory_listing_acceptance`, `epic_acceptance_harness`, `eval_smoke_acceptance`, `tool_lifecycle_acceptance` | Each defines a distinct `cucumber::World`; steps are registered per-World via inventory, so merging is safe and cuts the `cucumber` link jobs. `plugin_e2e_acceptance` is no longer a Gherkin runner: its production-mirror scanner World/steps were removed with the worthless-test sweep (the real `scan_plugin_dir` contract stays covered by unit tests in `src/tools/plugin.rs`), leaving the plain binary smoke + `#[cfg(all(unix, feature="long-running-tests"))]` PTY acceptance, dormant in the default run. | The former `tests/pty` harness was removed. It accumulated full-screen copy, color, timing, and geometry assertions that were expensive to link and made the implementation serve a simulated terminal. Visible UX is now accepted against @@ -48,14 +48,17 @@ runtime is meant to depend on. Coverage today exercises the trait surface end-to-end: - streaming turn loop -- reasoning-content replay across tool-call rounds (V4 §5.1.1, the bug that - broke v0.4.9-v0.5.1) - tool-call round-trip with chunked input JSON - multi-tool-call ordering inside a single turn - compaction-style non-streaming `create_message` - sub-agent style independent parent/child mocks - capacity-gate observation of a captured request before stream drain +Reasoning-content replay across tool-call rounds (V4 §5.1.1, the bug that +broke v0.4.9-v0.5.1) is pinned by the engine tests +(`multi_block_and_redacted_thinking_persist_into_followup_requests`), which +drive a real engine and assert on the production-composed follow-up request. + Full-engine journeys use `Engine::new_with_model_client` and the same mock. A non-trivial model/protocol/user-visible behavior change needs one keyless assembled journey at the nearest real entry path. Prefer semantic assertions diff --git a/crates/tui/tests/cucumber/main.rs b/crates/tui/tests/cucumber/main.rs index 12f1ccc48c..9d36b221b1 100644 --- a/crates/tui/tests/cucumber/main.rs +++ b/crates/tui/tests/cucumber/main.rs @@ -1,9 +1,9 @@ //! Consolidated harness for Gherkin/Cucumber runners. //! //! Cucumber (`cucumber = "0.23"`) registers steps per `World` type via -//! inventory. Each `World` is scoped to its own `mod`, so merging the six +//! inventory. Each `World` is scoped to its own `mod`, so merging the //! feature runners into one binary keeps `cargo test -p codewhale-tui -//! tool_lifecycle` etc. filtering via the module path while cutting six link +//! tool_lifecycle` etc. filtering via the module path while cutting link //! jobs to one. See `crates/tui/tests/README.md`. #[cfg(all(unix, feature = "long-running-tests"))] diff --git a/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs b/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs index c47455e3d7..012e420351 100644 --- a/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs +++ b/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs @@ -1,10 +1,6 @@ //! Plugin acceptance at both compatibility and v0.9.1 bundle boundaries. //! -//! Tests the plugin frontmatter scanner end-to-end from the binary level: -//! - Scripts with valid `# name:` frontmatter are discovered -//! - Approval levels (auto, suggest, required) are parsed correctly -//! - Hidden files and README.md are ignored -//! - Empty and missing directories are handled gracefully +//! Exercises the plugin surface from the binary level: //! - The distributed binary still loads after the plugin module migration //! - A sealed real PTY exercises plugin.toml review/trust/enable/revoke, //! reviewed Skill dispatch, and hermetic reviewed stdio MCP execution @@ -12,9 +8,6 @@ use std::path::PathBuf; use std::process::Command; -use cucumber::{World as _, given, then, when, writer::Stats as _}; -use tempfile::TempDir; - #[cfg(all(unix, feature = "long-running-tests"))] use super::qa_harness; @@ -23,340 +16,6 @@ use qa_harness::harness::{Harness, make_sealed_workspace}; #[cfg(all(unix, feature = "long-running-tests"))] use qa_harness::keys; -const FEATURE_NAME: &str = "Plugin discovery and listing"; -const FEATURE_PATH: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/features/plugin_e2e_acceptance.feature" -); -const DISCOVERY_SCENARIO: &str = - "Plugin scripts are discovered from the configured plugin directory"; -const EMPTY_SCENARIO: &str = "Empty plugin directory reports no plugins"; -const MISSING_SCENARIO: &str = "Missing plugin directory reports the path"; - -// --------------------------------------------------------------------------- -// Test-local plugin scanner -// -// Mirrors the real `scan_plugin_dir` from `crates/tui/src/tools/plugin.rs` -// so the test can run as a standalone integration test without relying on -// `#[path]` (which breaks on internal `crate::` and `super::` imports). -// The contract (frontmatter format, skip rules) matches exactly. -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, PartialEq)] -struct TestPluginMeta { - name: String, - description: String, - approval: TestApproval, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -enum TestApproval { - Auto, - Suggest, - Required, -} - -fn parse_frontmatter(content: &str) -> Option { - let mut name = String::new(); - let mut description = String::new(); - let mut approval_str = String::new(); - - for line in content.lines().take(20) { - let line = line.trim(); - let rest = line - .strip_prefix('#') - .or_else(|| line.strip_prefix("//")) - .or_else(|| line.strip_prefix("--")); - let Some(rest) = rest else { continue }; - let Some((key, value)) = rest.trim_start().split_once(':') else { - continue; - }; - match key.trim().to_lowercase().as_str() { - "name" => name = value.trim().to_string(), - "description" => description = value.trim().to_string(), - "approval" => approval_str = value.trim().to_string(), - _ => {} - } - } - - if name.is_empty() { - return None; - } - - let approval = match approval_str.to_lowercase().as_str() { - "auto" => TestApproval::Auto, - "required" => TestApproval::Required, - _ => TestApproval::Suggest, - }; - - Some(TestPluginMeta { - name, - description: if description.is_empty() { - "User-provided plugin tool".to_string() - } else { - description - }, - approval, - }) -} - -fn scan_plugin_dir(dir: &std::path::Path) -> Vec<(PathBuf, TestPluginMeta)> { - let mut results = Vec::new(); - - let entries = match std::fs::read_dir(dir) { - Ok(entries) => entries, - Err(_) => return results, - }; - - let mut entries: Vec<_> = entries.flatten().collect(); - entries.sort_by_key(|entry| entry.file_name()); - - for entry in entries { - let path = entry.path(); - - if path.is_dir() { - continue; - } - - if let Some(name) = path.file_name().and_then(|n| n.to_str()) - && (name.starts_with('.') || name == "README.md") - { - continue; - } - - if let Ok(content) = std::fs::read_to_string(&path) - && let Some(meta) = parse_frontmatter(&content) - { - results.push((path, meta)); - } - } - - results -} - -// --------------------------------------------------------------------------- -// Cucumber world -// --------------------------------------------------------------------------- - -#[derive(Debug, Default, cucumber::World)] -struct PluginE2EWorld { - /// TempDir holding the plugin directory. We keep a second TempDir as - /// the "workspace" so the plugin dir path stays valid after move. - _workspace: Option, - plugin_dir: Option, - discovered: Option>, - scanner_message: Option, -} - -// --------------------------------------------------------------------------- -// Given steps -// --------------------------------------------------------------------------- - -#[given("an offline CodeWhale workspace with a configured plugin directory")] -fn offline_workspace_with_plugin_dir(world: &mut PluginE2EWorld) { - let workspace = TempDir::new().expect("workspace tempdir"); - let plugin_dir = TempDir::new().expect("plugin tempdir"); - world._workspace = Some(workspace); - world.plugin_dir = Some(plugin_dir); -} - -#[given(regex = r"^the plugin directory contains:$")] -fn plugin_directory_contains(world: &mut PluginE2EWorld, step: &cucumber::gherkin::Step) { - let dir = world - .plugin_dir - .as_ref() - .expect("plugin directory should be configured"); - - let table = step - .table - .as_ref() - .expect("step should include a data table"); - let mut rows = table.rows.iter(); - let headers = rows.next().expect("data table should include a header"); - let name_idx = headers - .iter() - .position(|h| h == "name") - .expect("data table should have a 'name' column"); - let desc_idx = headers - .iter() - .position(|h| h == "description") - .expect("data table should have a 'description' column"); - let approval_idx = headers - .iter() - .position(|h| h == "approval") - .expect("data table should have an 'approval' column"); - - for row in rows { - let name = row.get(name_idx).expect("plugin name"); - let description = row.get(desc_idx).expect("plugin description"); - let approval = row.get(approval_idx).expect("plugin approval"); - - let script_path = dir.path().join(format!("{name}.sh")); - let script_content = format!( - "# name: {name}\n\ - # description: {description}\n\ - # approval: {approval}\n\ - # schema: {{\"type\":\"object\"}}\n\ - echo hello\n" - ); - std::fs::write(&script_path, &script_content) - .unwrap_or_else(|e| panic!("write plugin script {name}.sh: {e}")); - - // Make executable on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) - .unwrap_or_else(|e| panic!("chmod {name}.sh: {e}")); - } - } - - // Write a README.md and a hidden file that should be ignored - std::fs::write(dir.path().join("README.md"), "# Plugin Docs\n").expect("write README.md"); - std::fs::write( - dir.path().join(".hidden_script.sh"), - "# name: hidden\n# description: Should not appear\n", - ) - .expect("write hidden"); -} - -#[given("the plugin directory is empty")] -fn plugin_directory_empty(world: &mut PluginE2EWorld) { - // Replace with a fresh empty directory - let dir = TempDir::new().expect("empty plugin tempdir"); - world.plugin_dir = Some(dir); -} - -#[given("the plugin directory does not exist")] -fn plugin_directory_does_not_exist(world: &mut PluginE2EWorld) { - let base = TempDir::new().expect("base tempdir for non-existent path"); - let non_existent = base.path().join("nonexistent"); - // Ensure it truly doesn't exist - let _ = std::fs::remove_dir_all(&non_existent); - // Store the base so the path stays valid for the lifetime of the test - world._workspace = Some(base); - // Remove the previous plugin_dir so scanning uses the path deliberately - world.plugin_dir = None; - world.scanner_message = Some(format!( - "No plugin directory found at {}", - non_existent.display() - )); -} - -// --------------------------------------------------------------------------- -// When steps -// --------------------------------------------------------------------------- - -#[when("the plugin scanner discovers plugins")] -fn plugin_scanner_discovers_plugins(world: &mut PluginE2EWorld) { - let dir = world - .plugin_dir - .as_ref() - .expect("plugin directory should be configured"); - let discovered = scan_plugin_dir(dir.path()); - world.discovered = Some(discovered); -} - -#[when("the plugin scanner runs")] -fn plugin_scanner_runs(world: &mut PluginE2EWorld) { - // Use the stored non-existent path - let msg = world - .scanner_message - .as_ref() - .expect("missing path message"); - // Extract the path from the message - let path_str = msg - .strip_prefix("No plugin directory found at ") - .expect("message format"); - let path = std::path::Path::new(path_str); - let discovered = scan_plugin_dir(path); - world.discovered = Some(discovered); -} - -// --------------------------------------------------------------------------- -// Then steps -// --------------------------------------------------------------------------- - -#[then(regex = r"^the scanner should report (\d+) plugins?$")] -fn scanner_should_report_n_plugins(world: &mut PluginE2EWorld, expected_count: usize) { - let discovered = world.discovered.as_ref().expect("scanner should have run"); - assert_eq!( - discovered.len(), - expected_count, - "expected {expected_count} plugins, found {}: {discovered:#?}", - discovered.len() - ); -} - -#[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as description$"#)] -fn scanned_plugin_should_have_description( - world: &mut PluginE2EWorld, - name: String, - expected_description: String, -) { - let discovered = world.discovered.as_ref().expect("scanner should have run"); - let meta = discovered - .iter() - .find(|(_, m)| m.name == name) - .map(|(_, m)| m) - .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); - - assert_eq!( - meta.description, expected_description, - "plugin \"{name}\" description mismatch" - ); -} - -#[then(regex = r#"^the scanned plugin "([^"]+)" should have "([^"]+)" as approval$"#)] -fn scanned_plugin_should_have_approval( - world: &mut PluginE2EWorld, - name: String, - expected_approval: String, -) { - let discovered = world.discovered.as_ref().expect("scanner should have run"); - let meta = discovered - .iter() - .find(|(_, m)| m.name == name) - .map(|(_, m)| m) - .unwrap_or_else(|| panic!("plugin \"{name}\" not found in scan results")); - - let actual = match meta.approval { - TestApproval::Auto => "auto", - TestApproval::Suggest => "suggest", - TestApproval::Required => "required", - }; - assert_eq!( - actual, expected_approval, - "plugin \"{name}\" approval mismatch" - ); -} - -#[then(regex = r#"^the scanned plugin "([^"]+)" should not be found$"#)] -fn scanned_plugin_should_not_be_found(world: &mut PluginE2EWorld, name: String) { - let discovered = world.discovered.as_ref().expect("scanner should have run"); - assert!( - !discovered.iter().any(|(_, m)| m.name == name), - "plugin \"{name}\" should not be present in scan results, but was found" - ); -} - -#[then("the scanner should report the missing directory path")] -fn scanner_should_report_missing_path(world: &mut PluginE2EWorld) { - let discovered = world.discovered.as_ref().expect("scanner should have run"); - assert!( - discovered.is_empty(), - "expected empty results for missing directory, got: {discovered:#?}" - ); - let msg = world - .scanner_message - .as_deref() - .unwrap_or("scanner ran without message"); - assert!( - msg.contains("No plugin directory found"), - "expected missing directory message, got: {msg}" - ); -} - // --------------------------------------------------------------------------- // Binary smoke test // --------------------------------------------------------------------------- @@ -1124,42 +783,6 @@ fn hermetic_model_server_answers_http11_keepalive_post() { let _ = handle.join(); } -// --------------------------------------------------------------------------- -// Scenario runners -// --------------------------------------------------------------------------- - -#[tokio::test(flavor = "current_thread")] -async fn plugin_discovery_happy_path() { - run_scenario(DISCOVERY_SCENARIO, 9).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn plugin_discovery_empty_directory() { - run_scenario(EMPTY_SCENARIO, 4).await; -} - -#[tokio::test(flavor = "current_thread")] -async fn plugin_discovery_missing_directory() { - run_scenario(MISSING_SCENARIO, 4).await; -} - -async fn run_scenario(name: &'static str, expected_steps: usize) { - let writer = PluginE2EWorld::cucumber() - .fail_on_skipped() - .with_default_cli() - .filter_run(FEATURE_PATH, move |feature, _, scenario| { - feature.name == FEATURE_NAME && scenario.name == name - }) - .await; - assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}"); - assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}"); - assert_eq!( - writer.passed_steps(), - expected_steps, - "scenario did not run: {name}" - ); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/crates/tui/tests/features/plugin_e2e_acceptance.feature b/crates/tui/tests/features/plugin_e2e_acceptance.feature deleted file mode 100644 index d06e18c8c9..0000000000 --- a/crates/tui/tests/features/plugin_e2e_acceptance.feature +++ /dev/null @@ -1,34 +0,0 @@ -@long-running -# [LONG RUNNING] Plugin discovery and listing acceptance tests. Run with: -# cargo test -p codewhale-tui --test plugin_e2e_acceptance --features long-running-tests -- --test-threads=1 -# The same integration target also drives the real distributed binary through a -# sealed PTY for plugin.toml show/trust/enable/revoke, reviewed Skill dispatch, -# and reviewed stdio MCP startup/call/cancellation. -Feature: Plugin discovery and listing - - Scenario: Plugin scripts are discovered from the configured plugin directory - Given an offline CodeWhale workspace with a configured plugin directory - And the plugin directory contains: - | name | description | approval | - | greet | Say hello to the user | auto | - | audit | Run a security audit | required | - | summarizer | Summarize the given input | suggest | - When the plugin scanner discovers plugins - Then the scanner should report 3 plugins - And the scanned plugin "greet" should have "Say hello to the user" as description - And the scanned plugin "greet" should have "auto" as approval - And the scanned plugin "audit" should have "required" as approval - And the scanned plugin "summarizer" should have "suggest" as approval - And the scanned plugin "missing-plugin" should not be found - - Scenario: Empty plugin directory reports no plugins - Given an offline CodeWhale workspace with a configured plugin directory - And the plugin directory is empty - When the plugin scanner discovers plugins - Then the scanner should report 0 plugins - - Scenario: Missing plugin directory reports the path - Given an offline CodeWhale workspace with a configured plugin directory - And the plugin directory does not exist - When the plugin scanner runs - Then the scanner should report the missing directory path diff --git a/crates/tui/tests/integration/cache_guard.rs b/crates/tui/tests/integration/cache_guard.rs deleted file mode 100644 index 0be7b20240..0000000000 --- a/crates/tui/tests/integration/cache_guard.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! Cache Guard CI test: verifies prefix-cache stability across multi-turn conversations. -//! -//! Runs 8 test cases × 14-24 turns each, checking that the tail average -//! hit rate stays above a configurable threshold (default 40%). -//! -//! Environment variables: -//! CODEWHALE_CACHE_GUARD=1 Enable the guard (default: disabled) -//! CODEWHALE_CACHE_GUARD_THRESHOLD=90 Hit rate threshold (0-100) -//! CODEWHALE_CACHE_GUARD_STRICT=1 Fail on threshold violation (default: warn) -//! -//! Usage: -//! CODEWHALE_CACHE_GUARD=1 cargo test --test cache_guard -//! CODEWHALE_CACHE_GUARD=1 CODEWHALE_CACHE_GUARD_STRICT=1 cargo test --test cache_guard - -// No external dependencies needed for the mock. - -// === Configuration === - -const DEFAULT_THRESHOLD: f64 = 40.0; -const ENABLED_ENV: &str = "CODEWHALE_CACHE_GUARD"; -const THRESHOLD_ENV: &str = "CODEWHALE_CACHE_GUARD_THRESHOLD"; -const STRICT_ENV: &str = "CODEWHALE_CACHE_GUARD_STRICT"; - -fn guard_enabled() -> bool { - std::env::var(ENABLED_ENV) - .map(|v| v == "1" || v == "true") - .unwrap_or(false) -} - -fn threshold() -> f64 { - std::env::var(THRESHOLD_ENV) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(DEFAULT_THRESHOLD) -} - -fn strict() -> bool { - std::env::var(STRICT_ENV) - .map(|v| v == "1" || v == "true") - .unwrap_or(false) -} - -// === Mock Prefix Cache === - -/// Simulates DeepSeek's server-side prefix cache behavior. -/// -/// The cache works on byte-prefix matching: if the first N bytes of the -/// current request match the first N bytes of the previous request, those -/// N bytes are counted as cache hits. -struct MockPrefixCache { - previous_body: Vec, - total_input_bytes: u64, - hit_bytes: u64, - per_turn_hit_rates: Vec, -} - -impl MockPrefixCache { - fn new() -> Self { - Self { - previous_body: Vec::new(), - total_input_bytes: 0, - hit_bytes: 0, - per_turn_hit_rates: Vec::new(), - } - } - - /// Submit a request body and compute cache hit/miss for this turn. - fn submit(&mut self, body: &[u8]) { - let common_prefix = body - .iter() - .zip(self.previous_body.iter()) - .take_while(|(a, b)| a == b) - .count(); - - let body_len = body.len() as u64; - self.total_input_bytes += body_len; - self.hit_bytes += common_prefix as u64; - - let hit_rate = if body_len > 0 { - common_prefix as f64 / body_len as f64 - } else { - 1.0 - }; - self.per_turn_hit_rates.push(hit_rate); - - self.previous_body = body.to_vec(); - } - - /// Compute the average hit rate over the last N turns. - fn tail_avg(&self, n: usize) -> f64 { - let start = self.per_turn_hit_rates.len().saturating_sub(n); - let tail = &self.per_turn_hit_rates[start..]; - if tail.is_empty() { - 0.0 - } else { - tail.iter().sum::() / tail.len() as f64 - } - } - - /// Overall hit rate across all turns. - fn overall_hit_rate(&self) -> f64 { - if self.total_input_bytes == 0 { - 0.0 - } else { - self.hit_bytes as f64 / self.total_input_bytes as f64 - } - } -} - -// === Test Case Generators === - -/// Generate a simulated request body for a plain dialogue turn. -fn plain_dialogue_body(turn: usize, with_reasoning: bool) -> Vec { - let system = "You are a helpful assistant. Answer concisely and accurately."; - let reasoning_prefix = if with_reasoning { - "[reasoning: analyzing the user's question carefully...]" - } else { - "" - }; - let user_msg = format!("User message turn {turn} — please respond to this query."); - let body = - format!("{system}{reasoning_prefix}\n\nConversation history:\n{user_msg}\nAssistant:"); - body.into_bytes() -} - -/// Generate a simulated request body for a tool-loop turn. -fn tool_loop_body(turn: usize, with_reasoning: bool) -> Vec { - let system = "You are a helpful assistant with tool access."; - let reasoning_prefix = if with_reasoning { - "[reasoning: deciding which tool to use...]" - } else { - "" - }; - let tool_name = if turn.is_multiple_of(2) { - "read_file" - } else { - "write_file" - }; - let tool_args = format!(r#"{{"path": "/tmp/file_{turn}.txt"}}"#); - let user_msg = format!("User request turn {turn}"); - let body = format!( - "{system}{reasoning_prefix}\n\nTools: read_file, write_file, exec_shell\n\ - User: {user_msg}\nAssistant: I'll use {tool_name}({tool_args})\nResult: success\nAssistant:" - ); - body.into_bytes() -} - -/// Generate a simulated request body with mixed sizes. -fn mixed_size_body(turn: usize) -> Vec { - let system = "You are a helpful assistant."; - let user_msg = match turn % 4 { - 0 => format!("Short question {turn}"), - 1 => format!( - "Medium length question {turn} with some additional context about the problem we're solving." - ), - 2 => { - let long_context = "Lorem ipsum dolor sit amet. ".repeat(20); - format!("Long question {turn} with extensive context: {long_context}") - } - _ => format!("Question {turn}"), - }; - let body = format!("{system}\n\nUser: {user_msg}\nAssistant:"); - body.into_bytes() -} - -// === Test Runner === - -struct CaseResult { - name: String, - tail_avg: f64, - overall: f64, - turns: usize, - passed: bool, -} - -fn run_case( - name: &str, - turns: usize, - with_reasoning: bool, - tool_loop: bool, - mixed_sizes: bool, -) -> CaseResult { - let mut cache = MockPrefixCache::new(); - - for turn in 0..turns { - let body = if mixed_sizes { - mixed_size_body(turn) - } else if tool_loop { - tool_loop_body(turn, with_reasoning) - } else { - plain_dialogue_body(turn, with_reasoning) - }; - cache.submit(&body); - } - - let tail_avg = cache.tail_avg(5) * 100.0; - let overall = cache.overall_hit_rate() * 100.0; - let thresh = threshold(); - let passed = tail_avg >= thresh; - - CaseResult { - name: name.to_string(), - tail_avg, - overall, - turns, - passed, - } -} - -// === 8 Test Cases === - -#[test] -fn case_plain_dialogue() { - if !guard_enabled() { - return; - } - let result = run_case("plain-dialogue", 14, true, false, false); - report_and_assert(&result); -} - -#[test] -fn case_plain_dialogue_no_reasoning() { - if !guard_enabled() { - return; - } - let result = run_case("plain-dialogue-no-reasoning", 14, false, false, false); - report_and_assert(&result); -} - -#[test] -fn case_long_dialogue() { - if !guard_enabled() { - return; - } - let result = run_case("long-dialogue", 18, true, false, false); - report_and_assert(&result); -} - -#[test] -fn case_mixed_message_sizes() { - if !guard_enabled() { - return; - } - let result = run_case("mixed-message-sizes", 20, true, false, true); - report_and_assert(&result); -} - -#[test] -fn case_tool_loop() { - if !guard_enabled() { - return; - } - let result = run_case("tool-loop", 14, true, true, false); - report_and_assert(&result); -} - -#[test] -fn case_tool_loop_no_reasoning() { - if !guard_enabled() { - return; - } - let result = run_case("tool-loop-no-reasoning", 14, false, true, false); - report_and_assert(&result); -} - -#[test] -fn case_long_tool_loop() { - if !guard_enabled() { - return; - } - let result = run_case("long-tool-loop", 24, true, true, false); - report_and_assert(&result); -} - -#[test] -fn case_long_tool_loop_no_reasoning() { - if !guard_enabled() { - return; - } - let result = run_case("long-tool-loop-no-reasoning", 24, false, true, false); - report_and_assert(&result); -} - -// === Hard Error Guard === - -#[test] -fn compaction_must_cause_at_least_one_miss() { - if !guard_enabled() { - return; - } - - let mut cache = MockPrefixCache::new(); - let system = "You are a helpful assistant with a very long system prompt that gets compacted."; - - // Simulate 30 turns where compaction happens around turn 20. - // After compaction, the system prompt changes significantly. - for turn in 0..30 { - let body = if turn < 20 { - format!("{system}\n\nUser: turn {turn}\nAssistant:") - } else { - // Post-compaction: system prompt is truncated/changed. - format!("You are a helpful assistant.\n\nUser: turn {turn}\nAssistant:") - }; - cache.submit(body.as_bytes()); - } - - // After compaction, there should be at least one significant miss. - // The threshold is relaxed because our mock doesn't perfectly simulate - // DeepSeek's radix-tree prefix cache. - let post_compaction_rates: Vec = cache.per_turn_hit_rates[20..].to_vec(); - let has_significant_miss = post_compaction_rates.iter().any(|&r| r < 0.8); - - if strict() { - assert!( - has_significant_miss, - "Compaction should cause at least one cache miss below 50%" - ); - } else if !has_significant_miss { - eprintln!("[WARN] compaction_must_cause_at_least_one_miss: no significant miss detected"); - } -} - -// === Helpers === - -fn report_and_assert(result: &CaseResult) { - let thresh = threshold(); - if result.passed { - eprintln!( - "[OK] {}: tail_avg={:.1}% (overall={:.1}%, {} turns)", - result.name, result.tail_avg, result.overall, result.turns - ); - } else { - eprintln!( - "[WARN] {}: tail_avg={:.1}% < threshold={:.1}% (overall={:.1}%, {} turns)", - result.name, result.tail_avg, thresh, result.overall, result.turns - ); - if strict() { - panic!( - "[STRICT] {} failed: tail_avg={:.1}% < threshold={:.1}%", - result.name, result.tail_avg, thresh - ); - } - } -} diff --git a/crates/tui/tests/integration/coordination_acceptance.rs b/crates/tui/tests/integration/coordination_acceptance.rs deleted file mode 100644 index ccb4f34c34..0000000000 --- a/crates/tui/tests/integration/coordination_acceptance.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Process-boundary acceptance for delegated coordination (#4647). -//! -//! This target retains the real-Git fan-in proof: terminal reconciliation must -//! keep both candidates available instead of reducing them to source strings. -//! Visible focus and composer behavior is accepted in the actual terminal. - -use std::path::Path; -use std::process::Command; - -use tempfile::tempdir; - -#[test] -fn terminal_retry_fixture_preserves_both_real_git_candidates() { - let repo = tempdir().expect("temp repo"); - git(repo.path(), &["init"]); - git(repo.path(), &["config", "core.autocrlf", "false"]); - git(repo.path(), &["config", "user.name", "codewhale Tests"]); - git(repo.path(), &["config", "user.email", "tests@example.com"]); - git(repo.path(), &["config", "commit.gpgsign", "false"]); - git(repo.path(), &["commit", "--allow-empty", "-m", "base"]); - let base = git_stdout(repo.path(), &["branch", "--show-current"]); - - git(repo.path(), &["switch", "-c", "candidate-a"]); - std::fs::create_dir_all(repo.path().join("src")).expect("src"); - std::fs::write(repo.path().join("src/a.rs"), "pub const A: u8 = 1;\n").expect("candidate A"); - git(repo.path(), &["add", "src/a.rs"]); - git(repo.path(), &["commit", "-m", "candidate A"]); - let candidate_a = git_stdout(repo.path(), &["rev-parse", "HEAD"]); - - git(repo.path(), &["switch", &base]); - git(repo.path(), &["switch", "-c", "candidate-b"]); - std::fs::create_dir_all(repo.path().join("src")).expect("src"); - std::fs::write(repo.path().join("src/b.rs"), "pub const B: u8 = 2;\n").expect("candidate B"); - git(repo.path(), &["add", "src/b.rs"]); - git(repo.path(), &["commit", "-m", "candidate B"]); - let candidate_b = git_stdout(repo.path(), &["rev-parse", "HEAD"]); - - assert_ne!(candidate_a, candidate_b); - assert_eq!( - git_stdout(repo.path(), &["show", "candidate-a:src/a.rs"]), - "pub const A: u8 = 1;" - ); - assert_eq!( - git_stdout(repo.path(), &["show", "candidate-b:src/b.rs"]), - "pub const B: u8 = 2;" - ); -} - -fn git(repo: &Path, args: &[&str]) { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git command"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); -} - -fn git_stdout(repo: &Path, args: &[&str]) -> String { - let output = Command::new("git") - .args(args) - .current_dir(repo) - .output() - .expect("git command"); - assert!( - output.status.success(), - "git {args:?} failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8_lossy(&output.stdout).trim().to_string() -} diff --git a/crates/tui/tests/integration/integration_mock_llm.rs b/crates/tui/tests/integration/integration_mock_llm.rs index ce55aec158..504c8e61d2 100644 --- a/crates/tui/tests/integration/integration_mock_llm.rs +++ b/crates/tui/tests/integration/integration_mock_llm.rs @@ -69,25 +69,6 @@ fn user_message(text: &str) -> Message { } } -fn assistant_thinking(thinking: &str, text: &str) -> Message { - Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Thinking { - thinking: thinking.to_string(), - signature: None, - state: None, - redacted_data: None, - reasoning_details: None, - }, - ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }, - ], - } -} - fn assistant_tool_call(id: &str, name: &str, input: serde_json::Value) -> Message { Message { role: Role::Assistant, @@ -182,82 +163,7 @@ async fn full_turn_loop_streams_text_chunks() { assert_eq!(mock.captured_requests().len(), 1); } -// === 2. Reasoning replay (V4 thinking-mode HTTP-400 regression) ============= - -#[tokio::test] -async fn reasoning_replay_required_on_subsequent_turn() { - // Turn 1: assistant emits thinking + tool_call. Turn 2: text reply. - let turn1 = vec![ - canned::message_start("r1"), - canned::thinking_delta(0, "I should call list_dir."), - canned::tool_use_block_start(1, "call_a", "list_dir"), - canned::tool_input_delta(1, r#"{"path":"/tmp"}"#), - canned::block_stop(1), - canned::message_delta("tool_use", None), - canned::message_stop(), - ]; - let turn2 = vec![ - canned::message_start("r2"), - canned::text_block_start(0), - canned::text_delta(0, "I see /tmp."), - canned::block_stop(0), - canned::message_delta("end_turn", None), - canned::message_stop(), - ]; - let mock = MockLlmClient::new(vec![turn1, turn2]); - - // === Round 1: user prompt -> assistant tool_call === - let req1 = make_request(vec![user_message("list /tmp")]); - let _ = mock.create_message_stream(req1).await.unwrap().next().await; - // (we don't drain — capture is what matters here) - - // === Round 2: runtime composes the next request including the prior - // assistant turn's reasoning_content. The mock can verify that any - // ContentBlock::Thinking the runtime preserves is present in the next - // outgoing request — the very payload shape that broke v0.4.9-v0.5.1. - let next_messages = vec![ - user_message("list /tmp"), - assistant_thinking("I should call list_dir.", ""), - assistant_tool_call("call_a", "list_dir", serde_json::json!({ "path": "/tmp" })), - tool_result_message("call_a", "/tmp/file1\n/tmp/file2"), - ]; - let req2 = make_request(next_messages); - let _ = mock.create_message_stream(req2).await.unwrap(); - - // The mock captured both requests. Assert the SECOND request preserves - // the prior assistant message's Thinking block — i.e. the runtime did - // not strip reasoning_content before re-sending. (V4 thinking-mode tool - // turns reject HTTP 400 if reasoning_content is missing.) - let captured = mock.captured_requests(); - assert_eq!(captured.len(), 2); - - let req2 = &captured[1]; - let assistant_with_thinking = req2 - .messages - .iter() - .find(|m| { - m.role == "assistant" - && m.content - .iter() - .any(|b| matches!(b, ContentBlock::Thinking { .. })) - }) - .expect("turn 2 request must replay assistant Thinking content"); - - let thinking_text = assistant_with_thinking - .content - .iter() - .find_map(|b| match b { - ContentBlock::Thinking { thinking, .. } => Some(thinking.clone()), - _ => None, - }) - .expect("Thinking block present"); - assert_eq!( - thinking_text, "I should call list_dir.", - "reasoning_content must be replayed verbatim across tool-call rounds" - ); -} - -// === 3. Tool-call round-trip ================================================ +// === 2. Tool-call round-trip ================================================ #[tokio::test] async fn tool_call_round_trip_streams_args_then_continues() { @@ -526,28 +432,3 @@ async fn capacity_gate_can_observe_request_before_response_streams() { // Drain to keep the mock state consistent. while stream.next().await.is_some() {} } - -// === 8. Compaction defaults (#402 P0) ====================================== - -#[test] -fn compaction_config_defaults_are_enabled_for_session_survivability() { - // The production CompactionConfig is gated behind a `#[path = ...]` module - // that isn't wired here, but we can test the principle: the - // `should_compact` function and `CompactionConfig` live in the same crate. - // Re-import from the production module to verify the default. - // - // We test via the mock pathway: the non-streaming compaction call (test 5 - // above) already exercises `create_message` with `stream: Some(false)`, - // which is the code path `compact_messages` uses. Combined with the - // capacity controller's `TargetedContextRefresh`, the enabled-by-default - // compaction config means long sessions auto-compact before hitting the - // context window limit. - // - // This test is a smoke check that the defaults compile and are correct. - // The production `CompactionConfig::default()` is exercised by - // `compaction::tests::should_compact_respects_enabled_flag` etc. - let config = crate::models::compaction_threshold_for_model_at_percent("deepseek-v4-pro", 80.0); - // Verify the threshold is reasonable (> 0 and < context window). - assert!(config > 0, "compaction threshold must be positive"); - assert!(config < 1_000_000, "compaction threshold must be below 1M"); -} diff --git a/crates/tui/tests/integration/main.rs b/crates/tui/tests/integration/main.rs index 02fd3bc3cd..395b440137 100644 --- a/crates/tui/tests/integration/main.rs +++ b/crates/tui/tests/integration/main.rs @@ -1,6 +1,6 @@ //! Consolidated harness for plain `#[test]`/`#[tokio::test]` acceptance suites. //! -//! See `crates/tui/tests/README.md` for why this exists: 17 small integration +//! See `crates/tui/tests/README.md` for why this exists: separate small integration //! binaries each re-linked the full `codewhale-tui` graph — one harness keeps the //! same test names (`integration::adaptive_evidence_acceptance::...`) so //! `cargo test -p codewhale-tui adaptive_evidence_acceptance` still filters. @@ -93,8 +93,6 @@ mod shell_dispatcher; use codewhale_core::tool_parser; mod adaptive_evidence_acceptance; -mod cache_guard; -mod coordination_acceptance; mod diagnostic_read_only; mod dotenv_authority; mod eval_harness; @@ -105,7 +103,6 @@ mod integration_mock_llm; mod lifecycle_outbox_exec; mod palette_audit; mod protocol_recovery; -mod reasoning_content_replayed_after_tool_call; mod skill_cli; mod telemetry_contract; mod verifiers_harness_contract; diff --git a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs deleted file mode 100644 index f72a6832c5..0000000000 --- a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs +++ /dev/null @@ -1,127 +0,0 @@ -use futures_util::StreamExt; - -use crate::llm_client::LlmClient; -use crate::llm_client::mock::{MockLlmClient, canned}; -use crate::models::Role; -use crate::models::{ContentBlock, Message, MessageRequest}; - -fn user_message(text: &str) -> Message { - Message { - role: Role::User, - content: vec![ContentBlock::Text { - text: text.to_string(), - cache_control: None, - }], - } -} - -fn assistant_thinking_tool_call( - thinking: &str, - id: &str, - name: &str, - input: serde_json::Value, -) -> Message { - Message { - role: Role::Assistant, - content: vec![ - ContentBlock::Thinking { - thinking: thinking.to_string(), - signature: None, - state: None, - redacted_data: None, - reasoning_details: None, - }, - ContentBlock::ToolUse { - id: id.to_string(), - name: name.to_string(), - input, - caller: None, - thought_signature: None, - }, - ], - } -} - -fn tool_result_message(tool_use_id: &str, content: &str) -> Message { - Message { - role: Role::User, - content: vec![ContentBlock::ToolResult { - tool_use_id: tool_use_id.to_string(), - content: content.to_string(), - is_error: None, - content_blocks: None, - }], - } -} - -fn make_request(messages: Vec) -> MessageRequest { - MessageRequest { - model: "deepseek-v4-pro".to_string(), - messages, - max_tokens: 4096, - system: None, - tools: None, - tool_choice: None, - metadata: None, - thinking: None, - reasoning_effort: Some("high".to_string()), - stream: Some(true), - temperature: None, - top_p: None, - } -} - -#[tokio::test] -async fn reasoning_content_is_replayed_after_thinking_tool_call() { - let mock = MockLlmClient::new(vec![]); - - mock.push_turn(vec![ - canned::message_start("r1"), - canned::thinking_delta(0, "I should inspect /tmp before answering."), - canned::tool_use_block_start(1, "call_a", "list_dir"), - canned::tool_input_delta(1, r#"{"path":"/tmp"}"#), - canned::block_stop(1), - canned::message_delta("tool_use", None), - canned::message_stop(), - ]); - - mock.push_factory(|request| { - let assistant = request - .messages - .iter() - .rev() - .find(|message| message.role == "assistant") - .expect("follow-up request must include the prior assistant tool-call turn"); - - assert!( - assistant - .content - .iter() - .any(|block| matches!(block, ContentBlock::Thinking { .. })), - "DeepSeek V4 follow-up requests must replay reasoning_content on the assistant tool-call turn" - ); - - canned::simple_text_turn("I see the /tmp entries.") - }); - - let mut first = mock - .create_message_stream(make_request(vec![user_message("list /tmp")])) - .await - .expect("first stream opens"); - while first.next().await.is_some() {} - - let mut second = mock - .create_message_stream(make_request(vec![ - user_message("list /tmp"), - assistant_thinking_tool_call( - "I should inspect /tmp before answering.", - "call_a", - "list_dir", - serde_json::json!({ "path": "/tmp" }), - ), - tool_result_message("call_a", "/tmp/file1\n/tmp/file2"), - ])) - .await - .expect("second stream opens"); - while second.next().await.is_some() {} -} diff --git a/crates/tui/tests/support/qa_harness/mod.rs b/crates/tui/tests/support/qa_harness/mod.rs index 5e5fd3e89d..c3517ebdac 100644 --- a/crates/tui/tests/support/qa_harness/mod.rs +++ b/crates/tui/tests/support/qa_harness/mod.rs @@ -24,7 +24,6 @@ pub mod harness; pub mod keys; pub mod modes; pub mod pty; -pub mod view_log; pub mod watchdog; pub use frame::{Color, Frame}; diff --git a/crates/tui/tests/support/qa_harness/view_log.rs b/crates/tui/tests/support/qa_harness/view_log.rs deleted file mode 100644 index 88e7e201b4..0000000000 --- a/crates/tui/tests/support/qa_harness/view_log.rs +++ /dev/null @@ -1,207 +0,0 @@ -//! Reader for the TUI's own `codewhale_tui::view_stack` trace records. -//! -//! Modal open/close coverage has an honesty problem: "the frame changed after -//! I pressed F1" is not evidence that a modal opened, and "the frame changed -//! back after Esc" is not evidence that it closed rather than being replaced. -//! `ViewStack::push` and its close paths already emit structured records with -//! the `ModalKind` and the resulting depth, so this reader consumes the -//! product's existing machine-readable signal instead of inventing a parallel -//! one for tests. -//! -//! Enable it by spawning the binary with -//! `RUST_LOG=warn,codewhale_tui::view_stack=debug` and a sealed `HOME`; the -//! subscriber writes to `$HOME/.codewhale/logs/tui--.log`. - -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use anyhow::{Context, Result, anyhow}; - -pub const VIEW_STACK_RUST_LOG: &str = "warn,codewhale_tui::view_stack=debug"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ViewEvent { - /// `push`, `push_boxed`, `pop`, `close`, or `emit_and_close`, verbatim - /// from the record. - pub action: String, - /// Debug spelling of the `ModalKind`, e.g. `CommandPalette`. - pub kind: String, - /// Stack depth *after* the transition, as the product reported it. - pub depth: usize, -} - -impl ViewEvent { - pub fn is_open(&self) -> bool { - self.action.starts_with("push") - } - - pub fn is_close(&self) -> bool { - matches!(self.action.as_str(), "pop" | "close" | "emit_and_close") - } -} - -/// Locate the log file the sealed-`HOME` child is writing to. Returns the most -/// recently modified `tui-*.log` so a re-spawned process in the same sealed -/// home does not resolve to a stale file. -pub fn log_path(home: &Path) -> Result { - let dir = home.join(".codewhale").join("logs"); - let mut newest: Option<(std::time::SystemTime, PathBuf)> = None; - for entry in - std::fs::read_dir(&dir).with_context(|| format!("read sealed log dir {}", dir.display()))? - { - let entry = entry?; - let path = entry.path(); - let is_tui_log = path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("tui-") && name.ends_with(".log")); - if !is_tui_log { - continue; - } - let modified = entry.metadata()?.modified()?; - if newest.as_ref().is_none_or(|(seen, _)| modified >= *seen) { - newest = Some((modified, path)); - } - } - newest - .map(|(_, path)| path) - .ok_or_else(|| anyhow!("no tui-*.log under {}", dir.display())) -} - -/// Parse every view-stack transition currently on disk, in order. -pub fn read_events(home: &Path) -> Result> { - let path = log_path(home)?; - let contents = - std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - Ok(parse_events(&contents)) -} - -/// Poll the log until at least `count` transitions are visible, or fail with -/// the transitions that *were* observed. The subscriber writes on its own -/// schedule, so a modal that has already repainted may not have been flushed -/// yet; this is a bounded wait on a real signal, never a fixed sleep. -pub fn wait_for_events(home: &Path, count: usize, timeout: Duration) -> Result> { - let budget = super::harness::ci_scaled(timeout); - let deadline = Instant::now() + budget; - let mut last: Vec = Vec::new(); - loop { - if let Ok(events) = read_events(home) { - last = events; - } - if last.len() >= count { - return Ok(last); - } - if Instant::now() >= deadline { - return Err(anyhow!( - "view-stack log never reached {count} transitions within {budget:?}; observed {:?}", - last - )); - } - std::thread::sleep(Duration::from_millis(40)); - } -} - -/// Poll until a newly appended transition satisfies `predicate`. Some views -/// are temporarily popped and restored while opening, so counting one record -/// per user gesture is not a stable contract; the semantic transition is. -pub fn wait_for_event_after( - home: &Path, - after: usize, - timeout: Duration, - mut predicate: F, -) -> Result<(Vec, ViewEvent)> -where - F: FnMut(&ViewEvent) -> bool, -{ - let budget = super::harness::ci_scaled(timeout); - let deadline = Instant::now() + budget; - let mut last: Vec = Vec::new(); - loop { - if let Ok(events) = read_events(home) { - last = events; - } - if let Some(event) = last.iter().skip(after).find(|event| predicate(event)) { - return Ok((last.clone(), event.clone())); - } - if Instant::now() >= deadline { - return Err(anyhow!( - "view-stack log produced no matching transition after index {after} within \ - {budget:?}; observed {:?}", - last - )); - } - std::thread::sleep(Duration::from_millis(40)); - } -} - -pub fn parse_events(contents: &str) -> Vec { - contents - .lines() - .filter(|line| line.contains("codewhale_tui::view_stack")) - .filter_map(parse_line) - .collect() -} - -fn parse_line(line: &str) -> Option { - let action = quoted_field(line, "action=")?; - let kind = bare_field(line, "kind=")?; - let depth = bare_field(line, "depth=")?.parse().ok()?; - Some(ViewEvent { - action, - kind, - depth, - }) -} - -/// `action="push"` → `push`. -fn quoted_field(line: &str, key: &str) -> Option { - let rest = line.split_once(key)?.1; - let rest = rest.strip_prefix('"')?; - let end = rest.find('"')?; - Some(rest[..end].to_string()) -} - -/// `kind=CommandPalette depth=1` → `CommandPalette`. -fn bare_field(line: &str, key: &str) -> Option { - let rest = line.split_once(key)?.1; - let end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len()); - let value = rest[..end].trim(); - if value.is_empty() { - return None; - } - Some(value.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - - const SAMPLE: &str = concat!( - "2026-07-26T12:00:00.100000Z WARN codewhale_tui::startup: unrelated line\n", - "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push\" kind=Help depth=1\n", - "2026-07-26T12:00:02.000000Z DEBUG codewhale_tui::view_stack: view pushed action=\"push_boxed\" kind=Pager depth=2\n", - "2026-07-26T12:00:03.000000Z DEBUG codewhale_tui::view_stack: view closed action=\"close\" kind=Pager depth=1\n", - ); - - #[test] - fn only_view_stack_records_are_parsed_and_order_is_preserved() { - let events = parse_events(SAMPLE); - - assert_eq!(events.len(), 3); - assert_eq!(events[0].kind, "Help"); - assert!(events[0].is_open()); - assert_eq!(events[1].action, "push_boxed"); - assert!(events[1].is_open()); - assert!(events[2].is_close()); - assert_eq!(events[2].depth, 1); - } - - #[test] - fn a_record_missing_its_fields_is_skipped_rather_than_guessed() { - let events = parse_events( - "2026-07-26T12:00:01.000000Z DEBUG codewhale_tui::view_stack: view pushed depth=1\n", - ); - - assert!(events.is_empty()); - } -} diff --git a/crates/workflow-js/tests/vm_tests.rs b/crates/workflow-js/tests/vm_tests.rs index 30ba25018e..6f22a911a1 100644 --- a/crates/workflow-js/tests/vm_tests.rs +++ b/crates/workflow-js/tests/vm_tests.rs @@ -1015,36 +1015,6 @@ async fn parallel_fail_fast_rejects_with_the_typed_slot_error() { ); } -#[tokio::test] -async fn task_errors_carry_typed_kinds() { - let driver = Arc::new(FakeDriver::new()); - driver.on("budget", FakeReply::BudgetExhausted("limit 10".to_string())); - driver.on("cancelled", FakeReply::Cancelled); - driver.on("admission", FakeReply::Reject("admission cap".to_string())); - let value = run( - &driver, - r#" - const kinds = {}; - for (const description of ["budget", "cancelled", "admission"]) { - try { - await task({ description }); - kinds[description] = "none"; - } catch (err) { - kinds[description] = err && err.kind; - } - } - return kinds; - "#, - json!(null), - ) - .await - .unwrap(); - assert_eq!( - value, - json!({"budget": "budget", "cancelled": "cancelled", "admission": "admission"}) - ); -} - #[tokio::test] async fn pipeline_fail_fast_rejects_instead_of_nulling_the_item() { let value = run( diff --git a/crates/workflow/src/experimental_search.rs b/crates/workflow/src/experimental_search.rs index 578f4e50d2..39b1914587 100644 --- a/crates/workflow/src/experimental_search.rs +++ b/crates/workflow/src/experimental_search.rs @@ -10,7 +10,12 @@ use sha2::{Digest, Sha256}; use std::path::{Component, Path}; use thiserror::Error; -use crate::{DEFAULT_FLEET_WORKFLOW_MAX_AGENTS, experimental_search::SearchSpecError::*}; +use crate::experimental_search::SearchSpecError::*; + +/// Lifetime cap on total agents a Fleet-shaped Workflow may launch. Kept local +/// after the imperative `WorkflowConfig` VM surface was removed; the value is +/// the admission ceiling this module's validation enforces. +const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 1000; pub const WORKFLOW_SEARCH_SCHEMA_VERSION: u32 = 1; /// Fallback live-worker ceiling for one search admission batch. diff --git a/crates/workflow/src/fleet_reasoning.rs b/crates/workflow/src/fleet_reasoning.rs index ab14a6fa12..36d52fad56 100644 --- a/crates/workflow/src/fleet_reasoning.rs +++ b/crates/workflow/src/fleet_reasoning.rs @@ -280,10 +280,6 @@ impl ProviderEffectiveReasoning { // ── Router call reasoning: configured, visible, and cheap ─────────────────── -/// The cheapest reasoning a Router call falls back to when nothing else is -/// configured. A Router profile may raise this to `low` — and no further. -pub const ROUTER_CALL_REASONING: RouterCallReasoning = RouterCallReasoning::Off; - /// Everything a receipt needs to say about *the Router's own call*. /// /// Four separate facts, because collapsing them is how a receipt starts lying: @@ -1877,7 +1873,6 @@ mod tests { assert_eq!(plan.tier, ReasoningTier::Off); assert_eq!(plan.disclosure.requested, "off"); assert_eq!(plan.disclosure.effective, "off"); - assert_eq!(ROUTER_CALL_REASONING, RouterCallReasoning::Off); } /// Capability may move a router call — an always-thinking route cannot diff --git a/crates/workflow/src/js_authoring.rs b/crates/workflow/src/js_authoring.rs index a4c8cd2330..bc7637b69d 100644 --- a/crates/workflow/src/js_authoring.rs +++ b/crates/workflow/src/js_authoring.rs @@ -453,7 +453,6 @@ mod tests { use super::*; use crate::{ AgentType, GateKind, GateOn, GateOnFail, GateOutcome, GateState, LaneGateBoard, TaskMode, - WorkflowReplayExecutor, }; #[test] @@ -894,22 +893,4 @@ workflow({ assert!(matches!(err, JavascriptWorkflowError::InvalidNode(_))); assert!(err.to_string().contains("missing")); } - - #[test] - fn javascript_example_compiles_and_replays_with_mock_trace() { - let source = include_str!("../../../workflows/issue_audit.workflow.js"); - let workflow = - compile_javascript_workflow("issue_audit.workflow.js", source).expect("compile"); - let trace = crate::WorkflowReplayTrace { - trace_id: "empty".to_string(), - leaf_records: Vec::new(), - control_records: Vec::new(), - }; - - let replayed = WorkflowReplayExecutor::new(trace) - .run(&workflow) - .expect("replay executor should accept validated JS IR"); - - assert_eq!(replayed.status, crate::WorkflowRunStatus::ReplayDiverged); - } } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 5e327ea96c..93bc57ad8b 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -23,12 +23,10 @@ mod model_policy; mod named_fleet; pub mod reasoning_router; pub mod redaction; -mod replay; mod review_repair; mod role_resolve; -use std::collections::{BTreeMap, BTreeSet}; -use std::path::Path; +use std::collections::BTreeSet; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -48,13 +46,13 @@ pub use fleet_preflight::{ }; pub use fleet_reasoning::{ EffectiveReasoning, EffectiveReasoningSource, FAITHFUL_WIRE_TIERS, FleetTaskReceipt, - ProviderEffectiveReasoning, ProviderReasoningControl, ROUTER_CALL_REASONING, - ROUTER_MAX_OUTPUT_TOKENS, ROUTER_REASONING_FIELD, ROUTER_SUMMARY_MAX_CHARS, ROUTING_SCOPE, - ReasoningCapability, ReasoningResolveError, ResolvedReasoning, RouterAvailability, - RouterCallDisclosure, RouterCallInput, RouterCallPlan, RouterDecision, RouterDecisionError, - RouterIdentity, RoutingDisclosure, RoutingPayload, TaskShape, bounded_routing_payload, - parse_router_decision, resolve_exact_member_reasoning, resolve_legacy_reasoning, - router_call_plan, router_system_prompt, router_user_message, transport_disclosure, + ProviderEffectiveReasoning, ProviderReasoningControl, ROUTER_MAX_OUTPUT_TOKENS, + ROUTER_REASONING_FIELD, ROUTER_SUMMARY_MAX_CHARS, ROUTING_SCOPE, ReasoningCapability, + ReasoningResolveError, ResolvedReasoning, RouterAvailability, RouterCallDisclosure, + RouterCallInput, RouterCallPlan, RouterDecision, RouterDecisionError, RouterIdentity, + RoutingDisclosure, RoutingPayload, TaskShape, bounded_routing_payload, parse_router_decision, + resolve_exact_member_reasoning, resolve_legacy_reasoning, router_call_plan, + router_system_prompt, router_user_message, transport_disclosure, }; pub use fleet_snapshot::{ FleetSnapshot, FleetSnapshotLegacyRole, FleetSnapshotMember, FleetSnapshotRouter, @@ -83,7 +81,6 @@ pub use redaction::{ REDACTION_ABSOLUTE_PATH, REDACTION_RELATIVE_PATH, REDACTION_SECRET, Redaction, redact_for_disclosure, }; -pub use replay::*; pub use review_repair::{ IterationReceipt, IterationVerdict, ReviewRepairBounds, ReviewRepairError, ReviewRepairLoop, ReviewRepairPolicy, RouteReceipt, RoutedBy, StopReason, @@ -93,32 +90,6 @@ pub use role_resolve::{ resolve_workflow_agent, validate_role_token, }; -/// Default hard ceiling on total agents a Fleet-shaped Workflow plan may launch. -/// Matches the imperative VM lifetime cap (1_000 agents per run). -pub const DEFAULT_FLEET_WORKFLOW_MAX_AGENTS: usize = 1000; -pub const DEFAULT_FLEET_WORKFLOW_MAX_DEPTH: usize = 5; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkflowConfig { - pub goal: String, - #[serde(default = "default_max_concurrent")] - pub max_concurrent: u8, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub phases: Vec, -} - -impl WorkflowConfig { - pub fn validate(&self) -> Result<(), WorkflowValidationError> { - WorkflowPlan::from_config(self).map(|_| ()) - } - - pub fn compile(&self) -> Result { - WorkflowPlan::from_config(self) - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkflowSpec { #[serde(default)] @@ -142,34 +113,6 @@ pub struct WorkflowSpec { pub nodes: Vec, } -impl WorkflowSpec { - pub fn validate_for_fleet(&self) -> Result { - self.validate_for_fleet_with_limits(WorkflowFleetLimits::default()) - } - - pub fn validate_for_fleet_with_limits( - &self, - limits: WorkflowFleetLimits, - ) -> Result { - validate_workflow_nodes(&self.nodes) - .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?; - let shape = estimate_fleet_shape(&self.nodes)?; - if shape.total_agents > limits.max_total_agents { - return Err(WorkflowFleetLimitError::TooManyAgents { - total_agents: shape.total_agents, - max_total_agents: limits.max_total_agents, - }); - } - if shape.max_depth > limits.max_depth { - return Err(WorkflowFleetLimitError::RecursionTooDeep { - depth: shape.max_depth, - max_depth: limits.max_depth, - }); - } - Ok(shape) - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", content = "spec", rename_all = "snake_case")] pub enum WorkflowNode { @@ -352,192 +295,6 @@ pub enum PromotionStrategy { TeacherSelected, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkflowPlan { - goal: String, - max_concurrent: u8, - phases: Vec, -} - -impl WorkflowPlan { - pub fn from_config(config: &WorkflowConfig) -> Result { - validate_non_empty("workflow goal", &config.goal)?; - if !(1..=20).contains(&config.max_concurrent) { - return Err(WorkflowValidationError::InvalidMaxConcurrent { - value: config.max_concurrent, - }); - } - if config.phases.is_empty() { - return Err(WorkflowValidationError::EmptyWorkflow); - } - - let mut phase_indices = BTreeMap::new(); - let mut all_tasks = BTreeMap::new(); - let mut task_phase = BTreeMap::new(); - - for (phase_index, phase) in config.phases.iter().enumerate() { - validate_non_empty("phase name", &phase.name)?; - if phase.tasks.is_empty() { - return Err(WorkflowValidationError::EmptyPhase { - phase: phase.name.clone(), - }); - } - if phase_indices - .insert(phase.name.clone(), phase_index) - .is_some() - { - return Err(WorkflowValidationError::DuplicatePhase { - phase: phase.name.clone(), - }); - } - - for task in &phase.tasks { - validate_non_empty("task id", &task.id)?; - validate_non_empty("task prompt", &task.prompt)?; - if all_tasks.insert(task.id.clone(), task).is_some() { - return Err(WorkflowValidationError::DuplicateTask { - task: task.id.clone(), - }); - } - task_phase.insert(task.id.clone(), phase.name.clone()); - } - } - - for phase in &config.phases { - for dependency in &phase.depends_on { - if dependency == &phase.name || !phase_indices.contains_key(dependency) { - return Err(WorkflowValidationError::InvalidPhaseDependency { - phase: phase.name.clone(), - dependency: dependency.clone(), - }); - } - } - validate_parallel_write_scope(phase)?; - } - - let ordered_phase_names = ordered_phases(config, &phase_indices)?; - let phase_order: BTreeMap<_, _> = ordered_phase_names - .iter() - .enumerate() - .map(|(index, phase)| (phase.clone(), index)) - .collect(); - - for phase in &config.phases { - for task in &phase.tasks { - for dependency in &task.depends_on_results { - let Some(dependency_phase) = task_phase.get(dependency) else { - return Err(WorkflowValidationError::InvalidTaskResultDependency { - task: task.id.clone(), - dependency: dependency.clone(), - }); - }; - if phase_order[dependency_phase] >= phase_order[&phase.name] { - return Err(WorkflowValidationError::UnavailableTaskResultDependency { - task: task.id.clone(), - dependency: dependency.clone(), - dependency_phase: dependency_phase.clone(), - task_phase: phase.name.clone(), - }); - } - } - } - } - - let phases = ordered_phase_names - .iter() - .map(|phase_name| { - let phase = &config.phases[phase_indices[phase_name]]; - PhasePlan { - name: phase.name.clone(), - parallel: phase.parallel, - on_failure: phase.on_failure, - tasks: phase.tasks.clone(), - } - }) - .collect(); - - Ok(Self { - goal: config.goal.clone(), - max_concurrent: config.max_concurrent, - phases, - }) - } - - pub fn goal(&self) -> &str { - &self.goal - } - - pub fn max_concurrent(&self) -> u8 { - self.max_concurrent - } - - pub fn phases(&self) -> &[PhasePlan] { - &self.phases - } - - pub fn phase_names(&self) -> impl Iterator { - self.phases.iter().map(|phase| phase.name.as_str()) - } -} - -pub type WorkflowIr = WorkflowPlan; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PhasePlan { - pub name: String, - pub parallel: bool, - pub on_failure: FailurePolicy, - pub tasks: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Phase { - pub name: String, - #[serde(default)] - pub description: Option, - #[serde(default)] - pub depends_on: Vec, - #[serde(default)] - pub parallel: bool, - #[serde(default)] - pub on_failure: FailurePolicy, - #[serde(default)] - pub tasks: Vec, -} - -pub type WorkflowPhase = Phase; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "snake_case")] -pub enum FailurePolicy { - #[default] - SkipContinue, - Abort, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Task { - pub id: String, - pub prompt: String, - #[serde(default)] - pub agent_type: AgentType, - #[serde(default)] - pub mode: TaskMode, - #[serde(default)] - pub isolation: IsolationMode, - #[serde(default)] - pub file_scope: Vec, - #[serde(default)] - pub depends_on_results: Vec, - #[serde(default)] - pub max_steps: Option, - #[serde(default)] - pub timeout_secs: Option, -} - -pub type WorkflowTask = Task; -pub type WorkflowRole = AgentType; - /// The workflow wire's agent type. The serialized spellings are the canonical /// Codewhale role vocabulary (founder, 2026-09-02); the pre-rename JS /// spellings stay accepted as aliases so checked-in and saved workflows keep @@ -684,19 +441,6 @@ impl WorkflowUsage { .zip(self.output_tokens) .map(|(input, output)| input.saturating_add(output)) } - - /// Add two independently observed usage receipts. A field remains known - /// only when both contributors reported it; `Some(0)` is still observed. - pub(crate) fn add_assign(&mut self, other: Self) { - self.input_tokens = sum_reported(self.input_tokens, other.input_tokens); - self.output_tokens = sum_reported(self.output_tokens, other.output_tokens); - self.cost_microusd = sum_reported(self.cost_microusd, other.cost_microusd); - } -} - -fn sum_reported(left: Option, right: Option) -> Option { - left.zip(right) - .map(|(left, right)| left.saturating_add(right)) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -713,22 +457,6 @@ pub struct WorkflowMemoUsage { pub provider_prompt_cache_misses: u64, } -impl WorkflowMemoUsage { - pub(crate) fn add_assign(&mut self, other: Self) { - self.armh_hits = self.armh_hits.saturating_add(other.armh_hits); - self.armh_misses = self.armh_misses.saturating_add(other.armh_misses); - self.armh_saved_estimated_tokens = self - .armh_saved_estimated_tokens - .saturating_add(other.armh_saved_estimated_tokens); - self.provider_prompt_cache_hits = self - .provider_prompt_cache_hits - .saturating_add(other.provider_prompt_cache_hits); - self.provider_prompt_cache_misses = self - .provider_prompt_cache_misses - .saturating_add(other.provider_prompt_cache_misses); - } -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ControlNodeResult { pub node_id: String, @@ -750,6 +478,9 @@ pub enum WorkflowRunStatus { Failed, Cancelled, BudgetExceeded, + /// Wire-compatible only: no in-repo producer since the replay executor + /// was removed; kept so older serializations and existing matches stay + /// valid. ReplayDiverged, } @@ -806,465 +537,6 @@ impl WorkflowExecution { pub fn mark_budget_exceeded(&mut self) { self.status = WorkflowRunStatus::BudgetExceeded; } - - pub(crate) fn mark_replay_diverged(&mut self) { - self.status = WorkflowRunStatus::ReplayDiverged; - } - - fn should_stop_mock_execution(&self) -> bool { - matches!( - self.status, - WorkflowRunStatus::Cancelled | WorkflowRunStatus::BudgetExceeded - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct MockLeafOutcome { - pub status: WorkflowRunStatus, - #[serde(default)] - pub usage: WorkflowUsage, - #[serde(default)] - pub memo_usage: WorkflowMemoUsage, - #[serde(default)] - pub output: Option, - #[serde(default)] - pub artifacts: Vec, -} - -impl MockLeafOutcome { - pub fn succeeded(output: impl Into) -> Self { - Self { - status: WorkflowRunStatus::Succeeded, - usage: WorkflowUsage::default(), - memo_usage: WorkflowMemoUsage::default(), - output: Some(output.into()), - artifacts: Vec::new(), - } - } - - pub fn failed(output: impl Into) -> Self { - Self { - status: WorkflowRunStatus::Failed, - usage: WorkflowUsage::default(), - memo_usage: WorkflowMemoUsage::default(), - output: Some(output.into()), - artifacts: Vec::new(), - } - } - - pub fn with_usage(mut self, usage: WorkflowUsage) -> Self { - self.usage = usage; - self - } - - pub fn with_memo_usage(mut self, memo_usage: WorkflowMemoUsage) -> Self { - self.memo_usage = memo_usage; - self - } -} - -#[derive(Debug, Default, Clone)] -pub struct MockWorkflowExecutor { - leaf_outcomes: BTreeMap, - predicate_results: BTreeMap>, - generated_nodes: BTreeMap>, - cancelled: bool, - max_leaf_steps: Option, - leaf_steps_executed: u32, - max_leaf_tokens: Option, - leaf_tokens_used: u64, -} - -impl MockWorkflowExecutor { - pub fn new() -> Self { - Self::default() - } - - pub fn with_leaf_outcome( - mut self, - leaf_id: impl Into, - outcome: MockLeafOutcome, - ) -> Self { - self.leaf_outcomes.insert(leaf_id.into(), outcome); - self - } - - pub fn with_predicate_results( - mut self, - node_id: impl Into, - results: Vec, - ) -> Self { - self.predicate_results.insert(node_id.into(), results); - self - } - - pub fn with_generated_nodes( - mut self, - node_id: impl Into, - nodes: Vec, - ) -> Self { - self.generated_nodes.insert(node_id.into(), nodes); - self - } - - pub fn with_cancelled(mut self) -> Self { - self.cancelled = true; - self - } - - pub fn with_max_leaf_steps(mut self, max_leaf_steps: u32) -> Self { - self.max_leaf_steps = Some(max_leaf_steps); - self - } - - pub fn with_max_leaf_tokens(mut self, max_leaf_tokens: u64) -> Self { - self.max_leaf_tokens = Some(max_leaf_tokens); - self - } - - pub fn run( - &mut self, - spec: &WorkflowSpec, - ) -> Result { - validate_workflow_nodes(&spec.nodes)?; - let mut execution = WorkflowExecution::default(); - self.execute_nodes(&spec.nodes, &mut execution)?; - Ok(execution) - } - - fn execute_nodes( - &mut self, - nodes: &[WorkflowNode], - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - for node in nodes { - if execution.should_stop_mock_execution() { - break; - } - self.execute_node(node, execution)?; - } - Ok(()) - } - - fn execute_node( - &mut self, - node: &WorkflowNode, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - match node { - WorkflowNode::BranchSet(spec) => self.execute_branch_set(spec, execution), - WorkflowNode::Leaf(spec) => { - self.execute_leaf(spec, execution); - Ok(()) - } - WorkflowNode::Sequence(spec) => { - self.execute_nodes(&spec.children, execution)?; - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::Sequence, - status: execution.status, - selected_children: spec.children.iter().map(node_id).collect(), - summary: Some("sequence executed in declaration order".to_string()), - }); - Ok(()) - } - WorkflowNode::Reduce(spec) => { - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::Reduce, - status: WorkflowRunStatus::Succeeded, - selected_children: spec.inputs.clone(), - summary: Some(spec.prompt.clone()), - }); - Ok(()) - } - WorkflowNode::TeacherReview(spec) => { - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::TeacherReview, - status: WorkflowRunStatus::Succeeded, - selected_children: spec.candidates.clone(), - summary: Some( - "teacher review scaffold selected declared candidates".to_string(), - ), - }); - Ok(()) - } - WorkflowNode::LoopUntil(spec) => self.execute_loop_until(spec, execution), - WorkflowNode::Cond(spec) => self.execute_cond(spec, execution), - WorkflowNode::Expand(spec) => self.execute_expand(spec, execution), - } - } - - fn execute_branch_set( - &mut self, - spec: &BranchSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - let before = execution.leaf_results.len(); - self.execute_nodes(&spec.children, execution)?; - let status = aggregate_mock_status(&execution.leaf_results[before..]); - let mut usage = WorkflowUsage::default(); - let mut memo_usage = WorkflowMemoUsage::default(); - for (index, result) in execution.leaf_results[before..].iter().enumerate() { - if index == 0 { - usage = result.usage; - } else { - usage.add_assign(result.usage); - } - memo_usage.add_assign(result.memo_usage); - } - mark_execution_for_status(execution, status); - execution.branch_results.push(BranchResult { - branch_id: spec.id.clone(), - task_id: spec.id.clone(), - status, - usage, - memo_usage, - artifacts: Vec::new(), - notes: Some("mock branch set executed without runtime fanout".to_string()), - }); - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::BranchSet, - status, - selected_children: spec.children.iter().map(node_id).collect(), - summary: Some("branch set scaffold executed children deterministically".to_string()), - }); - Ok(()) - } - - fn execute_leaf(&mut self, spec: &LeafSpec, execution: &mut WorkflowExecution) { - let outcome = self.mock_leaf_outcome(spec); - mark_execution_for_status(execution, outcome.status); - if execution.leaf_results.is_empty() { - execution.usage = outcome.usage; - } else { - execution.usage.add_assign(outcome.usage); - } - execution.memo_usage.add_assign(outcome.memo_usage); - execution.leaf_results.push(LeafResult { - leaf_id: spec.id.clone(), - task_id: spec.id.clone(), - role: spec.role.clone(), - profile: spec.profile.clone(), - status: outcome.status, - usage: outcome.usage, - memo_usage: outcome.memo_usage, - output: outcome.output, - artifacts: outcome.artifacts, - schema_error: None, - }); - } - - fn execute_loop_until( - &mut self, - spec: &LoopUntilSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - let max_iterations = spec.max_iterations.unwrap_or(1).max(1); - let mut iterations = 0; - let mut passed = false; - while iterations < max_iterations { - if execution.should_stop_mock_execution() { - break; - } - iterations += 1; - self.execute_nodes(&spec.children, execution)?; - if execution.should_stop_mock_execution() { - break; - } - if self.next_predicate_result(&spec.id) { - passed = true; - break; - } - } - let status = if execution.should_stop_mock_execution() { - execution.status - } else if passed { - WorkflowRunStatus::Succeeded - } else { - WorkflowRunStatus::Failed - }; - mark_execution_for_status(execution, status); - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::LoopUntil, - status, - selected_children: spec.children.iter().map(node_id).collect(), - summary: Some(format!("loop_until iterations={iterations}")), - }); - Ok(()) - } - - fn execute_cond( - &mut self, - spec: &CondSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - let passed = self.next_predicate_result(&spec.id); - let selected_nodes = if passed { - &spec.then_nodes - } else { - &spec.else_nodes - }; - self.execute_nodes(selected_nodes, execution)?; - let status = if execution.should_stop_mock_execution() { - execution.status - } else { - WorkflowRunStatus::Succeeded - }; - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::Cond, - status, - selected_children: selected_nodes.iter().map(node_id).collect(), - summary: Some(format!("predicate_result={passed}")), - }); - Ok(()) - } - - fn execute_expand( - &mut self, - spec: &ExpandSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowExecutionError> { - let mut nodes = self.generated_nodes.remove(&spec.id).unwrap_or_default(); - if let Some(max_children) = spec.max_children { - nodes.truncate(max_children); - } - validate_workflow_node_shapes(&nodes)?; - self.execute_nodes(&nodes, execution)?; - let status = if execution.should_stop_mock_execution() { - execution.status - } else { - WorkflowRunStatus::Succeeded - }; - execution.control_node_results.push(ControlNodeResult { - node_id: spec.id.clone(), - kind: ControlNodeKind::Expand, - status, - selected_children: nodes.iter().map(node_id).collect(), - summary: Some(format!("expanded_from={}", spec.source)), - }); - Ok(()) - } - - fn mock_leaf_outcome(&mut self, spec: &LeafSpec) -> MockLeafOutcome { - if self.cancelled { - return MockLeafOutcome { - status: WorkflowRunStatus::Cancelled, - usage: WorkflowUsage { - input_tokens: Some(0), - output_tokens: Some(0), - cost_microusd: Some(0), - }, - memo_usage: WorkflowMemoUsage::default(), - output: Some("mock workflow cancelled before leaf execution".to_string()), - artifacts: Vec::new(), - }; - } - if self - .max_leaf_steps - .is_some_and(|max| max > 0 && self.leaf_steps_executed >= max) - { - return MockLeafOutcome { - status: WorkflowRunStatus::BudgetExceeded, - // The leaf was rejected before execution, so zero usage is a - // known observation rather than missing provider telemetry. - usage: WorkflowUsage { - input_tokens: Some(0), - output_tokens: Some(0), - cost_microusd: Some(0), - }, - memo_usage: WorkflowMemoUsage::default(), - output: Some("mock workflow leaf step budget exhausted".to_string()), - artifacts: Vec::new(), - }; - } - if self - .max_leaf_tokens - .is_some_and(|max| self.leaf_tokens_used >= max) - || spec.budget.max_tokens == Some(0) - { - return MockLeafOutcome { - status: WorkflowRunStatus::BudgetExceeded, - usage: WorkflowUsage { - input_tokens: Some(0), - output_tokens: Some(0), - cost_microusd: Some(0), - }, - memo_usage: WorkflowMemoUsage::default(), - output: Some("mock workflow leaf token budget exhausted".to_string()), - artifacts: Vec::new(), - }; - } - self.leaf_steps_executed = self.leaf_steps_executed.saturating_add(1); - let outcome = self - .leaf_outcomes - .remove(&spec.id) - .unwrap_or_else(|| MockLeafOutcome::succeeded(format!("mock leaf {}", spec.id))); - let tokens = outcome.usage.total_tokens(); - if let (Some(per_leaf_token_cap), Some(tokens)) = (spec.budget.max_tokens, tokens) - && tokens > per_leaf_token_cap - { - return MockLeafOutcome { - status: WorkflowRunStatus::BudgetExceeded, - usage: outcome.usage, - memo_usage: outcome.memo_usage, - output: Some(format!( - "mock workflow leaf token budget exhausted ({tokens} > {per_leaf_token_cap})" - )), - artifacts: outcome.artifacts, - }; - } - if let Some(tokens) = tokens { - self.leaf_tokens_used = self.leaf_tokens_used.saturating_add(tokens); - } - outcome - } - - fn next_predicate_result(&mut self, node_id: &str) -> bool { - let Some(results) = self.predicate_results.get_mut(node_id) else { - return false; - }; - if results.is_empty() { - return false; - } - results.remove(0) - } -} - -fn aggregate_mock_status(results: &[LeafResult]) -> WorkflowRunStatus { - if results - .iter() - .any(|result| result.status == WorkflowRunStatus::Cancelled) - { - WorkflowRunStatus::Cancelled - } else if results - .iter() - .any(|result| result.status == WorkflowRunStatus::BudgetExceeded) - { - WorkflowRunStatus::BudgetExceeded - } else if results - .iter() - .any(|result| result.status != WorkflowRunStatus::Succeeded) - { - WorkflowRunStatus::Failed - } else { - WorkflowRunStatus::Succeeded - } -} - -fn mark_execution_for_status(execution: &mut WorkflowExecution, status: WorkflowRunStatus) { - match status { - WorkflowRunStatus::Succeeded | WorkflowRunStatus::Pending | WorkflowRunStatus::Running => {} - WorkflowRunStatus::Failed => execution.mark_failed(), - WorkflowRunStatus::Cancelled => execution.mark_cancelled(), - WorkflowRunStatus::BudgetExceeded => execution.mark_budget_exceeded(), - WorkflowRunStatus::ReplayDiverged => execution.mark_replay_diverged(), - } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -1744,127 +1016,6 @@ pub enum WorkflowExecutionError { }, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct WorkflowFleetLimits { - pub max_total_agents: usize, - pub max_depth: usize, -} - -impl Default for WorkflowFleetLimits { - fn default() -> Self { - Self { - max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS, - max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct WorkflowFleetShape { - pub total_agents: usize, - pub max_depth: usize, -} - -impl WorkflowFleetShape { - fn add(self, other: Self) -> Self { - Self { - total_agents: self.total_agents.saturating_add(other.total_agents), - max_depth: self.max_depth.max(other.max_depth), - } - } - - fn repeat(self, times: usize) -> Self { - Self { - total_agents: self.total_agents.saturating_mul(times), - max_depth: self.max_depth, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum WorkflowFleetLimitError { - #[error("workflow IR is invalid for Fleet: {source}")] - InvalidWorkflow { - #[from] - source: WorkflowExecutionError, - }, - #[error( - "workflow would launch {total_agents} agents; Fleet Workflow limit is {max_total_agents}" - )] - TooManyAgents { - total_agents: usize, - max_total_agents: usize, - }, - #[error("workflow reaches recursion depth {depth}; Fleet Workflow limit is {max_depth}")] - RecursionTooDeep { depth: usize, max_depth: usize }, - #[error("expand node `{node}` must declare max_children before Fleet launch")] - UnboundedExpand { node: String }, - #[error("expand node `{node}` must include a template before Fleet launch")] - MissingExpandTemplate { node: String }, - #[error("loop_until node `{node}` must declare max_iterations before Fleet launch")] - UnboundedLoop { node: String }, -} - -fn estimate_fleet_shape( - nodes: &[WorkflowNode], -) -> Result { - estimate_fleet_shape_at_depth(nodes, 1) -} - -fn estimate_fleet_shape_at_depth( - nodes: &[WorkflowNode], - depth: usize, -) -> Result { - nodes - .iter() - .try_fold(WorkflowFleetShape::default(), |shape, node| { - Ok(shape.add(estimate_node_fleet_shape(node, depth)?)) - }) -} - -fn estimate_node_fleet_shape( - node: &WorkflowNode, - depth: usize, -) -> Result { - match node { - WorkflowNode::Leaf(_) => Ok(WorkflowFleetShape { - total_agents: 1, - max_depth: depth, - }), - WorkflowNode::BranchSet(spec) => estimate_fleet_shape_at_depth(&spec.children, depth + 1), - WorkflowNode::Sequence(spec) => estimate_fleet_shape_at_depth(&spec.children, depth), - WorkflowNode::Reduce(_) | WorkflowNode::TeacherReview(_) => Ok(WorkflowFleetShape { - total_agents: 0, - max_depth: 0, - }), - WorkflowNode::LoopUntil(spec) => { - let iterations = - spec.max_iterations - .ok_or_else(|| WorkflowFleetLimitError::UnboundedLoop { - node: spec.id.clone(), - })? as usize; - Ok(estimate_fleet_shape_at_depth(&spec.children, depth)?.repeat(iterations.max(1))) - } - WorkflowNode::Cond(spec) => Ok(estimate_fleet_shape_at_depth(&spec.then_nodes, depth)? - .add(estimate_fleet_shape_at_depth(&spec.else_nodes, depth)?)), - WorkflowNode::Expand(spec) => { - let max_children = - spec.max_children - .ok_or_else(|| WorkflowFleetLimitError::UnboundedExpand { - node: spec.id.clone(), - })?; - let template = spec.template.as_deref().ok_or_else(|| { - WorkflowFleetLimitError::MissingExpandTemplate { - node: spec.id.clone(), - } - })?; - validate_workflow_node_shapes(std::slice::from_ref(template)) - .map_err(|source| WorkflowFleetLimitError::InvalidWorkflow { source })?; - Ok(estimate_node_fleet_shape(template, depth)?.repeat(max_children)) - } - } -} - fn default_frontier_limit() -> usize { 8 } @@ -1890,13 +1041,6 @@ pub(crate) fn validate_workflow_nodes( validate_workflow_references(nodes, &seen) } -pub(crate) fn validate_workflow_node_shapes( - nodes: &[WorkflowNode], -) -> Result<(), WorkflowExecutionError> { - let mut seen = BTreeSet::new(); - validate_workflow_nodes_inner(nodes, &mut seen) -} - fn validate_workflow_nodes_inner( nodes: &[WorkflowNode], seen: &mut BTreeSet, @@ -2038,167 +1182,6 @@ fn control_kind_name(node: &WorkflowNode) -> &'static str { } } -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum WorkflowValidationError { - #[error("{field} must not be empty")] - EmptyField { field: &'static str }, - #[error("workflow must contain at least one phase")] - EmptyWorkflow, - #[error("phase `{phase}` must contain at least one task")] - EmptyPhase { phase: String }, - #[error("max_concurrent must be between 1 and 20, got {value}")] - InvalidMaxConcurrent { value: u8 }, - #[error("duplicate workflow phase `{phase}`")] - DuplicatePhase { phase: String }, - #[error("duplicate workflow task `{task}`")] - DuplicateTask { task: String }, - #[error("phase `{phase}` has invalid dependency `{dependency}`")] - InvalidPhaseDependency { phase: String, dependency: String }, - #[error("phase dependency cycle includes `{phase}`")] - PhaseDependencyCycle { phase: String }, - #[error("task `{task}` has invalid result dependency `{dependency}`")] - InvalidTaskResultDependency { task: String, dependency: String }, - #[error( - "task `{task}` depends on result `{dependency}` from unavailable phase `{dependency_phase}` while running in `{task_phase}`" - )] - UnavailableTaskResultDependency { - task: String, - dependency: String, - dependency_phase: String, - task_phase: String, - }, - #[error("parallel read-write task `{task}` must declare a file_scope")] - MissingParallelWriteScope { task: String }, - #[error("parallel read-write tasks `{left}` and `{right}` have overlapping file scopes")] - OverlappingParallelWriteScope { left: String, right: String }, -} - -fn default_max_concurrent() -> u8 { - 4 -} - -fn validate_non_empty(field: &'static str, value: &str) -> Result<(), WorkflowValidationError> { - if value.trim().is_empty() { - return Err(WorkflowValidationError::EmptyField { field }); - } - Ok(()) -} - -fn ordered_phases( - config: &WorkflowConfig, - phase_indices: &BTreeMap, -) -> Result, WorkflowValidationError> { - let mut visiting = BTreeSet::new(); - let mut visited = BTreeSet::new(); - let mut ordered = Vec::with_capacity(config.phases.len()); - - for phase in &config.phases { - visit_phase( - &phase.name, - config, - phase_indices, - &mut visiting, - &mut visited, - &mut ordered, - )?; - } - - Ok(ordered) -} - -fn visit_phase( - phase_name: &str, - config: &WorkflowConfig, - phase_indices: &BTreeMap, - visiting: &mut BTreeSet, - visited: &mut BTreeSet, - ordered: &mut Vec, -) -> Result<(), WorkflowValidationError> { - if visited.contains(phase_name) { - return Ok(()); - } - if !visiting.insert(phase_name.to_string()) { - return Err(WorkflowValidationError::PhaseDependencyCycle { - phase: phase_name.to_string(), - }); - } - - let phase = &config.phases[phase_indices[phase_name]]; - for dependency in &phase.depends_on { - visit_phase( - dependency, - config, - phase_indices, - visiting, - visited, - ordered, - )?; - } - - visiting.remove(phase_name); - visited.insert(phase_name.to_string()); - ordered.push(phase_name.to_string()); - Ok(()) -} - -fn validate_parallel_write_scope(phase: &Phase) -> Result<(), WorkflowValidationError> { - if !phase.parallel { - return Ok(()); - } - - let write_tasks: Vec<_> = phase - .tasks - .iter() - .filter(|task| task.mode == TaskMode::ReadWrite) - .collect(); - - for task in &write_tasks { - if task.file_scope.is_empty() { - return Err(WorkflowValidationError::MissingParallelWriteScope { - task: task.id.clone(), - }); - } - } - - for (left_index, left) in write_tasks.iter().enumerate() { - for right in write_tasks.iter().skip(left_index + 1) { - if scopes_overlap(&left.file_scope, &right.file_scope) { - return Err(WorkflowValidationError::OverlappingParallelWriteScope { - left: left.id.clone(), - right: right.id.clone(), - }); - } - } - } - - Ok(()) -} - -pub fn scopes_overlap(left: &[String], right: &[String]) -> bool { - left.iter().any(|left_scope| { - right - .iter() - .any(|right_scope| scope_overlaps(left_scope, right_scope)) - }) -} - -fn scope_overlaps(left: &str, right: &str) -> bool { - let left = normalize_file_scope_root(left); - let right = normalize_file_scope_root(right); - - if left == right || left == "." || right == "." { - return true; - } - - if left.contains('*') || right.contains('*') { - return glob_prefix(&left) == glob_prefix(&right); - } - - let left_path = Path::new(&left); - let right_path = Path::new(&right); - left_path.starts_with(right_path) || right_path.starts_with(left_path) -} - /// Normalize the suffix-glob spelling accepted by declarative workflow /// `file_scope` into the concrete directory root enforced at runtime. #[must_use] @@ -2211,53 +1194,10 @@ pub fn normalize_file_scope_root(scope: &str) -> String { .to_string() } -fn glob_prefix(scope: &str) -> String { - scope - .split('*') - .next() - .unwrap_or(scope) - .trim_end_matches('/') - .to_string() -} - #[cfg(test)] mod tests { use super::*; - fn task(id: &str) -> Task { - Task { - id: id.to_string(), - prompt: format!("run {id}"), - agent_type: AgentType::General, - mode: TaskMode::ReadOnly, - isolation: IsolationMode::Shared, - file_scope: Vec::new(), - depends_on_results: Vec::new(), - max_steps: None, - timeout_secs: None, - } - } - - fn config(phases: Vec) -> WorkflowConfig { - WorkflowConfig { - goal: "cache-change".to_string(), - max_concurrent: 4, - description: None, - phases, - } - } - - fn phase(name: &str, depends_on: &[&str], tasks: Vec) -> Phase { - Phase { - name: name.to_string(), - description: None, - depends_on: depends_on.iter().map(|value| value.to_string()).collect(), - parallel: false, - on_failure: FailurePolicy::SkipContinue, - tasks, - } - } - fn leaf_node(id: &str) -> WorkflowNode { WorkflowNode::Leaf(LeafSpec { id: id.to_string(), @@ -2275,23 +1215,6 @@ mod tests { }) } - fn leaf_node_with_budget(id: &str, budget: BudgetSpec) -> WorkflowNode { - WorkflowNode::Leaf(LeafSpec { - id: id.to_string(), - prompt: format!("run {id}"), - agent_type: AgentType::General, - role: None, - profile: None, - mode: TaskMode::ReadOnly, - isolation: IsolationMode::Shared, - file_scope: Vec::new(), - depends_on_results: Vec::new(), - budget, - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - }) - } - fn invalid_leaf_node(id: &str) -> WorkflowNode { WorkflowNode::Leaf(LeafSpec { id: id.to_string(), @@ -2323,17 +1246,6 @@ mod tests { } } - fn control_result<'a>( - execution: &'a WorkflowExecution, - node_id: &str, - ) -> &'a ControlNodeResult { - execution - .control_node_results - .iter() - .find(|result| result.node_id == node_id) - .expect("control node result should exist") - } - fn candidate( branch_id: &str, status: WorkflowRunStatus, @@ -2351,252 +1263,30 @@ mod tests { } #[test] - fn independent_phases_preserve_declaration_order() { - let workflow = config(vec![ - phase("discover", &[], vec![task("scan")]), - phase("report", &[], vec![task("summarize")]), - ]); - - let plan = workflow.compile().expect("workflow should compile"); - - assert_eq!( - plan.phase_names().collect::>(), - vec!["discover", "report"] - ); - } - - #[test] - fn dependencies_override_declaration_order_deterministically() { - let workflow = config(vec![ - phase("review", &["implement"], vec![task("review-results")]), - phase("discover", &[], vec![task("scan")]), - phase("implement", &["discover"], vec![task("patch")]), - phase("report", &["review"], vec![task("summarize")]), - ]); - - let plan = workflow.compile().expect("workflow should compile"); - - assert_eq!( - plan.phase_names().collect::>(), - vec!["discover", "implement", "review", "report"] - ); - } - - #[test] - fn rejects_empty_workflow() { - let err = config(Vec::new()) - .validate() - .expect_err("empty workflow should fail"); - - assert_eq!(err, WorkflowValidationError::EmptyWorkflow); - } - - #[test] - fn rejects_empty_phase() { - let err = config(vec![phase("empty", &[], Vec::new())]) - .validate() - .expect_err("empty phase should fail"); - - assert_eq!( - err, - WorkflowValidationError::EmptyPhase { - phase: "empty".to_string() - } - ); - } - - #[test] - fn rejects_invalid_max_concurrent() { - let mut workflow = config(vec![phase("discover", &[], vec![task("scan")])]); - workflow.max_concurrent = 0; - - let err = workflow - .validate() - .expect_err("zero concurrency should fail"); - - assert_eq!( - err, - WorkflowValidationError::InvalidMaxConcurrent { value: 0 } - ); - } - - #[test] - fn rejects_duplicate_phase_names() { - let err = config(vec![ - phase("discover", &[], vec![task("scan")]), - phase("discover", &[], vec![task("scan-again")]), - ]) - .validate() - .expect_err("duplicate phase should fail"); - - assert!(matches!( - err, - WorkflowValidationError::DuplicatePhase { .. } - )); - } - - #[test] - fn rejects_duplicate_task_ids() { - let err = config(vec![ - phase("discover", &[], vec![task("scan")]), - phase("report", &[], vec![task("scan")]), - ]) - .validate() - .expect_err("duplicate task should fail"); - - assert!(matches!(err, WorkflowValidationError::DuplicateTask { .. })); - } - - #[test] - fn rejects_unknown_phase_dependency() { - let err = config(vec![phase("report", &["missing"], vec![task("summarize")])]) - .validate() - .expect_err("unknown dependency should fail"); - - assert!(matches!( - err, - WorkflowValidationError::InvalidPhaseDependency { .. } - )); - } - - #[test] - fn rejects_phase_dependency_cycles() { - let workflow = config(vec![ - phase("a", &["b"], vec![task("a-task")]), - phase("b", &["a"], vec![task("b-task")]), - ]); - - let err = workflow.validate().expect_err("cycle should fail"); - - assert!(matches!( - err, - WorkflowValidationError::PhaseDependencyCycle { .. } - )); - } - - #[test] - fn rejects_task_result_dependency_from_same_parallel_phase() { - let mut first = task("first"); - first.depends_on_results.push("second".to_string()); - let mut parallel = phase("parallel", &[], vec![first, task("second")]); - parallel.parallel = true; - - let err = config(vec![parallel]) - .validate() - .expect_err("same-phase result dependency should fail"); - - assert!(matches!( - err, - WorkflowValidationError::UnavailableTaskResultDependency { .. } - )); - } - - #[test] - fn rejects_task_result_dependency_from_later_phase() { - let mut summarize = task("summarize"); - summarize.depends_on_results.push("scan".to_string()); - let workflow = config(vec![ - phase("report", &[], vec![summarize]), - phase("discover", &[], vec![task("scan")]), - ]); - - let err = workflow - .validate() - .expect_err("later-phase result dependency should fail"); - - assert!(matches!( - err, - WorkflowValidationError::UnavailableTaskResultDependency { .. } - )); - } - - #[test] - fn allows_task_result_dependency_from_earlier_phase() { - let upstream = phase("discover", &[], vec![task("scan")]); - let mut summarize = task("summarize"); - summarize.depends_on_results.push("scan".to_string()); - let downstream = phase("report", &["discover"], vec![summarize]); - - config(vec![upstream, downstream]) - .validate() - .expect("earlier-phase result should be available"); - } - - #[test] - fn rejects_parallel_read_write_without_file_scope() { - let mut write = task("write"); - write.mode = TaskMode::ReadWrite; - let mut parallel = phase("parallel", &[], vec![write]); - parallel.parallel = true; - - let err = config(vec![parallel]) - .validate() - .expect_err("write task needs a scope"); - - assert!(matches!( - err, - WorkflowValidationError::MissingParallelWriteScope { .. } - )); - } - - #[test] - fn detects_overlapping_parallel_write_scopes_with_path_boundaries() { - let mut left = task("auth"); - left.mode = TaskMode::ReadWrite; - left.file_scope = vec!["src/auth/**".to_string()]; - let mut right = task("auth-login"); - right.mode = TaskMode::ReadWrite; - right.file_scope = vec!["src/auth/login.rs".to_string()]; - let mut parallel = phase("parallel", &[], vec![left, right]); - parallel.parallel = true; - - let err = config(vec![parallel]) - .validate() - .expect_err("nested scopes should overlap"); - - assert!(matches!( - err, - WorkflowValidationError::OverlappingParallelWriteScope { .. } - )); - } - - #[test] - fn does_not_confuse_path_prefixes_for_overlapping_scopes() { - let mut left = task("auth"); - left.mode = TaskMode::ReadWrite; - left.file_scope = vec!["src/auth/**".to_string()]; - let mut right = task("auth-admin"); - right.mode = TaskMode::ReadWrite; - right.file_scope = vec!["src/auth_admin/**".to_string()]; - let mut parallel = phase("parallel", &[], vec![left, right]); - parallel.parallel = true; - - config(vec![parallel]) - .validate() - .expect("component boundary scopes should not overlap"); - } + fn json_roundtrip_keeps_snake_case_enum_names() { + let leaf = LeafSpec { + id: "patch".to_string(), + prompt: "patch one safe gap".to_string(), + agent_type: AgentType::Implementer, + role: None, + profile: None, + mode: TaskMode::ReadWrite, + isolation: IsolationMode::Worktree, + file_scope: vec!["src/auth/**".to_string()], + depends_on_results: Vec::new(), + budget: BudgetSpec::default(), + permissions: PermissionSpec::default(), + model_policy: ModelPolicy::default(), + }; - #[test] - fn json_roundtrip_keeps_snake_case_enum_names() { - let mut task = task("patch"); - task.agent_type = AgentType::Implementer; - task.mode = TaskMode::ReadWrite; - task.isolation = IsolationMode::Worktree; - task.file_scope = vec!["src/auth/**".to_string()]; - let mut parallel = phase("implement", &[], vec![task]); - parallel.parallel = true; - parallel.on_failure = FailurePolicy::Abort; - let workflow = config(vec![parallel]); - - let json = serde_json::to_string(&workflow).expect("serialize workflow"); + let json = serde_json::to_string(&leaf).expect("serialize leaf"); assert!(json.contains("\"agent_type\":\"implement\"")); assert!(json.contains("\"mode\":\"read_write\"")); assert!(json.contains("\"isolation\":\"worktree\"")); - assert!(json.contains("\"on_failure\":\"abort\"")); - let parsed: WorkflowConfig = serde_json::from_str(&json).expect("parse workflow"); - assert_eq!(parsed, workflow); + let parsed: LeafSpec = serde_json::from_str(&json).expect("parse leaf"); + assert_eq!(parsed, leaf); } #[test] @@ -2853,149 +1543,6 @@ mod tests { assert!(!legacy_json.contains("profile")); } - #[test] - fn fleet_validation_accepts_one_thousand_agents_and_variable_models() { - let nodes = (0..DEFAULT_FLEET_WORKFLOW_MAX_AGENTS) - .map(|index| { - let mut leaf = match leaf_node(&format!("agent-{index}")) { - WorkflowNode::Leaf(leaf) => leaf, - _ => unreachable!("leaf helper returns a leaf"), - }; - leaf.model_policy = if index == 0 { - ModelPolicy { - provider: Some("deepseek".to_string()), - model: Some("deepseek-v4-pro".to_string()), - fallback_models: Vec::new(), - } - } else { - ModelPolicy { - provider: Some("deepseek".to_string()), - model: Some("deepseek-v4-flash".to_string()), - fallback_models: Vec::new(), - } - }; - WorkflowNode::Leaf(leaf) - }) - .collect(); - let workflow = workflow_spec(nodes); - - let shape = workflow - .validate_for_fleet() - .expect("one thousand agents should fit the Fleet Workflow limit"); - - assert_eq!(shape.total_agents, DEFAULT_FLEET_WORKFLOW_MAX_AGENTS); - assert_eq!(shape.max_depth, 1); - } - - #[test] - fn fleet_validation_rejects_more_than_one_thousand_agents() { - let nodes = (0..=DEFAULT_FLEET_WORKFLOW_MAX_AGENTS) - .map(|index| leaf_node(&format!("agent-{index}"))) - .collect(); - let workflow = workflow_spec(nodes); - - let err = workflow - .validate_for_fleet() - .expect_err("agent population should be bounded before Fleet launch"); - - assert_eq!( - err, - WorkflowFleetLimitError::TooManyAgents { - total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS + 1, - max_total_agents: DEFAULT_FLEET_WORKFLOW_MAX_AGENTS, - } - ); - } - - #[test] - fn fleet_validation_rejects_depth_beyond_five() { - let mut node = leaf_node("deep-leaf"); - for depth in (0..DEFAULT_FLEET_WORKFLOW_MAX_DEPTH).rev() { - node = WorkflowNode::BranchSet(BranchSpec { - id: format!("ring-{depth}"), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![node], - }); - } - let workflow = workflow_spec(vec![node]); - - let err = workflow - .validate_for_fleet() - .expect_err("sixth agent ring should be rejected"); - - assert_eq!( - err, - WorkflowFleetLimitError::RecursionTooDeep { - depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH + 1, - max_depth: DEFAULT_FLEET_WORKFLOW_MAX_DEPTH, - } - ); - } - - #[test] - fn fleet_validation_counts_loop_and_expand_fanout_conservatively() { - let workflow = workflow_spec(vec![ - WorkflowNode::LoopUntil(LoopUntilSpec { - id: "retry-ring".to_string(), - condition: "verifier passes".to_string(), - max_iterations: Some(3), - children: vec![leaf_node("retry-worker")], - }), - WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "retry-ring".to_string(), - max_children: Some(4), - template: Some(Box::new(leaf_node("split-template"))), - }), - ]); - - let shape = workflow - .validate_for_fleet() - .expect("bounded loop and expand should validate"); - - assert_eq!(shape.total_agents, 7); - assert_eq!(shape.max_depth, 1); - } - - #[test] - fn fleet_validation_rejects_unbounded_loop_or_expand_before_launch() { - let workflow = workflow_spec(vec![ - WorkflowNode::LoopUntil(LoopUntilSpec { - id: "retry-ring".to_string(), - condition: "verifier passes".to_string(), - max_iterations: None, - children: vec![leaf_node("retry-worker")], - }), - WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "retry-ring".to_string(), - max_children: Some(4), - template: Some(Box::new(leaf_node("split-template"))), - }), - ]); - - assert!(matches!( - workflow.validate_for_fleet(), - Err(WorkflowFleetLimitError::UnboundedLoop { node }) if node == "retry-ring" - )); - - let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "retry-ring".to_string(), - max_children: None, - template: Some(Box::new(leaf_node("split-template"))), - })]); - - assert!(matches!( - workflow.validate_for_fleet(), - Err(WorkflowFleetLimitError::UnboundedExpand { node }) if node == "split" - )); - } - #[test] fn branch_result_serialization() { let result = BranchResult { @@ -3118,82 +1665,19 @@ mod tests { .expect("parse minimal control node result"); assert!(minimal.selected_children.is_empty()); assert_eq!(minimal.summary, None); - } - - #[test] - fn run_mock_three_branch_workflow() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node("scan-readme"), - leaf_node("scan-config"), - leaf_node("scan-tests"), - ], - })]); - - let mut executor = MockWorkflowExecutor::new(); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| result.leaf_id.as_str()) - .collect::>(), - vec!["scan-readme", "scan-config", "scan-tests"] - ); - assert_eq!(execution.branch_results.len(), 1); - assert_eq!(execution.branch_results[0].branch_id, "discover"); - assert_eq!( - control_result(&execution, "discover").selected_children, - vec!["scan-readme", "scan-config", "scan-tests"] - ); - } - - #[test] - fn mock_executor_surfaces_leaf_profile() { - let mut profiled_leaf = match leaf_node("review-change") { - WorkflowNode::Leaf(leaf) => leaf, - _ => unreachable!("leaf helper returns a leaf"), - }; - profiled_leaf.profile = Some("reviewer".to_string()); - let workflow = workflow_spec(vec![ - WorkflowNode::Leaf(profiled_leaf), - leaf_node("scan-readme"), - ]); - - let execution = MockWorkflowExecutor::new() - .run(&workflow) - .expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!( - execution.leaf_results[0].profile.as_deref(), - Some("reviewer") - ); - assert_eq!(execution.leaf_results[1].profile, None); - } - - #[test] - fn mock_executor_surfaces_leaf_role() { - let mut role_leaf = match leaf_node("scout-issue") { - WorkflowNode::Leaf(leaf) => leaf, - _ => unreachable!("leaf helper returns a leaf"), - }; - role_leaf.role = Some("scout".to_string()); - let workflow = workflow_spec(vec![WorkflowNode::Leaf(role_leaf)]); - - let execution = MockWorkflowExecutor::new() - .run(&workflow) - .expect("mock workflow should run"); - assert_eq!(execution.leaf_results[0].role.as_deref(), Some("scout")); + // ReplayDiverged has no in-repo producer since the replay executor + // was removed; the variant's snake_case spelling is the + // wire-compatibility promise its doc keeps for older payloads, so + // pin it in both directions. + let mut diverged = result.clone(); + diverged.status = WorkflowRunStatus::ReplayDiverged; + let diverged_json = + serde_json::to_string(&diverged).expect("serialize replay-diverged result"); + assert!(diverged_json.contains("\"status\":\"replay_diverged\"")); + let reparsed: ControlNodeResult = + serde_json::from_str(&diverged_json).expect("parse replay-diverged result"); + assert_eq!(reparsed.status, WorkflowRunStatus::ReplayDiverged); } #[test] @@ -3206,8 +1690,7 @@ mod tests { leaf.role = Some(bad.to_string()); let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]); - let err = MockWorkflowExecutor::new() - .run(&workflow) + let err = validate_workflow_nodes(&workflow.nodes) .expect_err("invalid role token should fail validation"); assert!( @@ -3253,8 +1736,7 @@ mod tests { leaf.profile = Some(bad.to_string()); let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]); - let err = MockWorkflowExecutor::new() - .run(&workflow) + let err = validate_workflow_nodes(&workflow.nodes) .expect_err("invalid profile token should fail validation"); assert!( @@ -3269,376 +1751,10 @@ mod tests { }; leaf.profile = Some("reviewer".to_string()); let workflow = workflow_spec(vec![WorkflowNode::Leaf(leaf)]); - MockWorkflowExecutor::new() - .run(&workflow) + validate_workflow_nodes(&workflow.nodes) .expect("valid profile token should pass validation"); } - #[test] - fn mock_executor_aggregates_leaf_usage() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")], - })]); - - let mut executor = MockWorkflowExecutor::new() - .with_leaf_outcome( - "scan-readme", - MockLeafOutcome::succeeded("readme ok").with_usage(WorkflowUsage { - input_tokens: Some(100), - output_tokens: Some(25), - cost_microusd: Some(500), - }), - ) - .with_leaf_outcome( - "scan-tests", - MockLeafOutcome::succeeded("tests ok").with_usage(WorkflowUsage { - input_tokens: Some(50), - output_tokens: Some(10), - cost_microusd: Some(250), - }), - ); - - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!( - execution.usage, - WorkflowUsage { - input_tokens: Some(150), - output_tokens: Some(35), - cost_microusd: Some(750), - } - ); - assert_eq!(execution.usage.total_tokens(), Some(185)); - assert_eq!(execution.branch_results[0].usage, execution.usage); - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| result.usage.cost_microusd) - .collect::>(), - vec![Some(500), Some(250)] - ); - } - - #[test] - fn mock_executor_aggregates_memo_usage() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "cache-branches".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![leaf_node("rlm-hit"), leaf_node("rlm-miss")], - })]); - - let mut executor = MockWorkflowExecutor::new() - .with_leaf_outcome( - "rlm-hit", - MockLeafOutcome::succeeded("memo hit").with_memo_usage(WorkflowMemoUsage { - armh_hits: 1, - armh_misses: 0, - armh_saved_estimated_tokens: 4096, - provider_prompt_cache_hits: 1, - provider_prompt_cache_misses: 0, - }), - ) - .with_leaf_outcome( - "rlm-miss", - MockLeafOutcome::succeeded("memo miss").with_memo_usage(WorkflowMemoUsage { - armh_hits: 0, - armh_misses: 1, - armh_saved_estimated_tokens: 0, - provider_prompt_cache_hits: 0, - provider_prompt_cache_misses: 1, - }), - ); - - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!( - execution.memo_usage, - WorkflowMemoUsage { - armh_hits: 1, - armh_misses: 1, - armh_saved_estimated_tokens: 4096, - provider_prompt_cache_hits: 1, - provider_prompt_cache_misses: 1, - } - ); - assert_eq!(execution.branch_results[0].memo_usage, execution.memo_usage); - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| (result.memo_usage.armh_hits, result.memo_usage.armh_misses)) - .collect::>(), - vec![(1, 0), (0, 1)] - ); - } - - #[test] - fn mock_executor_marks_cancelled_before_leaf() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![leaf_node("scan-readme"), leaf_node("scan-tests")], - })]); - - let mut executor = MockWorkflowExecutor::new().with_cancelled(); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Cancelled); - assert_eq!(execution.leaf_results.len(), 1); - assert_eq!( - execution.leaf_results[0].status, - WorkflowRunStatus::Cancelled - ); - assert_eq!( - execution.branch_results[0].status, - WorkflowRunStatus::Cancelled - ); - assert_eq!( - control_result(&execution, "discover").status, - WorkflowRunStatus::Cancelled - ); - } - - #[test] - fn mock_executor_stops_when_global_leaf_budget_is_exhausted() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node("scan-readme"), - leaf_node("scan-config"), - leaf_node("scan-tests"), - ], - })]); - - let mut executor = MockWorkflowExecutor::new().with_max_leaf_steps(1); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded); - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| (result.leaf_id.as_str(), result.status)) - .collect::>(), - vec![ - ("scan-readme", WorkflowRunStatus::Succeeded), - ("scan-config", WorkflowRunStatus::BudgetExceeded) - ] - ); - assert_eq!( - execution.branch_results[0].status, - WorkflowRunStatus::BudgetExceeded - ); - } - - #[test] - fn mock_executor_treats_zero_step_budgets_as_unbounded() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "verify".to_string(), - description: None, - parallel: false, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node_with_budget( - "run-tests", - BudgetSpec { - max_steps: Some(0), - timeout_secs: None, - max_parallel: None, - max_tokens: None, - }, - ), - leaf_node("summarize"), - ], - })]); - - let mut executor = MockWorkflowExecutor::new().with_max_leaf_steps(0); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!(execution.leaf_results.len(), 2); - assert!( - execution - .leaf_results - .iter() - .all(|result| result.status == WorkflowRunStatus::Succeeded) - ); - } - - #[test] - fn mock_executor_stops_when_global_token_budget_is_exhausted() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node("scan-readme"), - leaf_node("scan-config"), - leaf_node("scan-tests"), - ], - })]); - - // First leaf uses 600 tokens (300 in + 300 out); after the second leaf - // (500 tokens) the running total is 1100, exceeding the 1000-token - // global cap, so the third leaf hits the exhausted budget and halts the - // run. - let mut executor = MockWorkflowExecutor::new() - .with_max_leaf_tokens(1000) - .with_leaf_outcome( - "scan-readme", - MockLeafOutcome::succeeded("readme done").with_usage(WorkflowUsage { - input_tokens: Some(300), - output_tokens: Some(300), - cost_microusd: Some(0), - }), - ) - .with_leaf_outcome( - "scan-config", - MockLeafOutcome::succeeded("config done").with_usage(WorkflowUsage { - input_tokens: Some(250), - output_tokens: Some(250), - cost_microusd: Some(0), - }), - ); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded); - // Leaves 1+2 consume 1100 tokens, exhausting the 1000-token global cap. - // The third leaf is attempted, sees the budget already exceeded, and is - // recorded as BudgetExceeded — the same boundary-leaf behaviour used by - // step budgets (max_leaf_steps). The budget outcome carries no tokens, - // so total usage stays at 1100. - assert_eq!(execution.leaf_results.len(), 3); - assert_eq!( - execution.leaf_results[0].status, - WorkflowRunStatus::Succeeded - ); - assert_eq!( - execution.leaf_results[1].status, - WorkflowRunStatus::Succeeded - ); - assert_eq!( - execution.leaf_results[2].status, - WorkflowRunStatus::BudgetExceeded - ); - assert_eq!(execution.usage.total_tokens(), Some(1100)); - } - - #[test] - fn mock_executor_honors_zero_token_leaf_budget() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "verify".to_string(), - description: None, - parallel: false, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node_with_budget( - "run-tests", - BudgetSpec { - max_steps: None, - timeout_secs: None, - max_parallel: None, - max_tokens: Some(0), - }, - ), - leaf_node("summarize"), - ], - })]); - - let mut executor = MockWorkflowExecutor::new(); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded); - assert_eq!(execution.leaf_results.len(), 1); - assert_eq!( - execution.leaf_results[0].status, - WorkflowRunStatus::BudgetExceeded - ); - assert!( - execution.leaf_results[0] - .output - .as_deref() - .unwrap_or_default() - .contains("token budget exhausted") - ); - } - - #[test] - fn mock_executor_honors_per_leaf_token_cap() { - let workflow = workflow_spec(vec![WorkflowNode::BranchSet(BranchSpec { - id: "review".to_string(), - description: None, - parallel: false, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - leaf_node_with_budget( - "expensive-scan", - BudgetSpec { - max_steps: None, - timeout_secs: None, - max_parallel: None, - max_tokens: Some(500), - }, - ), - leaf_node("summarize"), - ], - })]); - - // The leaf outcome uses 800 tokens which exceeds the per-leaf cap of 500. - let mut executor = MockWorkflowExecutor::new().with_leaf_outcome( - "expensive-scan", - MockLeafOutcome::succeeded("scan done").with_usage(WorkflowUsage { - input_tokens: Some(500), - output_tokens: Some(300), - cost_microusd: Some(0), - }), - ); - let execution = executor.run(&workflow).expect("mock workflow should run"); - - assert_eq!(execution.status, WorkflowRunStatus::BudgetExceeded); - assert_eq!(execution.leaf_results.len(), 1); - assert_eq!( - execution.leaf_results[0].status, - WorkflowRunStatus::BudgetExceeded - ); - assert!( - execution.leaf_results[0] - .output - .as_deref() - .unwrap_or_default() - .contains("token budget exhausted") - ); - } - #[test] fn budget_spec_serializes_max_tokens() { let budget = BudgetSpec { @@ -3661,115 +1777,9 @@ mod tests { assert!(parsed_default.max_tokens.is_none()); } - #[test] - fn loop_until_stops_on_pass() { - let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec { - id: "verify".to_string(), - condition: "verification passed".to_string(), - max_iterations: Some(5), - children: vec![leaf_node("run-check")], - })]); - - let mut executor = - MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]); - let execution = executor.run(&workflow).expect("loop should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!(execution.leaf_results.len(), 3); - assert_eq!( - control_result(&execution, "verify").summary.as_deref(), - Some("loop_until iterations=3") - ); - } - - #[test] - fn loop_until_honors_max_iters() { - let workflow = workflow_spec(vec![WorkflowNode::LoopUntil(LoopUntilSpec { - id: "verify".to_string(), - condition: "verification passed".to_string(), - max_iterations: Some(2), - children: vec![leaf_node("run-check")], - })]); - - let mut executor = - MockWorkflowExecutor::new().with_predicate_results("verify", vec![false, false, true]); - let execution = executor.run(&workflow).expect("loop should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Failed); - assert_eq!(execution.leaf_results.len(), 2); - assert_eq!( - control_result(&execution, "verify").summary.as_deref(), - Some("loop_until iterations=2") - ); - } - - #[test] - fn cond_uses_logged_predicate_result() { - let workflow = workflow_spec(vec![WorkflowNode::Cond(CondSpec { - id: "should-fix".to_string(), - condition: "finding requires a patch".to_string(), - then_nodes: vec![leaf_node("patch")], - else_nodes: vec![leaf_node("report-only")], - })]); - - let mut executor = - MockWorkflowExecutor::new().with_predicate_results("should-fix", vec![true]); - let execution = executor.run(&workflow).expect("cond should run"); - - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| result.leaf_id.as_str()) - .collect::>(), - vec!["patch"] - ); - assert_eq!( - control_result(&execution, "should-fix").summary.as_deref(), - Some("predicate_result=true") - ); - } - - #[test] - fn expand_respects_max_children() { - let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "plan".to_string(), - max_children: Some(2), - template: None, - })]); - - let generated = vec![leaf_node("first"), leaf_node("second"), leaf_node("third")]; - let mut executor = MockWorkflowExecutor::new().with_generated_nodes("split", generated); - let execution = executor.run(&workflow).expect("expand should run"); - - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| result.leaf_id.as_str()) - .collect::>(), - vec!["first", "second"] - ); - assert_eq!( - control_result(&execution, "split").selected_children, - vec!["first", "second"] - ); - } - #[test] fn expand_generated_nodes_validate_before_run() { - let workflow = workflow_spec(vec![WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "plan".to_string(), - max_children: None, - template: None, - })]); - - let mut executor = MockWorkflowExecutor::new() - .with_generated_nodes("split", vec![invalid_leaf_node("bad")]); - let err = executor - .run(&workflow) + let err = validate_workflow_nodes(&[invalid_leaf_node("bad")]) .expect_err("invalid generated leaf should fail before execution"); assert_eq!( @@ -3789,9 +1799,7 @@ mod tests { spec.depends_on_results = vec!["missing-scan".to_string()]; let workflow = workflow_spec(vec![summarize]); - let mut executor = MockWorkflowExecutor::new(); - let err = executor - .run(&workflow) + let err = validate_workflow_nodes(&workflow.nodes) .expect_err("unknown leaf dependency should fail before execution"); assert_eq!( @@ -3816,9 +1824,7 @@ mod tests { }), ]); - let mut executor = MockWorkflowExecutor::new(); - let err = executor - .run(&workflow) + let err = validate_workflow_nodes(&workflow.nodes) .expect_err("unknown reduce input should fail before execution"); assert_eq!( @@ -3842,9 +1848,7 @@ mod tests { }), ]); - let mut executor = MockWorkflowExecutor::new(); - let err = executor - .run(&workflow) + let err = validate_workflow_nodes(&workflow.nodes) .expect_err("unknown teacher candidate should fail before execution"); assert_eq!( diff --git a/crates/workflow/src/named_fleet.rs b/crates/workflow/src/named_fleet.rs index 076de53c0a..8780b84d75 100644 --- a/crates/workflow/src/named_fleet.rs +++ b/crates/workflow/src/named_fleet.rs @@ -353,8 +353,8 @@ pub(crate) fn content_hash(text: &str) -> String { sha256_label(text.as_bytes()) } -/// `sha256:` over arbitrary bytes. Mirrors the hex helper in `replay.rs` -/// rather than relying on a digest `LowerHex` impl. +/// `sha256:` over arbitrary bytes, hex-formatted manually rather than +/// relying on a digest `LowerHex` impl. pub(crate) fn sha256_label(bytes: &[u8]) -> String { use std::fmt::Write as _; diff --git a/crates/workflow/src/redaction.rs b/crates/workflow/src/redaction.rs index cf00eead33..841a878c1e 100644 --- a/crates/workflow/src/redaction.rs +++ b/crates/workflow/src/redaction.rs @@ -570,16 +570,6 @@ fn looks_relative(candidate: &str) -> bool { && extension.chars().all(|ch| ch.is_ascii_alphabetic()) } -/// Whether a string contains anything this module would redact. -/// -/// Used by assertions and by durable-write guards that must fail loudly rather -/// than persist a path or a key. -#[must_use] -#[cfg(test)] -pub fn contains_redactable(input: &str) -> bool { - redact_for_disclosure(input).redacted() -} - #[cfg(test)] mod tests { use super::*; @@ -917,11 +907,4 @@ mod tests { let redaction = redact_for_disclosure("look in crates/tui/src"); assert!(!redaction.redacted()); } - - #[test] - fn contains_redactable_matches_the_redactor() { - assert!(contains_redactable("/Users/hunter")); - assert!(contains_redactable("token=abc")); - assert!(!contains_redactable("land a fix in the workflow crate")); - } } diff --git a/crates/workflow/src/replay.rs b/crates/workflow/src/replay.rs deleted file mode 100644 index 3c104a8e77..0000000000 --- a/crates/workflow/src/replay.rs +++ /dev/null @@ -1,836 +0,0 @@ -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use thiserror::Error; - -use crate::{ - BranchResult, BranchSpec, CondSpec, ControlNodeKind, ControlNodeResult, ExpandSpec, LeafResult, - LeafSpec, LoopUntilSpec, SequenceSpec, WorkflowExecution, WorkflowExecutionError, - WorkflowMemoUsage, WorkflowNode, WorkflowRunStatus, WorkflowSpec, WorkflowUsage, - validate_workflow_node_shapes, validate_workflow_nodes, -}; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct ReplayOptions { - #[serde(default)] - pub allow_live_replay: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkflowReplayTrace { - pub trace_id: String, - #[serde(default)] - pub leaf_records: Vec, - #[serde(default)] - pub control_records: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReplayLeafRecord { - pub trace_id: String, - pub leaf_id: String, - pub input_hash: String, - pub result: LeafResult, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReplayControlRecord { - pub trace_id: String, - pub node_id: String, - pub kind: ControlNodeKind, - pub result: ControlNodeResult, - #[serde(default)] - pub generated_nodes: Vec, -} - -#[derive(Debug, Clone)] -pub struct WorkflowReplayExecutor { - trace_id: String, - options: ReplayOptions, - leaf_records: BTreeMap, - control_records: BTreeMap, - resolved_outputs: BTreeMap>, -} - -impl WorkflowReplayExecutor { - pub fn new(trace: WorkflowReplayTrace) -> Self { - Self::with_options(trace, ReplayOptions::default()) - } - - pub fn with_options(trace: WorkflowReplayTrace, options: ReplayOptions) -> Self { - let trace_id = trace.trace_id; - let leaf_records = trace - .leaf_records - .into_iter() - .map(|record| { - ( - ReplayLeafKey { - trace_id: record.trace_id, - leaf_id: record.leaf_id, - input_hash: record.input_hash, - }, - record.result, - ) - }) - .collect(); - let control_records = trace - .control_records - .into_iter() - .map(|record| { - ( - ReplayControlKey { - trace_id: record.trace_id.clone(), - node_id: record.node_id.clone(), - kind: record.kind, - }, - record, - ) - }) - .collect(); - - Self { - trace_id, - options, - leaf_records, - control_records, - resolved_outputs: BTreeMap::new(), - } - } - - pub fn run(&mut self, spec: &WorkflowSpec) -> Result { - validate_workflow_nodes(&spec.nodes)?; - let mut execution = WorkflowExecution::default(); - self.execute_nodes(spec, &spec.nodes, &mut execution)?; - Ok(execution) - } - - fn execute_nodes( - &mut self, - spec: &WorkflowSpec, - nodes: &[WorkflowNode], - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - for node in nodes { - self.execute_node(spec, node, execution)?; - } - Ok(()) - } - - fn execute_node( - &mut self, - spec: &WorkflowSpec, - node: &WorkflowNode, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - match node { - WorkflowNode::BranchSet(branch) => self.execute_branch_set(spec, branch, execution), - WorkflowNode::Leaf(leaf) => self.execute_leaf(spec, leaf, execution), - WorkflowNode::Sequence(sequence) => self.execute_sequence(spec, sequence, execution), - WorkflowNode::Reduce(reduce) => self.replay_recorded_control( - reduce.id.as_str(), - ControlNodeKind::Reduce, - execution, - Some(reduce.inputs.clone()), - Some(reduce.prompt.clone()), - ), - WorkflowNode::TeacherReview(review) => self.replay_recorded_control( - review.id.as_str(), - ControlNodeKind::TeacherReview, - execution, - Some(review.candidates.clone()), - Some("teacher review replayed from recorded candidates".to_string()), - ), - WorkflowNode::LoopUntil(loop_until) => { - self.execute_loop_until(spec, loop_until, execution) - } - WorkflowNode::Cond(cond) => self.execute_cond(spec, cond, execution), - WorkflowNode::Expand(expand) => self.execute_expand(spec, expand, execution), - } - } - - fn execute_branch_set( - &mut self, - spec: &WorkflowSpec, - branch: &BranchSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - let before = execution.leaf_results.len(); - self.execute_nodes(spec, &branch.children, execution)?; - let status = branch_status(&execution.leaf_results[before..]); - let mut usage = WorkflowUsage::default(); - let mut memo_usage = WorkflowMemoUsage::default(); - for (index, result) in execution.leaf_results[before..].iter().enumerate() { - if index == 0 { - usage = result.usage; - } else { - usage.add_assign(result.usage); - } - memo_usage.add_assign(result.memo_usage); - } - if status == WorkflowRunStatus::ReplayDiverged { - execution.mark_replay_diverged(); - } else if status == WorkflowRunStatus::Failed { - execution.mark_failed(); - } - execution.branch_results.push(BranchResult { - branch_id: branch.id.clone(), - task_id: branch.id.clone(), - status, - usage, - memo_usage, - artifacts: Vec::new(), - notes: Some("replay branch set evaluated from recorded leaf results".to_string()), - }); - self.replay_recorded_control( - branch.id.as_str(), - ControlNodeKind::BranchSet, - execution, - Some(branch.children.iter().map(workflow_node_id).collect()), - Some("branch set replayed declared children".to_string()), - ) - } - - fn execute_leaf( - &mut self, - spec: &WorkflowSpec, - leaf: &LeafSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - let inputs = resolved_inputs_for_leaf(leaf, &self.resolved_outputs); - let input_hash = compute_leaf_input_hash(spec, leaf, &inputs)?; - let key = ReplayLeafKey { - trace_id: self.trace_id.clone(), - leaf_id: leaf.id.clone(), - input_hash, - }; - - let Some(result) = self.leaf_records.get(&key).cloned() else { - if self.options.allow_live_replay { - return Err(WorkflowReplayError::LiveReplayUnavailable { - leaf: leaf.id.clone(), - }); - } - execution.mark_replay_diverged(); - let result = LeafResult { - leaf_id: leaf.id.clone(), - task_id: leaf.id.clone(), - role: leaf.role.clone(), - profile: leaf.profile.clone(), - status: WorkflowRunStatus::ReplayDiverged, - usage: WorkflowUsage::default(), - memo_usage: WorkflowMemoUsage::default(), - output: None, - artifacts: Vec::new(), - schema_error: None, - }; - self.resolved_outputs.insert(leaf.id.clone(), None); - if execution.leaf_results.is_empty() { - execution.usage = result.usage; - } else { - execution.usage.add_assign(result.usage); - } - execution.leaf_results.push(result); - return Ok(()); - }; - - if result.status == WorkflowRunStatus::ReplayDiverged { - execution.mark_replay_diverged(); - } else if result.status == WorkflowRunStatus::Failed { - execution.mark_failed(); - } - if execution.leaf_results.is_empty() { - execution.usage = result.usage; - } else { - execution.usage.add_assign(result.usage); - } - execution.memo_usage.add_assign(result.memo_usage); - self.resolved_outputs - .insert(leaf.id.clone(), result.output.clone()); - execution.leaf_results.push(result); - Ok(()) - } - - fn execute_sequence( - &mut self, - spec: &WorkflowSpec, - sequence: &SequenceSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - self.execute_nodes(spec, &sequence.children, execution)?; - self.replay_recorded_control( - sequence.id.as_str(), - ControlNodeKind::Sequence, - execution, - Some(sequence.children.iter().map(workflow_node_id).collect()), - Some("sequence replayed in declaration order".to_string()), - ) - } - - fn execute_loop_until( - &mut self, - spec: &WorkflowSpec, - loop_until: &LoopUntilSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - let record = self.control_record(loop_until.id.as_str(), ControlNodeKind::LoopUntil); - let selected = record - .as_ref() - .map(|record| record.result.selected_children.clone()) - .unwrap_or_else(|| loop_until.children.iter().map(workflow_node_id).collect()); - let children = select_nodes(&loop_until.children, &selected); - self.execute_nodes(spec, &children, execution)?; - self.push_control_or_diverge( - loop_until.id.as_str(), - ControlNodeKind::LoopUntil, - execution, - record, - Some(selected), - Some("loop_until replayed recorded child selection".to_string()), - ); - Ok(()) - } - - fn execute_cond( - &mut self, - spec: &WorkflowSpec, - cond: &CondSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - let record = self.control_record(cond.id.as_str(), ControlNodeKind::Cond); - let selected = record - .as_ref() - .map(|record| record.result.selected_children.clone()) - .unwrap_or_default(); - let available = cond - .then_nodes - .iter() - .chain(cond.else_nodes.iter()) - .cloned() - .collect::>(); - let nodes = select_nodes(&available, &selected); - self.execute_nodes(spec, &nodes, execution)?; - self.push_control_or_diverge( - cond.id.as_str(), - ControlNodeKind::Cond, - execution, - record, - Some(selected), - Some("cond replayed recorded branch selection".to_string()), - ); - Ok(()) - } - - fn execute_expand( - &mut self, - spec: &WorkflowSpec, - expand: &ExpandSpec, - execution: &mut WorkflowExecution, - ) -> Result<(), WorkflowReplayError> { - let record = self.control_record(expand.id.as_str(), ControlNodeKind::Expand); - let generated_nodes = record - .as_ref() - .map(|record| record.generated_nodes.clone()) - .unwrap_or_default(); - validate_workflow_node_shapes(&generated_nodes)?; - self.execute_nodes(spec, &generated_nodes, execution)?; - let selected = record - .as_ref() - .map(|record| record.result.selected_children.clone()) - .unwrap_or_else(|| generated_nodes.iter().map(workflow_node_id).collect()); - self.push_control_or_diverge( - expand.id.as_str(), - ControlNodeKind::Expand, - execution, - record, - Some(selected), - Some(format!( - "expand replayed recorded nodes from {}", - expand.source - )), - ); - Ok(()) - } - - fn replay_recorded_control( - &self, - node_id: &str, - kind: ControlNodeKind, - execution: &mut WorkflowExecution, - fallback_children: Option>, - fallback_summary: Option, - ) -> Result<(), WorkflowReplayError> { - let record = self.control_record(node_id, kind); - self.push_control_or_diverge( - node_id, - kind, - execution, - record, - fallback_children, - fallback_summary, - ); - Ok(()) - } - - fn control_record(&self, node_id: &str, kind: ControlNodeKind) -> Option { - self.control_records - .get(&ReplayControlKey { - trace_id: self.trace_id.clone(), - node_id: node_id.to_string(), - kind, - }) - .cloned() - } - - fn push_control_or_diverge( - &self, - node_id: &str, - kind: ControlNodeKind, - execution: &mut WorkflowExecution, - record: Option, - fallback_children: Option>, - fallback_summary: Option, - ) { - let Some(record) = record else { - execution.mark_replay_diverged(); - execution.control_node_results.push(ControlNodeResult { - node_id: node_id.to_string(), - kind, - status: WorkflowRunStatus::ReplayDiverged, - selected_children: fallback_children.unwrap_or_default(), - summary: fallback_summary - .or_else(|| Some("missing replay control record".to_string())), - }); - return; - }; - if record.result.status == WorkflowRunStatus::ReplayDiverged { - execution.mark_replay_diverged(); - } else if record.result.status == WorkflowRunStatus::Failed { - execution.mark_failed(); - } - execution.control_node_results.push(record.result); - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ReplayLeafKey { - trace_id: String, - leaf_id: String, - input_hash: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -struct ReplayControlKey { - trace_id: String, - node_id: String, - kind: ControlNodeKind, -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum WorkflowReplayError { - #[error(transparent)] - Validation(#[from] WorkflowExecutionError), - #[error("live replay requested for leaf `{leaf}`, but no live replay provider is configured")] - LiveReplayUnavailable { leaf: String }, - #[error("failed to compute replay input hash: {reason}")] - InputHash { reason: String }, -} - -pub fn compute_leaf_input_hash( - spec: &WorkflowSpec, - leaf: &LeafSpec, - resolved_inputs: &BTreeMap>, -) -> Result { - let input = ReplayLeafInput { - workflow_id: spec.id.as_deref(), - workflow_goal: spec.goal.as_str(), - leaf, - resolved_inputs, - }; - let bytes = serde_json::to_vec(&input).map_err(|error| WorkflowReplayError::InputHash { - reason: error.to_string(), - })?; - let digest = Sha256::digest(bytes); - Ok(hex_bytes(digest)) -} - -fn hex_bytes(bytes: impl AsRef<[u8]>) -> String { - let bytes = bytes.as_ref(); - let mut out = String::with_capacity(bytes.len() * 2); - for byte in bytes { - use std::fmt::Write as _; - let _ = write!(&mut out, "{byte:02x}"); - } - out -} - -#[derive(Serialize)] -struct ReplayLeafInput<'a> { - workflow_id: Option<&'a str>, - workflow_goal: &'a str, - leaf: &'a LeafSpec, - resolved_inputs: &'a BTreeMap>, -} - -fn resolved_inputs_for_leaf( - leaf: &LeafSpec, - resolved_outputs: &BTreeMap>, -) -> BTreeMap> { - leaf.depends_on_results - .iter() - .map(|dependency| { - ( - dependency.clone(), - resolved_outputs.get(dependency).cloned().unwrap_or(None), - ) - }) - .collect() -} - -fn branch_status(results: &[LeafResult]) -> WorkflowRunStatus { - if results - .iter() - .any(|result| result.status == WorkflowRunStatus::ReplayDiverged) - { - WorkflowRunStatus::ReplayDiverged - } else if results - .iter() - .any(|result| result.status != WorkflowRunStatus::Succeeded) - { - WorkflowRunStatus::Failed - } else { - WorkflowRunStatus::Succeeded - } -} - -fn select_nodes(nodes: &[WorkflowNode], selected: &[String]) -> Vec { - let by_id: BTreeMap<_, _> = nodes - .iter() - .map(|node| (workflow_node_id(node), node.clone())) - .collect(); - selected - .iter() - .filter_map(|id| by_id.get(id).cloned()) - .collect() -} - -fn workflow_node_id(node: &WorkflowNode) -> String { - match node { - WorkflowNode::BranchSet(spec) => spec.id.clone(), - WorkflowNode::Leaf(spec) => spec.id.clone(), - WorkflowNode::Sequence(spec) => spec.id.clone(), - WorkflowNode::Reduce(spec) => spec.id.clone(), - WorkflowNode::TeacherReview(spec) => spec.id.clone(), - WorkflowNode::LoopUntil(spec) => spec.id.clone(), - WorkflowNode::Cond(spec) => spec.id.clone(), - WorkflowNode::Expand(spec) => spec.id.clone(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{ - AgentType, BudgetSpec, CondSpec, ControlNodeKind, ControlNodeResult, ExpandSpec, LeafSpec, - ModelPolicy, PermissionSpec, TaskMode, - }; - - fn leaf(id: &str) -> LeafSpec { - LeafSpec { - id: id.to_string(), - prompt: format!("run {id}"), - agent_type: AgentType::General, - role: None, - profile: None, - mode: TaskMode::ReadOnly, - isolation: crate::IsolationMode::Shared, - file_scope: Vec::new(), - depends_on_results: Vec::new(), - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - } - } - - fn leaf_node(id: &str) -> WorkflowNode { - WorkflowNode::Leaf(leaf(id)) - } - - fn workflow(nodes: Vec) -> WorkflowSpec { - WorkflowSpec { - id: Some("wf".to_string()), - goal: "replay safely".to_string(), - description: None, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - promotion_policy: crate::PromotionPolicy::default(), - gates: Vec::new(), - nodes, - } - } - - fn leaf_result(id: &str, output: &str) -> LeafResult { - LeafResult { - leaf_id: id.to_string(), - task_id: id.to_string(), - role: None, - profile: None, - status: WorkflowRunStatus::Succeeded, - usage: WorkflowUsage { - input_tokens: Some(10), - output_tokens: Some(5), - cost_microusd: Some(2), - }, - memo_usage: WorkflowMemoUsage::default(), - output: Some(output.to_string()), - artifacts: Vec::new(), - schema_error: None, - } - } - - fn leaf_record(spec: &WorkflowSpec, leaf: &LeafSpec, result: LeafResult) -> ReplayLeafRecord { - ReplayLeafRecord { - trace_id: "trace-1".to_string(), - leaf_id: leaf.id.clone(), - input_hash: compute_leaf_input_hash(spec, leaf, &BTreeMap::new()).unwrap(), - result, - } - } - - fn control_record( - id: &str, - kind: ControlNodeKind, - status: WorkflowRunStatus, - selected_children: Vec<&str>, - ) -> ReplayControlRecord { - ReplayControlRecord { - trace_id: "trace-1".to_string(), - node_id: id.to_string(), - kind, - result: ControlNodeResult { - node_id: id.to_string(), - kind, - status, - selected_children: selected_children.into_iter().map(str::to_string).collect(), - summary: Some("recorded".to_string()), - }, - generated_nodes: Vec::new(), - } - } - - #[test] - fn replay_uses_recorded_leaf_outputs_not_live_calls() { - let scan = leaf("scan"); - let spec = workflow(vec![WorkflowNode::Leaf(scan.clone())]); - let trace = WorkflowReplayTrace { - trace_id: "trace-1".to_string(), - leaf_records: vec![leaf_record( - &spec, - &scan, - leaf_result("scan", "recorded output"), - )], - control_records: Vec::new(), - }; - - let execution = WorkflowReplayExecutor::new(trace) - .run(&spec) - .expect("replay should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!( - execution.leaf_results[0].output.as_deref(), - Some("recorded output") - ); - assert_eq!(execution.usage.cost_microusd, Some(2)); - } - - #[test] - fn workflow_trace_can_replay_from_records() { - let scan = leaf("scan"); - let summarize = leaf("summarize"); - let spec = workflow(vec![WorkflowNode::BranchSet(BranchSpec { - id: "discover".to_string(), - description: None, - parallel: true, - budget: BudgetSpec::default(), - permissions: PermissionSpec::default(), - model_policy: ModelPolicy::default(), - children: vec![ - WorkflowNode::Leaf(scan.clone()), - WorkflowNode::Leaf(summarize.clone()), - ], - })]); - let trace = WorkflowReplayTrace { - trace_id: "trace-1".to_string(), - leaf_records: vec![ - leaf_record(&spec, &scan, leaf_result("scan", "scan ok")), - leaf_record(&spec, &summarize, leaf_result("summarize", "summary ok")), - ], - control_records: vec![control_record( - "discover", - ControlNodeKind::BranchSet, - WorkflowRunStatus::Succeeded, - vec!["scan", "summarize"], - )], - }; - - let execution = WorkflowReplayExecutor::new(trace) - .run(&spec) - .expect("replay should run"); - - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - assert_eq!(execution.leaf_results.len(), 2); - assert_eq!( - execution.branch_results[0].status, - WorkflowRunStatus::Succeeded - ); - assert_eq!(execution.branch_results[0].usage.cost_microusd, Some(4)); - assert_eq!(execution.usage.cost_microusd, Some(4)); - } - - #[test] - fn workflow_replay_diverges_on_missing_leaf_record() { - let spec = workflow(vec![leaf_node("scan")]); - let trace = WorkflowReplayTrace { - trace_id: "trace-1".to_string(), - leaf_records: Vec::new(), - control_records: Vec::new(), - }; - - let execution = WorkflowReplayExecutor::new(trace) - .run(&spec) - .expect("missing records should be reported as divergence"); - - assert_eq!(execution.status, WorkflowRunStatus::ReplayDiverged); - assert_eq!( - execution.leaf_results[0].status, - WorkflowRunStatus::ReplayDiverged - ); - assert_eq!(execution.leaf_results[0].output, None); - } - - #[test] - fn live_replay_requires_explicit_opt_in() { - let spec = workflow(vec![leaf_node("scan")]); - let trace = WorkflowReplayTrace { - trace_id: "trace-1".to_string(), - leaf_records: Vec::new(), - control_records: Vec::new(), - }; - let err = WorkflowReplayExecutor::with_options( - trace, - ReplayOptions { - allow_live_replay: true, - }, - ) - .run(&spec) - .expect_err("live replay cannot run without a configured provider"); - - assert!(matches!( - err, - WorkflowReplayError::LiveReplayUnavailable { .. } - )); - assert!(!ReplayOptions::default().allow_live_replay); - } - - #[test] - fn leaf_input_hash_is_stable_across_object_key_order() { - let mut downstream = leaf("summarize"); - downstream.depends_on_results = vec!["b".to_string(), "a".to_string()]; - let spec = workflow(vec![WorkflowNode::Leaf(downstream.clone())]); - let mut left = BTreeMap::new(); - left.insert("a".to_string(), Some("one".to_string())); - left.insert("b".to_string(), Some("two".to_string())); - let mut right = BTreeMap::new(); - right.insert("b".to_string(), Some("two".to_string())); - right.insert("a".to_string(), Some("one".to_string())); - - let left_hash = compute_leaf_input_hash(&spec, &downstream, &left).unwrap(); - let right_hash = compute_leaf_input_hash(&spec, &downstream, &right).unwrap(); - - assert_eq!(left_hash, right_hash); - } - - #[test] - fn leaf_input_hash_diverges_on_profile_change() { - let base = leaf("review"); - let mut profiled = base.clone(); - profiled.profile = Some("reviewer".to_string()); - let spec = workflow(vec![WorkflowNode::Leaf(base.clone())]); - - let base_hash = compute_leaf_input_hash(&spec, &base, &BTreeMap::new()).unwrap(); - let profiled_hash = compute_leaf_input_hash(&spec, &profiled, &BTreeMap::new()).unwrap(); - - assert_ne!(base_hash, profiled_hash); - } - - #[test] - fn replay_control_records_drive_cond_expand_loop_until() { - let patch = leaf("patch"); - let generated = leaf("generated-check"); - let spec = workflow(vec![ - WorkflowNode::Cond(CondSpec { - id: "choose".to_string(), - condition: "patch?".to_string(), - then_nodes: vec![WorkflowNode::Leaf(patch.clone())], - else_nodes: vec![leaf_node("report")], - }), - WorkflowNode::Expand(ExpandSpec { - id: "split".to_string(), - source: "choose".to_string(), - max_children: None, - template: None, - }), - WorkflowNode::LoopUntil(crate::LoopUntilSpec { - id: "verify".to_string(), - condition: "done".to_string(), - max_iterations: Some(3), - children: vec![leaf_node("unused-live-child")], - }), - ]); - let mut expand_record = control_record( - "split", - ControlNodeKind::Expand, - WorkflowRunStatus::Succeeded, - vec!["generated-check"], - ); - expand_record.generated_nodes = vec![WorkflowNode::Leaf(generated.clone())]; - let trace = WorkflowReplayTrace { - trace_id: "trace-1".to_string(), - leaf_records: vec![ - leaf_record(&spec, &patch, leaf_result("patch", "patched")), - leaf_record(&spec, &generated, leaf_result("generated-check", "checked")), - ], - control_records: vec![ - control_record( - "choose", - ControlNodeKind::Cond, - WorkflowRunStatus::Succeeded, - vec!["patch"], - ), - expand_record, - control_record( - "verify", - ControlNodeKind::LoopUntil, - WorkflowRunStatus::Succeeded, - Vec::new(), - ), - ], - }; - - let execution = WorkflowReplayExecutor::new(trace) - .run(&spec) - .expect("replay should run"); - - assert_eq!( - execution - .leaf_results - .iter() - .map(|result| result.leaf_id.as_str()) - .collect::>(), - vec!["patch", "generated-check"] - ); - assert_eq!(execution.status, WorkflowRunStatus::Succeeded); - } -} diff --git a/docs/examples/fleet-dogfood.toml b/docs/examples/fleet-dogfood.toml index 38f566efe6..40ed6d5374 100644 --- a/docs/examples/fleet-dogfood.toml +++ b/docs/examples/fleet-dogfood.toml @@ -46,7 +46,7 @@ tags = ["smoke", "check"] id = "protocol-review" name = "Protocol review" description = "Review fleet protocol types for security and correctness." -objective = "Inspect crates/protocol/src/fleet.rs and crates/secrets/src/lib.rs. Report any missing serde defaults, unsafe wire changes, or security-sensitive fields lacking SecretRef." +objective = "Inspect crates/protocol/src/fleet.rs and crates/secrets/src/lib.rs. Report any missing serde defaults, unsafe wire changes, or security-sensitive fields lacking FleetSecretRef." instructions = "Read crates/protocol/src/fleet.rs and crates/secrets/src/lib.rs. Check for: (1) new fields without serde(default) or skip_serializing_if, (2) raw secrets in struct fields instead of FleetSecretRef, (3) missing Clone/Debug/PartialEq derives on new types. Write a concise report with file:line references for each finding. If there are no findings, report 'all clear'." worker = { role = "reviewer", tool_profile = "read-only", tools = ["rg", "git", "cargo"], capabilities = ["rust"] } workspace = { required_files = ["crates/protocol/src/fleet.rs", "crates/secrets/src/lib.rs"], writable_paths = [".codewhale/fleet"], environment = { required = ["PATH"] } }