From 8334c9feac5962b6856dc4bbe60c3d4c82b97d38 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 09:59:47 +0800 Subject: [PATCH 01/18] test: drop verified worthless tests across crates Repo-wide test-cleanup sweep, CodeWhale part. A 240-agent audit of every test file classified worthless tests into three verified categories and this commit applies the audited, independently re-verified deletions: - C1 (never runs / can never fail / unreachable): empty-body and env-gated self-skipping tests, tautologies that re-derive their own expected values, no-assertion smokes, tests of cfg(test)-only unreachable wrappers. - C2 (test-only survivors): production items whose only remaining callers were tests, deleted together with their pins - whole dead modules (workflow model_policy/replay/review_repair, protocol workroom, core session/journal placeholders, computer_meter, notifications inbox scaffold, route authority), dead structs/enums and their serde arms, dead accessors/builders/state fields. - C3 (strictly subsumed): byte-identical or assertion-subset duplicates deleted in favor of their surviving dominators. Hook::new stays public: the parent pinvou3-tauri tests consume it cross-repo. The two no_new_privs linux_flag_tests failures pre-exist on the base commit on this host and are unrelated. Gates: cargo check --workspace --all-features; per-crate cargo test (config 631, protocol 75, execpolicy 119, mcp 76, secrets 62, state 24, workflow 211, workflow-js 85, agent 63, app-server 106, cli 6); codewhale-tui lib 11849+ passing (2 pre-existing host failures), fmt, fork-guard --fast. Signed-off-by: asto Signed-off-by: asto18089 --- crates/agent/src/lib.rs | 155 -- crates/app-server/src/lib.rs | 23 - crates/cli/src/config_bundles.rs | 66 - crates/cli/src/update.rs | 10 - crates/config/src/catalog.rs | 19 +- crates/config/src/catalog/tests.rs | 51 - crates/config/src/lib.rs | 16 +- crates/config/src/provider_templates.rs | 17 - crates/config/src/resolve.rs | 170 +- crates/config/src/route/auth.rs | 46 - crates/config/src/route/authority.rs | 309 --- crates/config/src/route/candidate.rs | 2 - crates/config/src/route/mod.rs | 6 - crates/config/src/route/policy.rs | 160 -- crates/config/src/setup_state.rs | 9 - crates/core/src/fragments.rs | 181 -- crates/core/src/journal.rs | 78 - crates/core/src/lib.rs | 193 -- crates/core/src/request.rs | 10 - crates/core/src/session.rs | 137 -- crates/execpolicy/src/lib.rs | 13 - crates/mcp/src/lib.rs | 31 - crates/protocol/src/lib.rs | 1 - crates/protocol/src/op.rs | 84 +- crates/protocol/src/runtime/mod.rs | 26 - crates/protocol/src/workroom.rs | 347 --- crates/secrets/src/lib.rs | 18 - crates/state/src/lib.rs | 253 +- crates/state/tests/parity_state.rs | 63 +- crates/tui/locales/ca.json | 1 - crates/tui/locales/de.json | 1 - crates/tui/locales/en.json | 1 - crates/tui/locales/es-419.json | 1 - crates/tui/locales/fr.json | 1 - crates/tui/locales/hi.json | 1 - crates/tui/locales/id.json | 1 - crates/tui/locales/ja.json | 1 - crates/tui/locales/ko.json | 1 - crates/tui/locales/pt-BR.json | 1 - crates/tui/locales/ru.json | 1 - crates/tui/locales/uk.json | 1 - crates/tui/locales/vi.json | 1 - crates/tui/locales/zh-Hans.json | 1 - crates/tui/locales/zh-Hant.json | 1 - crates/tui/src/client.rs | 142 +- crates/tui/src/client/anthropic.rs | 14 - .../src/client/provider_native_search/kimi.rs | 4 - crates/tui/src/client/wire.rs | 10 - crates/tui/src/cloud_dispatch.rs | 121 - crates/tui/src/command_safety.rs | 20 - .../tui/src/commands/groups/config/config.rs | 9 - .../tui/src/commands/groups/config/status.rs | 7 +- crates/tui/src/commands/groups/core/core.rs | 1 - crates/tui/src/commands/groups/core/hooks.rs | 32 - crates/tui/src/commands/groups/core/voice.rs | 6 - .../src/commands/groups/session/session.rs | 11 - crates/tui/src/commands/user_commands.rs | 17 - crates/tui/src/commands/user_registry.rs | 7 - crates/tui/src/compaction.rs | 26 - crates/tui/src/computer_meter.rs | 768 ------ .../src/computer_meter/fixtures/cases.json | 429 ---- crates/tui/src/computer_meter/tests.rs | 245 -- crates/tui/src/config.rs | 20 - crates/tui/src/core/engine.rs | 8 - crates/tui/src/core/engine/preview/tests.rs | 6 - .../src/core/engine/token_estimate_cache.rs | 176 +- crates/tui/src/core/engine/turn_loop.rs | 85 - crates/tui/src/dependencies.rs | 30 - crates/tui/src/dispatch_runner.rs | 12 +- crates/tui/src/error_taxonomy/tests.rs | 27 +- crates/tui/src/eval.rs | 54 - crates/tui/src/fleet/host.rs | 26 - crates/tui/src/fleet/profile.rs | 129 +- crates/tui/src/fleet/scheduler.rs | 56 - crates/tui/src/fleet/store.rs | 79 +- crates/tui/src/fleet/task_spec.rs | 19 +- crates/tui/src/fleet/worker_runtime.rs | 7 +- crates/tui/src/goal_loop.rs | 10 - crates/tui/src/hooks/config.rs | 10 +- crates/tui/src/hooks/executor.rs | 59 +- crates/tui/src/lib.rs | 36 - crates/tui/src/llm_client/mod.rs | 359 +-- crates/tui/src/localization.rs | 15 - crates/tui/src/mcp/external_import.rs | 50 - crates/tui/src/mcp/tests.rs | 15 +- crates/tui/src/mcp/wire.rs | 13 +- crates/tui/src/model_profile.rs | 51 +- crates/tui/src/models.rs | 89 - crates/tui/src/network_policy.rs | 141 +- crates/tui/src/palette/tests.rs | 35 +- crates/tui/src/palette/themes.rs | 8 - crates/tui/src/prefix_cache.rs | 94 +- crates/tui/src/project_context/types.rs | 36 - crates/tui/src/prompt_zones.rs | 273 +-- crates/tui/src/prompts.rs | 105 +- crates/tui/src/prompts/text.rs | 39 +- crates/tui/src/provider_lake.rs | 21 - crates/tui/src/remote_setup/bundle.rs | 19 - crates/tui/src/resource_telemetry.rs | 441 +--- crates/tui/src/route_billing.rs | 95 - crates/tui/src/route_runtime.rs | 116 +- crates/tui/src/runtime_api/tests.rs | 3 +- crates/tui/src/runtime_threads.rs | 59 - crates/tui/src/runtime_threads/tests.rs | 92 - crates/tui/src/sandbox/mod.rs | 9 - crates/tui/src/sandbox/seccomp.rs | 8 - crates/tui/src/session_control_acceptance.rs | 42 +- crates/tui/src/session_manager.rs | 25 - crates/tui/src/session_tree.rs | 45 - crates/tui/src/settings.rs | 31 - crates/tui/src/shell_dispatcher.rs | 50 - crates/tui/src/skill_state.rs | 26 - crates/tui/src/skills/audit.rs | 6 - crates/tui/src/skills/install.rs | 22 - crates/tui/src/skills/mod.rs | 2 +- crates/tui/src/snapshot/paths.rs | 9 - crates/tui/src/snapshot/repo.rs | 24 - crates/tui/src/tool_output_receipts.rs | 2 - crates/tui/src/tools/approval_cache.rs | 11 - crates/tui/src/tools/automation.rs | 204 +- crates/tui/src/tools/execution_envelope.rs | 34 - crates/tui/src/tools/large_output_router.rs | 33 +- crates/tui/src/tools/read_media.rs | 21 - crates/tui/src/tools/rlm.rs | 30 - crates/tui/src/tools/runtime_mcp.rs | 21 - crates/tui/src/tools/shell.rs | 37 - crates/tui/src/tools/shell/tests.rs | 14 - crates/tui/src/tools/tasks.rs | 218 +- crates/tui/src/tools/truncate.rs | 27 +- crates/tui/src/tools/verify.rs | 19 - crates/tui/src/tools/web_run.rs | 21 +- crates/tui/src/tools/web_search.rs | 45 - crates/tui/src/tui/active_cell.rs | 15 +- crates/tui/src/tui/app.rs | 52 - crates/tui/src/tui/app/init.rs | 1 - crates/tui/src/tui/app/tests.rs | 64 +- crates/tui/src/tui/app/types.rs | 7 - crates/tui/src/tui/approval/elevation.rs | 56 - crates/tui/src/tui/approval/tests.rs | 60 - crates/tui/src/tui/background_indicator.rs | 19 - crates/tui/src/tui/command_palette.rs | 32 - crates/tui/src/tui/display_refresh.rs | 9 - crates/tui/src/tui/file_mention.rs | 18 - crates/tui/src/tui/file_tree.rs | 24 - crates/tui/src/tui/footer_ui.rs | 21 - .../src/tui/goldens/notifications_100x30.txt | 30 - .../src/tui/goldens/notifications_120x32.txt | 32 - .../src/tui/goldens/notifications_160x40.txt | 40 - .../src/tui/goldens/notifications_80x24.txt | 24 - crates/tui/src/tui/history.rs | 1 - crates/tui/src/tui/history/tideline_stream.rs | 34 - .../src/tui/history/tideline_stream_tests.rs | 12 +- crates/tui/src/tui/hotbar/actions.rs | 188 -- crates/tui/src/tui/live_transcript.rs | 29 - crates/tui/src/tui/markdown_render.rs | 11 - crates/tui/src/tui/menu_style.rs | 49 - crates/tui/src/tui/notifications.rs | 262 --- .../src/tui/notifications/tideline_tests.rs | 181 -- crates/tui/src/tui/phase_strip.rs | 15 - crates/tui/src/tui/provider_picker.rs | 1 - crates/tui/src/tui/setup/remote.rs | 60 +- crates/tui/src/tui/sidebar.rs | 40 +- crates/tui/src/tui/tideline.rs | 59 +- crates/tui/src/tui/transcript_cache.rs | 17 - crates/tui/src/tui/ui/apply.rs | 1 - crates/tui/src/tui/ui/session_state.rs | 1 - crates/tui/src/tui/ui/tests.rs | 76 +- crates/tui/src/tui/underwater.rs | 575 +---- crates/tui/src/tui/views/extensions.rs | 11 - crates/tui/src/tui/views/mod.rs | 4 +- crates/tui/src/tui/views/skills_manager.rs | 2 +- crates/tui/src/tui/whales.rs | 84 +- crates/tui/src/tui/widgets/agent_card.rs | 114 +- crates/tui/src/tui/widgets/key_hint.rs | 5 - crates/tui/src/tui/widgets/mod.rs | 94 +- crates/tui/src/tui/widgets/workflow_panel.rs | 284 +-- crates/tui/src/tui/work_surface/model.rs | 13 +- .../src/tui/work_surface/tideline/tests.rs | 14 - crates/tui/src/working_set.rs | 23 - crates/tui/tests/README.md | 2 +- .../tests/cucumber/plugin_e2e_acceptance.rs | 379 +-- .../features/plugin_e2e_acceptance.feature | 34 - crates/tui/tests/integration/cache_guard.rs | 344 --- .../integration/coordination_acceptance.rs | 74 - .../tests/integration/integration_mock_llm.rs | 25 - crates/tui/tests/integration/main.rs | 3 - ...soning_content_replayed_after_tool_call.rs | 127 - crates/tui/tests/support/qa_harness/mod.rs | 1 - .../tui/tests/support/qa_harness/view_log.rs | 207 -- crates/workflow-js/tests/vm_tests.rs | 30 - crates/workflow/src/experimental_search.rs | 7 +- crates/workflow/src/fleet_preflight.rs | 16 +- crates/workflow/src/fleet_reasoning.rs | 5 - crates/workflow/src/fleet_snapshot.rs | 13 - crates/workflow/src/gates.rs | 160 +- crates/workflow/src/js_authoring.rs | 19 - crates/workflow/src/lib.rs | 2089 +---------------- crates/workflow/src/model_policy.rs | 654 ------ crates/workflow/src/redaction.rs | 17 - crates/workflow/src/replay.rs | 836 ------- crates/workflow/src/review_repair.rs | 890 ------- 201 files changed, 649 insertions(+), 16479 deletions(-) delete mode 100644 crates/config/src/route/authority.rs delete mode 100644 crates/config/src/route/policy.rs delete mode 100644 crates/core/src/journal.rs delete mode 100644 crates/core/src/session.rs delete mode 100644 crates/protocol/src/workroom.rs delete mode 100644 crates/tui/src/computer_meter.rs delete mode 100644 crates/tui/src/computer_meter/fixtures/cases.json delete mode 100644 crates/tui/src/computer_meter/tests.rs delete mode 100644 crates/tui/src/tui/goldens/notifications_100x30.txt delete mode 100644 crates/tui/src/tui/goldens/notifications_120x32.txt delete mode 100644 crates/tui/src/tui/goldens/notifications_160x40.txt delete mode 100644 crates/tui/src/tui/goldens/notifications_80x24.txt delete mode 100644 crates/tui/src/tui/notifications/tideline_tests.rs delete mode 100644 crates/tui/tests/features/plugin_e2e_acceptance.feature delete mode 100644 crates/tui/tests/integration/cache_guard.rs delete mode 100644 crates/tui/tests/integration/coordination_acceptance.rs delete mode 100644 crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs delete mode 100644 crates/tui/tests/support/qa_harness/view_log.rs delete mode 100644 crates/workflow/src/model_policy.rs delete mode 100644 crates/workflow/src/replay.rs delete mode 100644 crates/workflow/src/review_repair.rs 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/config_bundles.rs b/crates/cli/src/config_bundles.rs index 544d3b6d09..b4e3131f3b 100644 --- a/crates/cli/src/config_bundles.rs +++ b/crates/cli/src/config_bundles.rs @@ -677,55 +677,6 @@ fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> { Ok(()) } -// --------------------------------------------------------------------------- -// Path safety -// --------------------------------------------------------------------------- - -/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink -/// escapes. Returns the resolved path or an error naming the refusal — the -/// candidate string itself is safe to echo (it is config data, not a secret). -/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink -/// escapes. Returns the joined path or an error naming the refusal. -/// Reserved for path-carrying bundle sections (none shipped yet); exercised -/// by the traversal tests so the contract cannot silently rot. -#[cfg_attr( - not(test), - expect(dead_code, reason = "path-carrying sections land with the next schema") -)] -pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result { - if candidate.contains('\0') { - bail!("bundle path contains a NUL byte; refused"); - } - let candidate_path = Path::new(candidate); - if candidate_path.is_absolute() { - bail!( - "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted" - ); - } - let canonical_base = base_dir - .canonicalize() - .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?; - let joined = base_dir.join(candidate_path); - // Walk the joined path's ancestors from the deepest existing component up: - // every existing component must canonicalize inside the base, so a symlink - // pointing outside the config directory is refused even when the final - // target does not exist yet. - let deepest_existing = joined - .ancestors() - .find(|ancestor| ancestor.symlink_metadata().is_ok()) - .context("bundle path has no existing ancestor inside the config directory")?; - let resolved = deepest_existing.canonicalize().with_context(|| { - format!( - "could not resolve bundle path component {}", - deepest_existing.display() - ) - })?; - if !resolved.starts_with(&canonical_base) { - bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused"); - } - Ok(joined) -} - // --------------------------------------------------------------------------- // Remote fetch // --------------------------------------------------------------------------- @@ -3430,23 +3381,6 @@ label = "safe-nested-workspace-label" require_import_consent(true, &plan).expect("--yes short-circuits consent"); } - #[test] - fn bounded_paths_refuse_traversal_and_absolute_escapes() { - let base = tempfile::tempdir().expect("base"); - let err = - resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused"); - assert!( - err.to_string().contains("escapes") || err.to_string().contains("absolute"), - "{err:#}" - ); - let absolute = base.path().join("absolute.toml"); - let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref()) - .expect_err("absolute refused"); - assert!(err.to_string().contains("absolute"), "{err:#}"); - let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok"); - assert!(ok.starts_with(base.path())); - } - // -- helpers ------------------------------------------------------------ fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec { 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/catalog.rs b/crates/config/src/catalog.rs index 0f6ea076a5..2c676fe3f1 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -628,8 +628,7 @@ impl CatalogSnapshot { /// Builds a [`CatalogSnapshot`] by merging layers in precedence order. /// -/// Last writer wins per `(provider, wire id)` field. Policy DENY is applied -/// after every layer and is never overridden: +/// Last writer wins per `(provider, wire id)` field: /// /// ```text /// 0 bundled committed models.dev-shaped snapshot @@ -639,7 +638,6 @@ impl CatalogSnapshot { /// 25 codewhale live signed CWC catalog (authority) /// 30 config config.toml [providers.*] overrides /// 40 user user approved set -/// policy DENY last, never overridden /// ``` /// /// [`Self::with_live`] remains the combined live bucket so existing callers @@ -655,7 +653,6 @@ pub struct CatalogCompiler { codewhale_live: Vec, config: Vec, overrides: Vec, - policy: crate::route::CatalogPolicy, } impl CatalogCompiler { @@ -733,14 +730,7 @@ impl CatalogCompiler { self } - /// Attach policy evaluated after every layer. DENY is never overridden. - #[must_use] - pub fn with_policy(mut self, policy: crate::route::CatalogPolicy) -> Self { - self.policy = policy; - self - } - - /// Merge all layers into a deterministic snapshot, then apply policy DENY. + /// Merge all layers into a deterministic snapshot. #[must_use] pub fn compile(self) -> CatalogSnapshot { let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); @@ -757,10 +747,7 @@ impl CatalogCompiler { { merged.insert(row.merge_key(), row); } - let offerings = merged - .into_values() - .filter(|row| self.policy.allows(&row.provider, &row.wire_model_id)) - .collect(); + let offerings = merged.into_values().collect(); CatalogSnapshot { offerings } } } diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index ff556e2164..5d1636fff0 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -204,57 +204,6 @@ fn compiler_merges_layers_with_override_precedence() { assert!(matches!(two.source, CatalogSource::Live { .. })); } -#[test] -fn compiler_layer_order_and_policy_deny_never_overridden() { - let row = |source: CatalogSource, model: &str, family: &str| CatalogOffering { - provider: "zai-coding-cn".into(), - wire_model_id: model.into(), - endpoint_key: "chat".into(), - family: Some(family.into()), - source, - ..Default::default() - }; - let policy = crate::route::CatalogPolicy { - rules: vec![crate::route::PolicyRule { - effect: crate::route::PolicyEffect::Deny, - action: crate::route::PolicyAction::ModelUse, - resource: "*-cn/*".to_string(), - }], - }; - let snapshot = CatalogCompiler::new() - .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")]) - .with_models_dev_live(vec![row( - CatalogSource::ModelsDevLive { fetched_at: 1 }, - "glm-5", - "models-dev", - )]) - .with_provider_live(vec![row( - CatalogSource::Live { - base_url_fingerprint: "fp".into(), - fetched_at: 2, - }, - "glm-5", - "provider", - )]) - .with_config(vec![row(CatalogSource::ConfigOverride, "glm-5", "config")]) - .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")]) - .with_policy(policy) - .compile(); - - assert!( - snapshot.offerings.is_empty(), - "policy DENY after every layer must drop the row; layers cannot override it" - ); - - let allowed = CatalogCompiler::new() - .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")]) - .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")]) - .compile(); - let kept = find(&allowed.offerings, "zai-coding-cn", "glm-5"); - assert_eq!(kept.source, CatalogSource::UserOverride); - assert_eq!(kept.family.as_deref(), Some("user")); -} - #[test] fn cache_scopes_by_provider_and_base_url_fingerprint() { let fp_a = base_url_fingerprint("https://api.example.com/v1"); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 62ec7fdcff..4e4ff2d92b 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, @@ -1548,20 +1548,6 @@ impl ProviderChain { } } -#[cfg(test)] -mod provider_chain_tests { - use super::*; - - #[test] - fn current_on_empty_chain_returns_default_provider() { - let chain = ProviderChain { - providers: vec![], - position: 0, - }; - assert_eq!(chain.current(), ProviderKind::default()); - } -} - /// On-disk schema for the `[hook_sinks]` table. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSinksToml { diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 25f5b23437..5c755fe807 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"); 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/route/auth.rs b/crates/config/src/route/auth.rs index 41d65842ce..f0a9ce77f0 100644 --- a/crates/config/src/route/auth.rs +++ b/crates/config/src/route/auth.rs @@ -124,27 +124,6 @@ impl AuthMethod { label: "External consent", prompts: &[], }; - - /// Whether `answers` satisfy this prompt's `when` clause. - #[must_use] - pub fn prompt_visible( - prompt: &Prompt, - answers: &std::collections::BTreeMap, - ) -> bool { - let when = match prompt { - Prompt::Text { when, .. } | Prompt::Select { when, .. } => *when, - }; - match when { - None => true, - Some(When { key, op, value }) => { - let actual = answers.get(key).map(String::as_str).unwrap_or(""); - match op { - Op::Eq => actual == value, - Op::Neq => actual != value, - } - } - } - } } /// Owned, serializable projection of [`AuthMethod`] for `providers export`. @@ -283,28 +262,3 @@ impl From for ChoiceExport { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn when_eq_hides_unmatched_prompt() { - let prompt = Prompt::Text { - key: "key", - message: "Key", - placeholder: None, - secret: true, - when: Some(When { - key: "plan", - op: Op::Eq, - value: "coding", - }), - }; - let mut answers = std::collections::BTreeMap::new(); - answers.insert("plan".into(), "token".into()); - assert!(!AuthMethod::prompt_visible(&prompt, &answers)); - answers.insert("plan".into(), "coding".into()); - assert!(AuthMethod::prompt_visible(&prompt, &answers)); - } -} diff --git a/crates/config/src/route/authority.rs b/crates/config/src/route/authority.rs deleted file mode 100644 index f23171188e..0000000000 --- a/crates/config/src/route/authority.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! One immutable authority for a compiled provider catalog and its route resolver. -//! -//! Before this seam, callers could independently project a -//! [`crate::catalog::CatalogSnapshot`] through -//! [`crate::catalog::CatalogSnapshot::to_offerings`] and create a -//! [`super::RouteResolver`]. That made it possible for a picker, readiness -//! view, and execution path to use different catalog snapshots without any -//! type-level signal. `RouteAuthoritySnapshot` replaces that ad-hoc pairing for -//! new consumers: it owns the exact compiled catalog and the resolver projected -//! from that catalog together. -//! -//! It deliberately does not claim that a catalog row is runnable. Calling -//! [`RouteAuthoritySnapshot::resolve`] still mints a -//! [`super::ReadyRouteCandidate`] through the sole resolver. The returned -//! receipt distinguishes an exact catalog row, a custom-endpoint route whose -//! provider facts are intentionally not reused, and an allowed pass-through -//! route with no catalog row. All state is secret-free. - -use std::collections::BTreeMap; - -use serde::{Deserialize, Serialize}; - -use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogStatus}; - -use super::{ - ProviderId, ReadyRouteCandidate, RouteError, RouteRequest, RouteResolver, WireModelId, -}; - -/// A secret-free provider catalog cache scope. -/// -/// The base URL is represented only by its already-redacted fingerprint. The -/// provider remains an open catalog string because a catalog can include a -/// discoverable provider that is not yet a built-in [`crate::ProviderKind`]. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct RouteCatalogScope { - provider: String, - base_url_fingerprint: String, -} - -impl RouteCatalogScope { - /// Construct a normalized, secret-free cache scope. - #[must_use] - pub fn new(provider: impl Into, base_url_fingerprint: impl Into) -> Self { - Self { - provider: provider.into().trim().to_string(), - base_url_fingerprint: base_url_fingerprint.into().trim().to_string(), - } - } - - /// Provider id associated with this cache scope. - #[must_use] - pub fn provider(&self) -> &str { - &self.provider - } - - /// Secret-free base URL fingerprint associated with this cache scope. - #[must_use] - pub fn base_url_fingerprint(&self) -> &str { - &self.base_url_fingerprint - } -} - -/// Provenance receipt for a route resolved from a [`RouteAuthoritySnapshot`]. -/// -/// A catalog source describes metadata provenance, not account authorization or -/// endpoint health. Consumers must keep readiness/auth checks separate. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum CatalogOfferingReceipt { - /// The resolved provider/wire pair exactly matches an offering in the - /// compiled catalog owned by this authority snapshot. - Catalog { offering: Box }, - /// The caller selected an explicit base URL. The resolver deliberately - /// clears catalog-owned capabilities and pricing for that custom endpoint, - /// so it must not be presented as an exact catalog offering. - CustomEndpoint { - provider: ProviderId, - wire_model_id: WireModelId, - }, - /// The resolver permitted an unknown/pass-through wire model, but the - /// compiled catalog did not assert facts for that provider/wire pair. - NotCataloged { - provider: ProviderId, - wire_model_id: WireModelId, - }, -} - -impl CatalogOfferingReceipt { - /// Return the exact catalog offering, if this route was catalog-backed. - #[must_use] - pub fn offering(&self) -> Option<&CatalogOffering> { - match self { - Self::Catalog { offering } => Some(offering), - Self::CustomEndpoint { .. } | Self::NotCataloged { .. } => None, - } - } -} - -/// One route-resolution result plus honest catalog provenance. -#[derive(Debug, Clone)] -pub struct AuthorityResolution { - candidate: ReadyRouteCandidate, - receipt: CatalogOfferingReceipt, -} - -impl AuthorityResolution { - /// The executable candidate minted by the snapshot's resolver. - #[must_use] - pub fn candidate(&self) -> &ReadyRouteCandidate { - &self.candidate - } - - /// Honest catalog provenance for the candidate's provider/wire pair. - #[must_use] - pub fn receipt(&self) -> &CatalogOfferingReceipt { - &self.receipt - } -} - -/// A compiled catalog and the resolver derived from that exact catalog. -/// -/// The private fields prevent a consumer from retaining the snapshot while -/// silently replacing its resolver. Refreshers build a new immutable snapshot -/// when their catalog changes; existing consumers keep their coherent view. -#[derive(Debug, Clone)] -pub struct RouteAuthoritySnapshot { - catalog: CatalogSnapshot, - resolver: RouteResolver, - scope_statuses: BTreeMap, -} - -impl RouteAuthoritySnapshot { - /// Bind a compiled catalog to the resolver that consumes its offerings. - #[must_use] - pub fn new(catalog: CatalogSnapshot) -> Self { - let resolver = RouteResolver::from_offerings(catalog.to_offerings()); - Self { - catalog, - resolver, - scope_statuses: BTreeMap::new(), - } - } - - /// Record the cache health known for one provider/base-URL scope. - /// - /// An omitted status stays [`CatalogStatus::Unknown`]; this API never - /// derives freshness from a model row or invents a successful refresh. - #[must_use] - pub fn with_scope_status(mut self, scope: RouteCatalogScope, status: CatalogStatus) -> Self { - self.scope_statuses.insert(scope, status); - self - } - - /// The exact compiled catalog bound to this resolver. - #[must_use] - pub fn catalog(&self) -> &CatalogSnapshot { - &self.catalog - } - - /// Cache health for one provider/base-URL scope, or honestly unknown. - #[must_use] - pub fn scope_status(&self, scope: &RouteCatalogScope) -> CatalogStatus { - self.scope_statuses - .get(scope) - .cloned() - .unwrap_or(CatalogStatus::Unknown) - } - - /// Resolve through the authority-bound resolver and retain catalog receipt. - /// - /// # Errors - /// Returns the route resolver's validation error when a request cannot - /// produce an executable candidate. - pub fn resolve(&self, request: &RouteRequest) -> Result { - let candidate = self.resolver.resolve(request)?; - let receipt = if request.base_url_override.is_some() { - CatalogOfferingReceipt::CustomEndpoint { - provider: candidate.provider_id().clone(), - wire_model_id: candidate.wire_model_id().clone(), - } - } else if let Some(offering) = self.catalog.offerings.iter().find(|offering| { - offering.provider == candidate.provider_id().as_str() - && offering.wire_model_id == candidate.wire_model_id().as_str() - }) { - CatalogOfferingReceipt::Catalog { - offering: Box::new(offering.clone()), - } - } else { - CatalogOfferingReceipt::NotCataloged { - provider: candidate.provider_id().clone(), - wire_model_id: candidate.wire_model_id().clone(), - } - }; - - Ok(AuthorityResolution { candidate, receipt }) - } -} - -#[cfg(test)] -mod tests { - use crate::ProviderKind; - use crate::catalog::{CatalogCompiler, CatalogSource}; - use crate::models_dev::ModelsDevLimit; - use crate::route::{LogicalModelRef, RouteRequest}; - - use super::{CatalogOfferingReceipt, RouteAuthoritySnapshot, RouteCatalogScope}; - - fn request(model: &str) -> RouteRequest { - RouteRequest { - explicit_provider: Some(ProviderKind::Deepseek), - model_selector: Some(LogicalModelRef::from(model)), - saved_provider_model: None, - base_url_override: None, - limit_overrides: Vec::new(), - wire_override: None, - } - } - - fn offering(source: CatalogSource, context: u64) -> crate::catalog::CatalogOffering { - crate::catalog::CatalogOffering { - provider: "deepseek".to_string(), - wire_model_id: "deepseek-v4-flash-vision-exp".to_string(), - endpoint_key: "responses".to_string(), - default_for_provider: true, - limit: Some(ModelsDevLimit { - context: Some(context), - ..Default::default() - }), - source, - ..Default::default() - } - } - - #[test] - fn one_compiled_snapshot_drives_candidate_and_catalog_receipt() { - let snapshot = CatalogCompiler::new() - .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) - .with_config(vec![offering(CatalogSource::ConfigOverride, 64_000)]) - .compile(); - let authority = RouteAuthoritySnapshot::new(snapshot); - - let resolved = authority - .resolve(&request("deepseek-v4-flash-vision-exp")) - .expect("catalog-backed direct route resolves"); - - assert_eq!(resolved.candidate().limits().context_tokens, Some(64_000)); - let CatalogOfferingReceipt::Catalog { offering } = resolved.receipt() else { - panic!("compiled catalog row must be retained as the receipt"); - }; - assert_eq!(offering.source, CatalogSource::ConfigOverride); - assert_eq!( - offering.limit.as_ref().and_then(|limit| limit.context), - Some(64_000) - ); - } - - #[test] - fn custom_endpoint_never_claims_catalog_offering_facts() { - let snapshot = CatalogCompiler::new() - .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) - .compile(); - let authority = RouteAuthoritySnapshot::new(snapshot); - let mut request = request("deepseek-v4-flash-vision-exp"); - request.base_url_override = Some("https://compatible.example/v1".to_string()); - - let resolved = authority - .resolve(&request) - .expect("custom compatible endpoint still resolves"); - - assert!(matches!( - resolved.receipt(), - CatalogOfferingReceipt::CustomEndpoint { .. } - )); - assert!(resolved.receipt().offering().is_none()); - } - - #[test] - fn direct_provider_pass_through_is_explicitly_not_cataloged() { - let authority = RouteAuthoritySnapshot::new(CatalogCompiler::new().compile()); - - let resolved = authority - .resolve(&request("future-deepseek-model")) - .expect("direct-provider pass-through remains executable"); - - assert!(matches!( - resolved.receipt(), - CatalogOfferingReceipt::NotCataloged { .. } - )); - assert!(resolved.receipt().offering().is_none()); - } - - #[test] - fn scope_status_is_explicit_and_defaults_to_unknown() { - let snapshot = CatalogCompiler::new().compile(); - let fresh_scope = RouteCatalogScope::new("deepseek", "fingerprint-a"); - let absent_scope = RouteCatalogScope::new("deepseek", "fingerprint-b"); - let authority = RouteAuthoritySnapshot::new(snapshot) - .with_scope_status(fresh_scope.clone(), crate::catalog::CatalogStatus::Fresh); - - assert_eq!( - authority.scope_status(&fresh_scope), - crate::catalog::CatalogStatus::Fresh - ); - assert_eq!( - authority.scope_status(&absent_scope), - crate::catalog::CatalogStatus::Unknown - ); - } -} diff --git a/crates/config/src/route/candidate.rs b/crates/config/src/route/candidate.rs index 01cdee6614..41fffba0db 100644 --- a/crates/config/src/route/candidate.rs +++ b/crates/config/src/route/candidate.rs @@ -95,8 +95,6 @@ pub enum OverrideSource { CodexPublicApiLimitStrip, /// Per-model context from the fresh Codex account roster. CodexRosterCorrection, - /// Fresh, route-scoped provider-reported context metadata. - ProviderReportedContextWindow, /// Conservative all-plan safe floor for a membership-plan route. MembershipPlanSafeFloor, /// Maximum output documented for one exact provider endpoint/model route. diff --git a/crates/config/src/route/mod.rs b/crates/config/src/route/mod.rs index b4a5708269..9a66471381 100644 --- a/crates/config/src/route/mod.rs +++ b/crates/config/src/route/mod.rs @@ -25,7 +25,6 @@ pub use crate::provider::WireFormat as RequestProtocol; pub mod auth; -pub mod authority; pub mod candidate; pub mod capabilities; pub mod descriptor; @@ -33,16 +32,12 @@ pub mod errors; pub mod export; pub mod ids; pub mod offering; -pub mod policy; pub mod resolver; pub use auth::{ AuthKind, AuthMethod, AuthMethodExport, Choice, ChoiceExport, Op, Prompt, PromptExport, When, WhenExport, }; -pub use authority::{ - AuthorityResolution, CatalogOfferingReceipt, RouteAuthoritySnapshot, RouteCatalogScope, -}; pub use candidate::{ LimitField, OverrideSource, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, SourcedLimitOverride, ValidationReport, @@ -61,7 +56,6 @@ pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, RouteId, Wire pub use offering::{ ProviderModelOffering, RouteLimits, bundled_offerings, opencode_zen_picker_models, }; -pub use policy::{CatalogPolicy, PolicyAction, PolicyEffect, PolicyRule}; pub use resolver::{RouteRequest, RouteResolver}; #[cfg(test)] diff --git a/crates/config/src/route/policy.rs b/crates/config/src/route/policy.rs deleted file mode 100644 index da9285353d..0000000000 --- a/crates/config/src/route/policy.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Catalog policy evaluated after every catalog layer (Phase 1 hook). -//! -//! Policy decides whether an allowed offering may be used. It never configures -//! endpoints or credentials, and it never makes an unusable route usable. -//! `DENY` is applied last and is never overridden by a catalog layer. - -use serde::{Deserialize, Serialize}; - -/// Effect of one policy rule. Last match wins. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PolicyEffect { - /// Permit the matched resource. - Allow, - /// Forbid the matched resource. Never overridden by a later catalog layer. - Deny, -} - -/// Action a policy rule addresses. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum PolicyAction { - /// Using a model on a route (`/`). - ModelUse, - /// Using a route at all (``). - ProviderUse, -} - -/// One wildcard-matched policy rule. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PolicyRule { - /// Allow or deny. - pub effect: PolicyEffect, - /// Action being gated. - pub action: PolicyAction, - /// Resource glob: `"/"` or `""`. - pub resource: String, -} - -/// Ordered policy document. Empty means allow-all. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct CatalogPolicy { - /// Rules in file order; last match wins. - #[serde(default)] - pub rules: Vec, -} - -impl CatalogPolicy { - /// Empty policy (allow everything). - #[must_use] - pub fn allow_all() -> Self { - Self::default() - } - - /// Whether `route_id` / `model_id` survives policy. - /// - /// Default is allow. Each matching rule overwrites the decision; the last - /// match wins. A final DENY cannot be undone by a catalog layer because - /// callers apply this *after* merge. - #[must_use] - pub fn allows(&self, route_id: &str, model_id: &str) -> bool { - let mut allowed = true; - for rule in &self.rules { - if rule.matches(route_id, model_id) { - allowed = rule.effect == PolicyEffect::Allow; - } - } - allowed - } -} - -impl PolicyRule { - fn matches(&self, route_id: &str, model_id: &str) -> bool { - let resource = match self.action { - PolicyAction::ProviderUse => route_id.to_string(), - PolicyAction::ModelUse => format!("{route_id}/{model_id}"), - }; - wildcard_match(&self.resource, &resource) - } -} - -/// Single-`*` wildcard match. `*` does not cross `/`. -fn wildcard_match(pattern: &str, value: &str) -> bool { - wildcard_match_parts(pattern.as_bytes(), value.as_bytes()) -} - -fn wildcard_match_parts(pattern: &[u8], value: &[u8]) -> bool { - let mut p = 0; - let mut v = 0; - let mut star = None; - while v < value.len() { - if p < pattern.len() && pattern[p] == b'*' { - star = Some((p, v)); - p += 1; - continue; - } - if p < pattern.len() && pattern[p] == value[v] { - p += 1; - v += 1; - continue; - } - if let Some((star_p, star_v)) = star { - if value[star_v] == b'/' { - return false; - } - v = star_v + 1; - p = star_p + 1; - star = Some((star_p, v)); - continue; - } - return false; - } - while p < pattern.len() && pattern[p] == b'*' { - p += 1; - } - p == pattern.len() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn deny_survives_every_layer_order() { - let policy = CatalogPolicy { - rules: vec![PolicyRule { - effect: PolicyEffect::Deny, - action: PolicyAction::ModelUse, - resource: "*-cn/*".to_string(), - }], - }; - assert!(!policy.allows("zai-coding-cn", "glm-5")); - assert!(policy.allows("zai", "glm-5")); - assert!(policy.allows("deepseek", "deepseek-v4-pro")); - } - - #[test] - fn last_match_wins() { - let policy = CatalogPolicy { - rules: vec![ - PolicyRule { - effect: PolicyEffect::Deny, - action: PolicyAction::ProviderUse, - resource: "deepseek".to_string(), - }, - PolicyRule { - effect: PolicyEffect::Allow, - action: PolicyAction::ProviderUse, - resource: "deepseek".to_string(), - }, - ], - }; - assert!(policy.allows("deepseek", "any")); - } - - #[test] - fn empty_policy_allows() { - assert!(CatalogPolicy::allow_all().allows("anything", "model")); - } -} 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..867563106d 100644 --- a/crates/core/src/fragments.rs +++ b/crates/core/src/fragments.rs @@ -236,19 +236,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 +253,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 +395,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 +409,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 +491,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 +555,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/journal.rs b/crates/core/src/journal.rs deleted file mode 100644 index 5302e83824..0000000000 --- a/crates/core/src/journal.rs +++ /dev/null @@ -1,78 +0,0 @@ -//! Session tree journal placeholder (issue #5262). -//! -//! The journal is append-only with an in-memory tree projection: -//! every non-header entry carries `id` + `parentId`, the active position is -//! a `leafId`, appending creates a child of the leaf, and branching only -//! moves the leaf — it never rewrites history. This file lands the entry -//! shape that #5262's tree operations hang off of; compaction and -//! branch-summary entry kinds are included as first-class kinds but their -//! *strategies* are deferred. -//! -//! Re-exports the protocol journal as the canonical shape so `protocol` and -//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that -//! owns the `current_leaf_id` column in `state.threads`. - -pub use codewhale_protocol::journal::{Journal, JournalEntry}; - -use serde::{Deserialize, Serialize}; - -/// Persisted thread metadata extension for the tree. This is the -/// `current_leaf_id` column added to `state.threads`; `None` before the -/// first turn, `Some(id)` after. The existing `threads` JSON shape is -/// otherwise unchanged (back-compat: old rows read as `None` and the next -/// append mints the header leaf). -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct ThreadLeafState { - pub thread_id: String, - pub leaf_id: Option, -} - -/// First-class journal entry kinds (data shape lands now; strategies later). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum JournalKind { - Header, - User, - Assistant, - ToolResult, - Compaction, - BranchSummary, -} - -impl JournalKind { - #[must_use] - pub fn as_str(self) -> &'static str { - match self { - Self::Header => "header", - Self::User => "user", - Self::Assistant => "assistant", - Self::ToolResult => "tool_result", - Self::Compaction => "compaction", - Self::BranchSummary => "branch_summary", - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn leaf_state_roundtrip() { - let s = ThreadLeafState { - thread_id: "thread-1".into(), - leaf_id: Some("entry-abc".into()), - }; - let j = serde_json::to_string(&s).unwrap(); - let back: ThreadLeafState = serde_json::from_str(&j).unwrap(); - assert_eq!(back, s); - } - - #[test] - fn journal_append_is_child_of_leaf() { - let mut j = Journal::new(); - let a = j.append("header", json!({})); - let b = j.append("user", json!("hi")); - assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str())); - } -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a70611c685..c7b3e75440 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,9 +1,7 @@ pub mod fragments; pub mod ids; -pub mod journal; pub mod request; pub mod role; -pub mod session; pub mod tool_parser; use std::collections::{HashMap, HashSet}; @@ -205,53 +203,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 +403,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 +2520,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] @@ -2631,17 +2549,6 @@ mod tests { } } - #[test] - fn deterministic_backoff_saturates_at_high_exponent() { - let retry = JobRetryMetadata { - attempt: 63, - backoff_base_ms: 1000, - ..Default::default() - }; - // Should not panic; result saturates - let _ = JobManager::deterministic_backoff_ms(&retry); - } - // ── JobManager: history truncation ───────────────────────────────── #[test] @@ -2891,106 +2798,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/core/src/session.rs b/crates/core/src/session.rs deleted file mode 100644 index e22e2c6007..0000000000 --- a/crates/core/src/session.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! `Thread` / `Session` split (issue #5261). -//! -//! `codewhale`'s `Session` was really a thread. The new split is: -//! - `Thread` — durable, persisted, owns the append-only `Journal` and the -//! `leafId` cursor. One row in `state.threads`, one directory on disk. -//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the -//! in-memory `TurnContext` plus the live approval/sandbox posture for this -//! `SessionId`. Many sessions can attach to one thread over time, but only -//! one `Session` drives a turn for a given `ThreadId` at a time. -//! -//! The thread manager (`ThreadManager` in `crate::lib`) already can start a -//! session with no TUI attached (`spawn_thread_with_history`); this file -//! formalizes the types that make that first-class and moves the former -//! `crates/tui/src/core/session.rs` state (model, reasoning_effort, -//! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`, -//! `messages_revision`) into `crates/core` so both TUI and headless share it. - -use std::path::PathBuf; - -use serde::{Deserialize, Serialize}; - -use crate::ids::{SessionId, ThreadId}; -use crate::journal::Journal; - -/// Durable thread (the former `Session`). One per conversation, persisted in -/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the -/// journal cursor — plus the typed `ThreadId`. All other fields keep their -/// persisted JSON shape unchanged. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Thread { - pub thread_id: ThreadId, - /// Active branch tip. `None` before the first journal header. - #[serde(skip_serializing_if = "Option::is_none")] - pub leaf_id: Option, - /// Journal (append-only). In-memory projection of the persisted - /// `threads/turns/items/events` layout is derived root→leaf. - #[serde(default)] - pub journal: Journal, - pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - pub workspace: PathBuf, - #[serde(default)] - pub ephemeral: bool, -} - -impl Thread { - #[must_use] - pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into) -> Self { - Self { - thread_id, - leaf_id: None, - journal: Journal::new(), - model: model.into(), - reasoning_effort: None, - workspace, - ephemeral: false, - } - } - - #[must_use] - pub fn leaf_id(&self) -> Option<&str> { - self.leaf_id.as_deref() - } - - pub fn set_leaf(&mut self, leaf: Option) { - self.leaf_id = leaf; - } -} - -/// Ephemeral session within a thread (one engine lifetime / one turn's -/// live posture). The TUI's `EngineHandle` and the headless `exec` both -/// hold a `Session` that points at the same `ThreadId` but with different -/// `SessionId`s. -#[derive(Debug, Clone)] -pub struct Session { - pub session_id: SessionId, - pub thread_id: ThreadId, - /// Model for this session's next turn (may differ from thread default). - pub model: String, - pub workspace: PathBuf, - /// Monotonic `messages_revision` for prefix-cache memoization (carried - /// from the former `Session::messages_revision`). - pub messages_revision: u64, -} - -impl Session { - #[must_use] - pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into) -> Self { - Self { - session_id: SessionId::new(), - thread_id, - model: model.into(), - workspace, - messages_revision: 0, - } - } - - pub fn bump_revision(&mut self) { - self.messages_revision = self.messages_revision.wrapping_add(1); - } -} - -/// Split helper: derive a `Session` from an existing `Thread` without -/// cloning the journal. Headless and TUI call the same constructor so -/// the request shape stays identical. -#[must_use] -pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session { - Session::new(thread.thread_id.clone(), workspace, thread.model.clone()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn thread_and_session_ids_are_distinct_scopes() { - let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash"); - let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); - let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); - assert_eq!(s1.thread_id, s2.thread_id); - assert_ne!(s1.session_id, s2.session_id); - } - - #[test] - fn leaf_is_moved_not_rewritten() { - let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m"); - let a = t.journal.append("header", serde_json::json!({})); - let b = t.journal.append("user", serde_json::json!("b")); - t.leaf_id = t.journal.leaf_id.clone(); - assert_eq!(t.leaf_id.as_deref(), Some(b.as_str())); - assert!(t.journal.branch_to(&a)); - t.leaf_id = t.journal.leaf_id.clone(); - assert_eq!(t.leaf_id.as_deref(), Some(a.as_str())); - assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf - } -} 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/lib.rs b/crates/protocol/src/lib.rs index 3bf853d3c2..de385deaa7 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -17,7 +17,6 @@ pub mod ids; pub mod journal; pub mod op; pub mod runtime; -pub mod workroom; /// Common trait for lifecycle status enums across the protocol layer. /// diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index ec323c82b7..e40477cad1 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -4,9 +4,9 @@ //! The TUI engine already had an internal channel (`Op` in //! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`). //! This protocol file formalizes that channel so TUI, CLI, app-server, and -//! tests share one serializable API. The wire is `OpEnvelope` + `Op`; -//! transports that already speak JSON (app-server, tests) can send the -//! envelope directly, while in-process callers continue to use the typed +//! tests share one serializable API. The wire is the `Op` enum; +//! transports that already speak JSON (app-server, tests) can send it +//! directly, while in-process callers continue to use the typed //! enum. //! //! Parity is compile-enforced from the engine side: @@ -51,20 +51,8 @@ where Ok(None) } -use crate::ids::{SessionId, ThreadId}; use crate::runtime::DynamicToolSpec; -/// Every `Op` is paired with the ids that route it. This is the -/// `Op`-in half of the `Op`-in / `EventMsg`-out contract. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OpEnvelope { - /// Monotonic `op:` for dedup / tracing within a session. - pub op_id: String, - pub thread_id: ThreadId, - pub session_id: SessionId, - pub op: Op, -} - /// Token-limit facts for a route. `None` is unknown, never zero. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RouteLimits { @@ -495,40 +483,6 @@ impl Op { } } -/// Build a headless `SendMessage` envelope with fresh ids. This is the -/// one-line helper every headless caller (CLI `exec`, app-server, tests) -/// uses so TUI and headless start a session identically. -#[must_use] -pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into) -> OpEnvelope { - OpEnvelope { - op_id: format!("op-{}", uuid::Uuid::new_v4()), - thread_id: thread_id.clone(), - session_id: SessionId::new(), - op: Op::SendMessage { - content: content.into(), - mode: default_mode(), - model: None, - model_provider: None, - allowed_tools: None, - dynamic_tools: Vec::new(), - provenance: default_provenance(), - compaction: None, - goal_objective: None, - goal_token_budget: None, - goal_status: default_goal_status(), - reasoning_effort: None, - reasoning_effort_auto: false, - auto_model: false, - allow_shell: false, - trust_mode: false, - auto_approve: false, - approval_mode: default_approval_mode(), - translation_enabled: false, - verbosity: None, - }, - } -} - #[cfg(test)] mod tests { use super::*; @@ -551,7 +505,28 @@ mod tests { /// One instance of every variant, in declaration order. fn every_variant() -> Vec { vec![ - headless_send_message_op(ThreadId::new(), "hello").op, + Op::SendMessage { + content: "hello".into(), + mode: default_mode(), + model: None, + model_provider: None, + allowed_tools: None, + dynamic_tools: Vec::new(), + provenance: default_provenance(), + compaction: None, + goal_objective: None, + goal_token_budget: None, + goal_status: default_goal_status(), + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: default_approval_mode(), + translation_enabled: false, + verbosity: None, + }, Op::Steer { content: "more".into(), }, @@ -717,15 +692,6 @@ mod tests { } } - #[test] - fn op_envelope_roundtrip() { - let env = headless_send_message_op(ThreadId::new(), "hello"); - let json = serde_json::to_string(&env).unwrap(); - let back: OpEnvelope = serde_json::from_str(&json).unwrap(); - assert_eq!(back.thread_id, env.thread_id); - assert!(back.op.is_send_message()); - } - #[test] fn minimal_send_message_json_still_parses_with_defaults() { // The pre-A1 wire shape: only `content` plus the tag. 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/protocol/src/workroom.rs b/crates/protocol/src/workroom.rs deleted file mode 100644 index 1a3c9c9704..0000000000 --- a/crates/protocol/src/workroom.rs +++ /dev/null @@ -1,347 +0,0 @@ -//! Workroom types — durable chat-native containers for threaded agent work. -//! -//! A [`Workroom`] groups threads, events, and external references into a -//! stable, addressable surface that can be accessed from the TUI, mobile page, -//! chat bridges, and programmatic Runtime API consumers. -//! -//! See `docs/rfcs/3209-workrooms.md` for the full design. - -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::fmt::Write; - -/// Unique identifier for a workroom. -/// -/// Stable across restarts. Opaque to callers; generated via UUID v4 with a -/// `wr_` prefix for link recognition. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub struct WorkroomId(pub String); - -impl WorkroomId { - /// Create a new workroom id from a UUID v4 string. - pub fn new() -> Self { - Self(format!("wr_{}", uuid::Uuid::new_v4().simple())) - } -} - -impl Default for WorkroomId { - fn default() -> Self { - Self::new() - } -} - -impl std::fmt::Display for WorkroomId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -/// A durable container for threaded agent conversations. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Workroom { - pub id: WorkroomId, - pub title: String, - pub workspace: Option, - pub repo_identity: Option, - pub owner: String, - pub created_at: DateTime, - pub updated_at: DateTime, - pub visibility: WorkroomVisibility, -} - -/// GitHub repository identity attached to a workroom. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RepoRef { - pub owner: String, - pub name: String, -} - -/// Visibility controls for a workroom. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum WorkroomVisibility { - /// Only the local user can access. - Private, - /// Accessible to callers bearing one of the listed bearer tokens. - Shared { allowed_tokens: Vec }, -} - -/// A thread within a workroom. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomThread { - pub id: String, - pub workroom_id: WorkroomId, - pub title: String, - pub kind: WorkroomThreadKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub external_ref: Option, - pub created_at: DateTime, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum WorkroomThreadKind { - Channel, - DirectMessage, - AgentTask, - ApprovalQueue, - ReceiptLog, -} - -/// An external reference that can be attached to a workroom thread. -/// -/// Stores only metadata — no API keys, tokens, or secrets. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum ExternalThreadRef { - GitHubIssue { - owner: String, - repo: String, - number: u64, - }, - GitHubPullRequest { - owner: String, - repo: String, - number: u64, - }, - GitHubCommit { - owner: String, - repo: String, - sha: String, - }, - GitHubCheck { - owner: String, - repo: String, - check_run_id: u64, - }, -} - -/// An event within a workroom thread, attributed to a specific agent/model. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomEvent { - pub id: String, - pub thread_id: String, - pub workroom_id: WorkroomId, - pub timestamp: DateTime, - pub kind: WorkroomEventKind, - #[serde(skip_serializing_if = "Option::is_none")] - pub agent: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(tag = "event", rename_all = "snake_case")] -pub enum WorkroomEventKind { - Message { content: String }, - Mention { mentioned_user: String }, - ToolCall { tool_name: String, summary: String }, - ToolResult { tool_name: String, success: bool }, - ApprovalRequest { tool_name: String }, - ArtifactLinked { path: String, kind: String }, - Receipt { summary: String }, - Failure { error: String }, - NeedsHuman { reason: String }, - Resumed, -} - -/// Attribution metadata recording which agent and model produced an event. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AgentAttribution { - pub provider: String, - pub model: String, - pub agent_id: String, -} - -/// A shareable link that resolves to a workroom, thread, or event. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomLink { - pub workroom_id: WorkroomId, - #[serde(skip_serializing_if = "Option::is_none")] - pub thread_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option, -} - -impl WorkroomLink { - /// Parse a `codewhale://workroom/...` URL. - /// - /// Accepted forms: - /// - `codewhale://workroom/wr_` - /// - `codewhale://workroom/wr_/thread/` - /// - `codewhale://workroom/wr_/event/` - pub fn parse(url: &str) -> Option { - let rest = url.strip_prefix("codewhale://workroom/")?; - let mut segments = rest.split('/'); - let workroom_id = parse_segment_with_prefix(segments.next()?, "wr_")?; - let next = segments.next(); - let (thread_id, event_id) = match next { - None => (None, None), - Some("thread") => { - let thread_id = non_empty_segment(segments.next()?)?; - match segments.next() { - None => (Some(thread_id), None), - Some("event") => { - let event_id = non_empty_segment(segments.next()?)?; - if segments.next().is_some() { - return None; - } - (Some(thread_id), Some(event_id)) - } - _ => return None, - } - } - Some("event") => { - let event_id = non_empty_segment(segments.next()?)?; - if segments.next().is_some() { - return None; - } - (None, Some(event_id)) - } - _ => return None, - }; - - Some(Self { - workroom_id: WorkroomId(workroom_id), - thread_id, - event_id, - }) - } - - /// Serialise back to the `codewhale://workroom/...` URL form. - pub fn to_url(&self) -> String { - let mut url = format!("codewhale://workroom/{}", self.workroom_id); - if let Some(ref thread_id) = self.thread_id { - write!(url, "/thread/{thread_id}").unwrap(); - if let Some(ref event_id) = self.event_id { - write!(url, "/event/{event_id}").unwrap(); - } - } else if let Some(ref event_id) = self.event_id { - write!(url, "/event/{event_id}").unwrap(); - } - url - } -} - -fn parse_segment_with_prefix(segment: &str, prefix: &str) -> Option { - let segment = non_empty_segment(segment)?; - if segment.len() == prefix.len() || !segment.starts_with(prefix) { - return None; - } - Some(segment) -} - -fn non_empty_segment(segment: &str) -> Option { - if segment.is_empty() { - None - } else { - Some(segment.to_string()) - } -} - -/// Summary projection of a workroom for list/inbox views. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomSummary { - pub id: WorkroomId, - pub title: String, - pub updated_at: DateTime, - pub active_threads: usize, -} - -/// Paginated list of workrooms. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomListResponse { - pub workrooms: Vec, -} - -/// Response from the `/workroom/resolve` endpoint. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkroomResolveResponse { - pub link: WorkroomLink, - pub thread_title: Option, - pub external_ref: Option, - pub recent_events: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn workroom_id_new_is_stable() { - let id = WorkroomId::new(); - assert!(id.0.starts_with("wr_")); - assert_eq!(id.0.len(), 35); // "wr_" + 32 hex chars - } - - #[test] - fn workroom_link_parse_workroom_only() { - let link = WorkroomLink::parse("codewhale://workroom/wr_abc123def456").unwrap(); - assert_eq!(link.workroom_id.0, "wr_abc123def456"); - assert!(link.thread_id.is_none()); - assert!(link.event_id.is_none()); - } - - #[test] - fn workroom_link_parse_with_thread() { - let link = WorkroomLink::parse("codewhale://workroom/wr_abc/thread/thr_xyz").unwrap(); - assert_eq!(link.workroom_id.0, "wr_abc"); - assert_eq!(link.thread_id.as_deref(), Some("thr_xyz")); - assert!(link.event_id.is_none()); - } - - #[test] - fn workroom_link_parse_with_event() { - let link = WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt_789").unwrap(); - assert_eq!(link.workroom_id.0, "wr_abc"); - assert_eq!(link.event_id.as_deref(), Some("evt_789")); - assert!(link.thread_id.is_none()); - } - - #[test] - fn workroom_link_roundtrip() { - let original = "codewhale://workroom/wr_abc/thread/thr_x/event/evt_y"; - let parsed = WorkroomLink::parse(original).unwrap(); - assert_eq!(parsed.to_url(), original); - } - - #[test] - fn workroom_link_reject_bad_prefix() { - assert!(WorkroomLink::parse("http://workroom/wr_abc").is_none()); - assert!(WorkroomLink::parse("codewhale://not-workroom/wr_abc").is_none()); - } - - #[test] - fn workroom_link_rejects_malformed_paths() { - assert!(WorkroomLink::parse("codewhale://workroom/").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/abc").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/wr_").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread/").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/unknown/x").is_none()); - assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt/x").is_none()); - } - - #[test] - fn external_thread_ref_serde_roundtrip() { - let issue = ExternalThreadRef::GitHubIssue { - owner: "Hmbown".into(), - repo: "CodeWhale".into(), - number: 3209, - }; - let json = serde_json::to_string(&issue).unwrap(); - let back: ExternalThreadRef = serde_json::from_str(&json).unwrap(); - assert!(matches!(back, ExternalThreadRef::GitHubIssue { .. })); - } - - #[test] - fn agent_attribution_serde_roundtrip() { - let attr = AgentAttribution { - provider: "deepseek".into(), - model: "deepseek-v4-pro".into(), - agent_id: "sub_agent_1".into(), - }; - let json = serde_json::to_string(&attr).unwrap(); - let back: AgentAttribution = serde_json::from_str(&json).unwrap(); - assert_eq!(back.provider, "deepseek"); - assert_eq!(back.model, "deepseek-v4-pro"); - } -} 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/state/src/lib.rs b/crates/state/src/lib.rs index 24bcbee55d..954305f7d9 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -642,7 +642,7 @@ impl StateStore { /// Insert or update thread metadata. /// /// This does **not** update `current_leaf_id`; use [`append_message`](Self::append_message) - /// or [`set_current_leaf_id`](Self::set_current_leaf_id) for that. + /// for that. pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> { let conn = self.conn()?; conn.execute( @@ -788,15 +788,6 @@ impl StateStore { Ok(()) } - /// Permanently delete a thread and all of its associated data - /// (messages, checkpoints, dynamic tools) via cascading foreign keys. - pub fn delete_thread(&self, id: &str) -> Result<()> { - let conn = self.conn()?; - conn.execute("DELETE FROM threads WHERE id = ?1", params![id]) - .context("failed to delete thread")?; - Ok(()) - } - /// Set the memory mode for a thread. /// /// Pass `None` to clear the memory mode. @@ -979,62 +970,6 @@ impl StateStore { Ok(changed > 0) } - /// List all leaf messages in a thread. - /// - /// A leaf message is one that has no other message referencing it as a parent. - /// In a branching conversation tree, there may be multiple leaf messages. - pub fn list_leaf_messages(&self, thread_id: &str) -> Result> { - let conn = self.conn()?; - let mut stmt = conn - .prepare( - r#" - SELECT m1.id, m1.thread_id, m1.role, m1.content, m1.item_json, m1.created_at, m1.parent_entry_id - FROM messages m1 - LEFT JOIN messages m2 ON m1.id = m2.parent_entry_id - WHERE m1.thread_id = ?1 AND m2.id IS NULL - "#, - ) - .context("failed to prepare message listing query")?; - let mut rows = stmt - .query(params![thread_id]) - .with_context(|| format!("failed to list leaf messages for thread {thread_id}"))?; - let mut out = Vec::new(); - while let Some(row) = rows.next().context("failed to iterate message rows")? { - let item_json: Option = row.get(4).context("failed to read item json")?; - let item = item_json - .as_deref() - .map(serde_json::from_str) - .transpose() - .with_context(|| { - format!("failed to parse message item json in thread {thread_id}") - })?; - out.push(MessageRecord { - id: row.get(0).context("failed to read message id")?, - thread_id: row.get(1).context("failed to read message thread id")?, - role: row.get(2).context("failed to read message role")?, - content: row.get(3).context("failed to read message content")?, - item, - created_at: row.get(5).context("failed to read message timestamp")?, - parent_entry_id: row.get(6).context("failed to read parent entry id")?, - }); - } - Ok(out) - } - - /// Update the current leaf message pointer for a thread. - /// - /// This controls which branch of the conversation tree is considered active - /// when listing messages via [`list_messages`](Self::list_messages). - pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> { - let conn = self.conn()?; - conn.execute( - "UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2", - params![current_leaf_id, thread_id], - ) - .context("failed to update thread current leaf id")?; - Ok(()) - } - /// Replace the dynamic tools for a thread. /// /// All existing dynamic tools for the thread are deleted and replaced with the @@ -1221,96 +1156,6 @@ impl StateStore { Ok(out) } - /// Fork the conversation at a specific message. - /// - /// Creates a new message whose parent is `message_id` and updates the thread's - /// `current_leaf_id` to the new message. Returns the ID of the new message. - /// This enables branching conversations from any point in the history. - pub fn fork_at_message( - &self, - message_id: &str, - role: &str, - content: &str, - item: Option, - ) -> Result { - let mut conn = self.conn()?; - let created_at = Utc::now().timestamp(); - let item_json = item - .as_ref() - .map(serde_json::to_string) - .transpose() - .context("failed to serialize message item payload")?; - - let tx = conn - .transaction() - .context("failed to begin fork message transaction")?; - - let thread_id: String = tx - .query_row( - "SELECT thread_id FROM messages WHERE id = ?1", - params![message_id], - |row| row.get(0), - ) - .with_context(|| format!("failed to query thread id for message {message_id}"))?; - - let next_leaf_id: i64 = tx.query_row( - r#" - INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id) - SELECT ?1, ?2, ?3, ?4, ?5, ?6 - RETURNING id - "#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0) - ).with_context(|| format!("failed to fork at message for thread {thread_id:?}"))?; - - tx.execute( - r#" - UPDATE threads - SET current_leaf_id = ?1 - WHERE id = ?2; - "#, - params![next_leaf_id, thread_id], - ) - .with_context(|| { - format!("failed to update thread current leaf id for thread {thread_id:?}") - })?; - - tx.commit() - .context("failed to commit fork message transaction")?; - - Ok(next_leaf_id) - } - - /// Delete all messages belonging to a thread and reset its `current_leaf_id`. - /// - /// Returns the number of messages deleted. - pub fn clear_messages(&self, thread_id: &str) -> Result { - let mut conn = self.conn()?; - let tx = conn - .transaction() - .context("failed to begin clear messages transaction")?; - - tx.execute( - r#" - UPDATE threads - SET current_leaf_id = NULL - WHERE id = ?1; - "#, - params![thread_id], - ) - .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; - let result = tx - .execute( - r#" - DELETE FROM messages WHERE thread_id = ?1 - "#, - params![thread_id], - ) - .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; - tx.commit() - .context("failed to commit clear messages transaction")?; - - Ok(result) - } - /// Save (or update) a named checkpoint for a thread. /// /// If a checkpoint with the same `thread_id` and `checkpoint_id` already exists, @@ -1663,16 +1508,6 @@ impl StateStore { operation() } - /// Find the display name for a thread by its ID, using the session index. - /// - /// Returns `None` if the thread is not in the index or has no name. - pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result> { - let map = self.session_index_map()?; - Ok(map - .get(thread_id) - .and_then(|entry| entry.thread_name.clone())) - } - /// Look up display names for multiple thread IDs at once. /// /// Returns a map from thread ID to its name (which may be `None`). @@ -2163,50 +1998,6 @@ mod tests { assert!(err.to_string().contains("thread missing-thread not found")); } - #[test] - fn delete_thread_cascades_child_rows() { - let store = temp_state_store("thread-delete-cascade"); - store - .upsert_thread(&test_thread("thread-1")) - .expect("upsert thread"); - store - .append_message("thread-1", "user", "hello", None) - .expect("append message"); - store - .save_checkpoint("thread-1", "checkpoint-1", &serde_json::json!({"ok": true})) - .expect("save checkpoint"); - store - .persist_dynamic_tools( - "thread-1", - &[DynamicToolRecord { - position: 0, - name: "test_tool".to_string(), - description: Some("test".to_string()), - input_schema: serde_json::json!({"type": "object"}), - }], - ) - .expect("persist dynamic tools"); - store - .upsert_thread_goal(&test_goal("thread-1", "Ship v0.8.67")) - .expect("upsert goal"); - - store.delete_thread("thread-1").expect("delete thread"); - - let conn = store.conn().expect("conn"); - for table in [ - "messages", - "checkpoints", - "thread_dynamic_tools", - "thread_goals", - ] { - let sql = format!("SELECT COUNT(*) FROM {table} WHERE thread_id = ?1"); - let count: i64 = conn - .query_row(&sql, params!["thread-1"], |row| row.get(0)) - .expect("count child rows"); - assert_eq!(count, 0, "{table} row survived thread deletion"); - } - } - #[test] fn state_store_reuses_one_connection_across_operations_and_clones() { let store = temp_state_store("conn-reuse"); @@ -2627,9 +2418,11 @@ mod tests { assert_eq!(line_count, 1); let name = store - .find_thread_name_by_id("thread-1") + .session_index_map() + .ok() + .and_then(|m| m.get("thread-1").and_then(|e| e.thread_name.clone())) .expect("lookup thread name"); - assert_eq!(name.as_deref(), Some("name-5")); + assert_eq!(name, "name-5"); } #[test] @@ -2657,17 +2450,19 @@ mod tests { assert_eq!( store - .find_thread_name_by_id("thread-1") - .expect("lookup thread-1") - .as_deref(), - Some("first"), + .session_index_map() + .ok() + .and_then(|m| m.get("thread-1").and_then(|e| e.thread_name.clone())) + .expect("lookup thread-1"), + "first", ); assert_eq!( store - .find_thread_name_by_id("thread-3") - .expect("lookup thread-3") - .as_deref(), - Some("third"), + .session_index_map() + .ok() + .and_then(|m| m.get("thread-3").and_then(|e| e.thread_name.clone())) + .expect("lookup thread-3"), + "third", ); } @@ -2754,18 +2549,20 @@ mod tests { assert_eq!( store - .find_thread_name_by_id("racer") - .expect("lookup racer") - .as_deref(), - Some("racer-name"), + .session_index_map() + .ok() + .and_then(|m| m.get("racer").and_then(|e| e.thread_name.clone())) + .expect("lookup racer"), + "racer-name", "append was dropped by a concurrent compaction", ); assert_eq!( store - .find_thread_name_by_id("trigger") - .expect("lookup trigger") - .as_deref(), - Some("trigger-name"), + .session_index_map() + .ok() + .and_then(|m| m.get("trigger").and_then(|e| e.thread_name.clone())) + .expect("lookup trigger"), + "trigger-name", ); } } diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 5be1d89cf7..54f6249e33 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -405,7 +405,7 @@ fn test_fork() { .list_messages("thread-test-1", None) .expect("list messages"); assert_eq!(messages.len(), 5); - let ids = messages + let _ids = messages .iter() .enumerate() .map(|(i, message)| { @@ -415,65 +415,4 @@ fn test_fork() { message.id.to_string() }) .collect::>(); - - store.upsert_thread(&thread).expect("upsert thread"); - - store - .fork_at_message(&ids[2], "foo5", "bar5", None) - .expect("fork at message"); - let messages = store - .list_messages("thread-test-1", None) - .expect("list messages"); - assert_eq!(messages.len(), 4); - const LIST_1: [i64; 4] = [0, 1, 2, 5]; - messages - .iter() - .zip(LIST_1.iter()) - .for_each(|(message, &i)| { - assert_eq!(message.thread_id, "thread-test-1"); - assert_eq!(message.role, format!("foo{i}")); - assert_eq!(message.content, format!("bar{i}")); - }); - let leaves = store - .list_leaf_messages("thread-test-1") - .expect("list leaf messages"); - assert_eq!(leaves.len(), 2); - - store - .set_current_leaf_id("thread-test-1", &ids[4]) - .expect("set current leaf id"); - store - .append_message("thread-test-1", "foo6", "bar6", None) - .expect("append message"); - let messages = store - .list_messages("thread-test-1", None) - .expect("list messages"); - assert_eq!(messages.len(), 6); - const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6]; - messages - .iter() - .zip(LIST_2.iter()) - .for_each(|(message, &i)| { - assert_eq!(message.thread_id, "thread-test-1"); - assert_eq!(message.role, format!("foo{i}")); - assert_eq!(message.content, format!("bar{i}")); - }); - - let leaves = store - .list_leaf_messages("thread-test-1") - .expect("list leaf messages"); - assert_eq!(leaves.len(), 2); - - store - .clear_messages("thread-test-1") - .expect("clear messages"); - let leaves = store - .list_leaf_messages("thread-test-1") - .expect("list leaf messages"); - assert_eq!(leaves.len(), 0); - let thread = store - .get_thread("thread-test-1") - .expect("get thread") - .unwrap(); - assert!(thread.current_leaf_id.is_none()); } diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index e671748701..e3cc86fd2b 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} configurats", "StatusContextUsage": "{percent}% utilitzat ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", - "StatusContextSourceProviderReported": "informada pel proveïdor", "StatusContextSourceKimiSafeFloor": "mínim segur estàtic de Kimi Code", "StatusContextSourceCatalog": "catàleg", "StatusContextSourceModelHint": "pista del nom del model (sense verificar)", diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index ea65b6b349..1f447b83e6 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} konfiguriert", "StatusContextUsage": "{percent}% belegt ({used} / {max} Token)", "StatusContextSourceConfigured": "konfiguriert", - "StatusContextSourceProviderReported": "vom Provider gemeldet", "StatusContextSourceKimiSafeFloor": "statische Sicherheitsuntergrenze für Kimi Code", "StatusContextSourceCatalog": "Katalog", "StatusContextSourceModelHint": "Hinweis aus Modellname (ungeprüft)", diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 68736df4a5..264280fb12 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} configured", "StatusContextUsage": "{percent}% used ({used} / {max} tokens)", "StatusContextSourceConfigured": "configured", - "StatusContextSourceProviderReported": "provider-reported", "StatusContextSourceKimiSafeFloor": "static Kimi Code safe floor", "StatusContextSourceCatalog": "catalog", "StatusContextSourceModelHint": "model-name hint (unverified)", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 42c58299d6..76d8c1cfb5 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} configurados", "StatusContextUsage": "{percent}% usado ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", - "StatusContextSourceProviderReported": "informada por el proveedor", "StatusContextSourceKimiSafeFloor": "mínimo seguro estático de Kimi Code", "StatusContextSourceCatalog": "catálogo", "StatusContextSourceModelHint": "pista del nombre del modelo (sin verificar)", diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 362667e2c4..f817fe0c48 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} configurés", "StatusContextUsage": "{percent}% utilisé ({used} / {max} jetons)", "StatusContextSourceConfigured": "configurée", - "StatusContextSourceProviderReported": "signalée par le fournisseur", "StatusContextSourceKimiSafeFloor": "plancher de sécurité statique Kimi Code", "StatusContextSourceCatalog": "catalogue", "StatusContextSourceModelHint": "indice du nom du modèle (non vérifié)", diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index f8580f6c24..895599c5e7 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} कॉन्फ़िगर", "StatusContextUsage": "{percent}% उपयोग ({used} / {max} टोकन)", "StatusContextSourceConfigured": "कॉन्फ़िगर किया गया", - "StatusContextSourceProviderReported": "प्रोवाइडर द्वारा रिपोर्ट किया गया", "StatusContextSourceKimiSafeFloor": "Kimi Code की स्थिर सुरक्षित निचली सीमा", "StatusContextSourceCatalog": "कैटलॉग", "StatusContextSourceModelHint": "मॉडल नाम संकेत (असत्यापित)", diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index 2a42a0389f..d2b48cbaf9 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} dikonfigurasi", "StatusContextUsage": "{percent}% terpakai ({used} / {max} token)", "StatusContextSourceConfigured": "dikonfigurasi", - "StatusContextSourceProviderReported": "dilaporkan penyedia", "StatusContextSourceKimiSafeFloor": "batas aman statis Kimi Code", "StatusContextSourceCatalog": "katalog", "StatusContextSourceModelHint": "petunjuk nama model (belum diverifikasi)", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 2a1aba5296..ff8b74bbac 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} 件設定済み", "StatusContextUsage": "{percent}% 使用中({used} / {max} トークン)", "StatusContextSourceConfigured": "設定値", - "StatusContextSourceProviderReported": "プロバイダー報告", "StatusContextSourceKimiSafeFloor": "Kimi Code の静的安全下限", "StatusContextSourceCatalog": "カタログ", "StatusContextSourceModelHint": "モデル名のヒント(未検証)", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 7582372b55..bd003854a3 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count}개 구성됨", "StatusContextUsage": "{percent}% 사용 중 ({used} / {max} 토큰)", "StatusContextSourceConfigured": "구성 값", - "StatusContextSourceProviderReported": "공급자 보고", "StatusContextSourceKimiSafeFloor": "Kimi Code 정적 안전 하한", "StatusContextSourceCatalog": "카탈로그", "StatusContextSourceModelHint": "모델 이름 힌트(미검증)", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 8e2ff30d0b..19e7ec0bca 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "{count} configurados", "StatusContextUsage": "{percent}% usado ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", - "StatusContextSourceProviderReported": "informada pelo provedor", "StatusContextSourceKimiSafeFloor": "piso seguro estático do Kimi Code", "StatusContextSourceCatalog": "catálogo", "StatusContextSourceModelHint": "dica do nome do modelo (não verificada)", diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index 5ba1336e74..4066a8e316 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "настроено: {count}", "StatusContextUsage": "использовано {percent}% ({used} / {max} токенов)", "StatusContextSourceConfigured": "настроено", - "StatusContextSourceProviderReported": "сообщено провайдером", "StatusContextSourceKimiSafeFloor": "статический безопасный минимум Kimi Code", "StatusContextSourceCatalog": "каталог", "StatusContextSourceModelHint": "подсказка из имени модели (не проверено)", diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index 6624dac829..fd85637408 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "налаштовано: {count}", "StatusContextUsage": "використано {percent}% ({used} / {max} токенів)", "StatusContextSourceConfigured": "налаштовано", - "StatusContextSourceProviderReported": "повідомлено провайдером", "StatusContextSourceKimiSafeFloor": "статичний безпечний мінімум Kimi Code", "StatusContextSourceCatalog": "каталог", "StatusContextSourceModelHint": "підказка з назви моделі (не перевірено)", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index fa618e5465..2457a5b6d9 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "đã cấu hình {count}", "StatusContextUsage": "đã dùng {percent}% ({used} / {max} token)", "StatusContextSourceConfigured": "đã cấu hình", - "StatusContextSourceProviderReported": "nhà cung cấp báo cáo", "StatusContextSourceKimiSafeFloor": "ngưỡng an toàn tĩnh của Kimi Code", "StatusContextSourceCatalog": "danh mục", "StatusContextSourceModelHint": "gợi ý từ tên mô hình (chưa xác minh)", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 79e942ba85..414f1c2069 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "已配置 {count} 个", "StatusContextUsage": "已使用 {percent}%({used} / {max} 令牌)", "StatusContextSourceConfigured": "配置值", - "StatusContextSourceProviderReported": "提供商报告", "StatusContextSourceKimiSafeFloor": "静态 Kimi Code 安全下限", "StatusContextSourceCatalog": "目录", "StatusContextSourceModelHint": "模型名称提示(未验证)", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 67e2921f83..4defd6a3fc 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -1776,7 +1776,6 @@ "StatusMcpConfigured": "已設定 {count} 個", "StatusContextUsage": "已使用 {percent}%({used} / {max} 權杖)", "StatusContextSourceConfigured": "設定值", - "StatusContextSourceProviderReported": "提供者回報", "StatusContextSourceKimiSafeFloor": "靜態 Kimi Code 安全下限", "StatusContextSourceCatalog": "目錄", "StatusContextSourceModelHint": "模型名稱提示(未驗證)", diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 5f4d278f58..b12b3b4508 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -4531,64 +4531,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 @@ -7480,16 +7422,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))) @@ -7504,15 +7453,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") @@ -10676,56 +10634,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 7221b56b00..5add3e25c1 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -2564,20 +2564,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..98fdddf2e1 100644 --- a/crates/tui/src/cloud_dispatch.rs +++ b/crates/tui/src/cloud_dispatch.rs @@ -31,11 +31,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 +618,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 +1324,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 +2254,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 +3230,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/command_safety.rs b/crates/tui/src/command_safety.rs index 6a2e5b7256..e121bb3b23 100644 --- a/crates/tui/src/command_safety.rs +++ b/crates/tui/src/command_safety.rs @@ -2627,26 +2627,6 @@ mod tests { // ── git (arity 2 each) ──────────────────────────────────────────────────── - #[test] - fn classify_git_status_bare() { - assert_eq!(classify("git status"), "git status"); - } - - #[test] - fn classify_git_status_with_short_flag() { - assert_eq!(classify("git status -s"), "git status"); - } - - #[test] - fn classify_git_status_with_long_flag() { - assert_eq!(classify("git status --porcelain"), "git status"); - } - - #[test] - fn classify_git_push_does_not_equal_git_status() { - assert_ne!(classify("git push origin main"), "git status"); - } - #[test] fn classify_git_push() { assert_eq!(classify("git push origin main"), "git push"); diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index b8476a6027..0d7d4d5207 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -3717,15 +3717,6 @@ mod tests { assert!(result.message.unwrap().contains("Usage: /config")); } - #[test] - fn test_show_settings_loads_from_file() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let result = show_settings(&mut app); - // Settings should load (may use defaults if file doesn't exist) - assert!(result.message.is_some()); - } - #[test] fn settings_command_opens_typed_editor_and_preserves_text_mode() { let _lock = lock_test_env(); diff --git a/crates/tui/src/commands/groups/config/status.rs b/crates/tui/src/commands/groups/config/status.rs index 7f1fa0e48a..d9170eece8 100644 --- a/crates/tui/src/commands/groups/config/status.rs +++ b/crates/tui/src/commands/groups/config/status.rs @@ -381,9 +381,6 @@ fn context_window_source_label( crate::route_runtime::ContextWindowSource::Configured => { MessageId::StatusContextSourceConfigured } - crate::route_runtime::ContextWindowSource::ProviderReported => { - MessageId::StatusContextSourceProviderReported - } crate::route_runtime::ContextWindowSource::StaticKimiCodeSafeFloor => { MessageId::StatusContextSourceKimiSafeFloor } @@ -849,7 +846,7 @@ mod tests { app.ui_locale = Locale::Ja; app.approval_mode = ApprovalMode::Bypass; app.active_context_window_source = - crate::route_runtime::ContextWindowSource::ProviderReported; + crate::route_runtime::ContextWindowSource::StaticKimiCodeSafeFloor; let msg = format_status(&app); @@ -867,7 +864,7 @@ mod tests { MessageId::StatusLabelSessionCost, MessageId::StatusLabelToolOutputs, MessageId::StatusProjectDocsNone, - MessageId::StatusContextSourceProviderReported, + MessageId::StatusContextSourceKimiSafeFloor, MessageId::StatusSessionNotSaved, MessageId::StatusToolNone, MessageId::StatusSafetyDisabled, diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index cb77e7cb93..dab2d2540e 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -334,7 +334,6 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { None, route_base_url, app.active_context_window_override, - None, // This receipt feeds only base_url/limits below; the static // policy is fine because protocol is never consumed here. None, 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 c2e2b7929c..0699b82e58 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -2300,18 +2300,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 [ @@ -3179,18 +3167,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 79500d310a..6159721b75 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -10163,26 +10163,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 d7469dcdc8..f0e42fb222 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4950,7 +4950,6 @@ impl Engine { ), mcp_tool_names: Vec::new(), mcp: McpToolState::Disabled, - subagent_runtime_model: None, mailbox: None, plugin_tool_names: HashSet::new(), }; @@ -5086,14 +5085,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(), @@ -5223,7 +5220,6 @@ impl Engine { surface, mcp_tool_names, mcp: mcp_state, - subagent_runtime_model, mailbox: mailbox_for_runtime, plugin_tool_names, } @@ -8615,10 +8611,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/token_estimate_cache.rs b/crates/tui/src/core/engine/token_estimate_cache.rs index 50402376c3..addef4f56b 100644 --- a/crates/tui/src/core/engine/token_estimate_cache.rs +++ b/crates/tui/src/core/engine/token_estimate_cache.rs @@ -23,18 +23,12 @@ use std::hash::{Hash, Hasher}; use crate::compaction::estimate_input_tokens_conservative; use crate::models::{Message, SystemPrompt}; -/// Default capacity for the rolling audit ring. Sized so a 64-entry window -/// covers a full capacity controller observation cycle without unbounded -/// growth on long-running sessions. -const AUDIT_RING_CAPACITY: usize = 64; - /// Process-local memoization for `estimate_input_tokens_conservative`. /// /// The cache is keyed on the `(messages_revision, system_fingerprint)` /// pair, both of which the engine bumps on every content change. On a hit /// the previously stored token estimate is returned without re-walking the -/// message list. On a miss, the estimator runs and the result is stored -/// alongside the audit ring entry. +/// message list. On a miss, the estimator runs and the result is stored. #[derive(Debug, Default, Clone)] pub struct TokenEstimateCache { /// Monotonic counter bumped by the engine on every message mutation. @@ -44,21 +38,13 @@ pub struct TokenEstimateCache { system_fingerprint: u64, /// Cached token count, valid iff both keys match the current inputs. cached_tokens: Option, - /// Audit ring of recent (revision, tokens) pairs. The most recent entry - /// is the tail; the oldest is dropped when capacity is exceeded. Used by - /// observability to surface cache effectiveness to `/status`. - audit_ring: Vec<(u64, usize)>, - /// Number of cache hits since the cache was last cleared. Saturates at - /// `u64::MAX` (effectively never in practice). - hits: u64, - /// Number of cache misses since the cache was last cleared. - misses: u64, } impl TokenEstimateCache { - /// Construct a fresh, empty cache. `messages_revision` defaults to 0; the - /// engine must call [`bump_messages_revision`](Self::bump_messages_revision) - /// whenever a mutation occurs so the next lookup correctly invalidates. + /// Construct a fresh, empty cache. `messages_revision` defaults to 0; + /// the engine passes the current revision on every + /// [`lookup_or_compute`](Self::lookup_or_compute) call so the next + /// lookup correctly invalidates. #[must_use] pub fn new() -> Self { Self::default() @@ -81,7 +67,6 @@ impl TokenEstimateCache { && self.system_fingerprint == system_fingerprint && let Some(tokens) = self.cached_tokens { - self.hits = self.hits.saturating_add(1); return tokens; } @@ -89,53 +74,8 @@ impl TokenEstimateCache { self.messages_revision = messages_revision; self.system_fingerprint = system_fingerprint; self.cached_tokens = Some(tokens); - self.misses = self.misses.saturating_add(1); - self.push_audit(messages_revision, tokens); tokens } - - /// Record a messages-revision bump. The engine calls this whenever - /// `session.messages` is mutated. Calling it with a value smaller than - /// the current value is a no-op (the cache is monotonic). - #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it - pub fn bump_messages_revision(&mut self, revision: u64) { - if revision > self.messages_revision { - self.messages_revision = revision; - self.cached_tokens = None; - } - } - - /// Forget all cached state. Used by `/clear` and session reset paths. - #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it - pub fn invalidate(&mut self) { - self.cached_tokens = None; - self.system_fingerprint = 0; - self.audit_ring.clear(); - self.hits = 0; - self.misses = 0; - } - - /// Returns `(hits, misses)` counters since the last `invalidate` call. - #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it - #[must_use] - pub fn stats(&self) -> (u64, u64) { - (self.hits, self.misses) - } - - /// Returns the most recent `(revision, tokens)` audit entries, newest - /// first. Bounded by [`AUDIT_RING_CAPACITY`]. - #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it - #[must_use] - pub fn recent_audit(&self) -> &[(u64, usize)] { - &self.audit_ring - } - - fn push_audit(&mut self, revision: u64, tokens: usize) { - if self.audit_ring.len() >= AUDIT_RING_CAPACITY { - self.audit_ring.remove(0); - } - self.audit_ring.push((revision, tokens)); - } } /// Stable 64-bit hash of the system prompt text. Walks the same shape the @@ -184,89 +124,45 @@ mod tests { } #[test] - fn first_call_is_a_miss() { + fn first_call_computes_a_positive_estimate() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hello world")]; let tokens = cache.lookup_or_compute(1, None, &messages); - let (hits, misses) = cache.stats(); assert!(tokens > 0); - assert_eq!(hits, 0); - assert_eq!(misses, 1); } #[test] - fn repeated_call_with_same_revision_is_a_hit() { + fn repeated_call_with_same_revision_returns_the_cached_estimate() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hello world")]; - let _ = cache.lookup_or_compute(1, None, &messages); - let _ = cache.lookup_or_compute(1, None, &messages); - let (hits, misses) = cache.stats(); - assert_eq!(hits, 1); - assert_eq!(misses, 1); + let first = cache.lookup_or_compute(1, None, &messages); + let second = cache.lookup_or_compute(1, None, &messages); + assert!(first > 0); + assert_eq!(first, second); } #[test] - fn revision_bump_invalidates() { + fn revision_bump_recomputes_the_same_estimate() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; let a = cache.lookup_or_compute(1, None, &messages); let b = cache.lookup_or_compute(2, None, &messages); - let (hits, misses) = cache.stats(); - // Both calls were misses (different revisions), neither hit the cache. + assert!(a > 0); assert_eq!(a, b); - assert_eq!(hits, 0); - assert_eq!(misses, 2); } #[test] - fn system_prompt_change_invalidates() { + fn system_prompt_change_recomputes_the_same_estimate() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; - let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); - let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); - let (hits, misses) = cache.stats(); - assert_eq!(hits, 0); - assert_eq!(misses, 2); - } - - #[test] - fn bump_messages_revision_clears_cache() { - let mut cache = TokenEstimateCache::new(); - let messages = vec![user_text("x")]; - let _ = cache.lookup_or_compute(1, None, &messages); - cache.bump_messages_revision(2); - let _ = cache.lookup_or_compute(2, None, &messages); - let (hits, misses) = cache.stats(); - assert_eq!(hits, 0); - assert_eq!(misses, 2); - } - - #[test] - fn bump_to_smaller_revision_is_noop() { - let mut cache = TokenEstimateCache::new(); - let messages = vec![user_text("x")]; - let _ = cache.lookup_or_compute(5, None, &messages); - cache.bump_messages_revision(2); - // revision went down, cache should still be valid for revision 5 - let _ = cache.lookup_or_compute(5, None, &messages); - let (hits, _) = cache.stats(); - assert_eq!(hits, 1, "downward revision bumps must not invalidate"); - } - - #[test] - fn invalidate_resets_state() { - let mut cache = TokenEstimateCache::new(); - let messages = vec![user_text("x")]; - let _ = cache.lookup_or_compute(1, None, &messages); - let _ = cache.lookup_or_compute(1, None, &messages); - cache.invalidate(); - let (hits, misses) = cache.stats(); - assert_eq!(hits, 0); - assert_eq!(misses, 0); + let a = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); + let b = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); + assert!(a > 0); + assert_eq!(a, b); } #[test] - fn blocks_system_prompt_yields_distinct_fingerprint() { + fn blocks_system_prompt_lookups_stay_consistent() { let blocks_a = SystemPrompt::Blocks(vec![SystemBlock { block_type: "text".to_string(), text: "alpha".to_string(), @@ -279,35 +175,9 @@ mod tests { }]); let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; - let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages); - let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages); - let (hits, misses) = cache.stats(); - assert_eq!(hits, 0); - assert_eq!(misses, 2); - } - - #[test] - fn audit_ring_records_recent_pairs() { - let mut cache = TokenEstimateCache::new(); - let messages = vec![user_text("hi")]; - for rev in 1..=5 { - let _ = cache.lookup_or_compute(rev, None, &messages); - } - let ring = cache.recent_audit(); - assert_eq!(ring.len(), 5); - assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1))); - } - - #[test] - fn audit_ring_bounded_by_capacity() { - let mut cache = TokenEstimateCache::new(); - let messages = vec![user_text("hi")]; - for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 { - let _ = cache.lookup_or_compute(rev, None, &messages); - } - let ring = cache.recent_audit(); - assert_eq!(ring.len(), AUDIT_RING_CAPACITY); - // newest entry should be the most recent revision we asked for - assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64); + let a = cache.lookup_or_compute(1, Some(&blocks_a), &messages); + let b = cache.lookup_or_compute(1, Some(&blocks_b), &messages); + assert!(a > 0); + assert_eq!(a, b); } } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 00c0c49819..da34f8e312 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -6254,19 +6254,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)) @@ -7608,19 +7595,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)); @@ -7802,65 +7776,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..aef2a61ea6 100644 --- a/crates/tui/src/error_taxonomy/tests.rs +++ b/crates/tui/src/error_taxonomy/tests.rs @@ -53,31 +53,18 @@ 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( + // Redaction of the raw key is the caller's responsibility before the + // envelope is built; the envelope itself must classify a 401 as a + // critical authentication error and never surface the raw key. + 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: [redacted API key]", )); 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/store.rs b/crates/tui/src/fleet/store.rs index 83b23d6cd6..2f089dee16 100644 --- a/crates/tui/src/fleet/store.rs +++ b/crates/tui/src/fleet/store.rs @@ -206,10 +206,6 @@ pub struct FleetFile { pub enum FleetStoreError { #[error("invalid fleet: {0}")] Invalid(String), - #[error( - "fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}" - )] - Ambiguous(String, String, String), #[error("fleet file not found: {0}")] NotFound(String), #[error("failed to read {path}: {message}")] @@ -500,53 +496,8 @@ fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec) { } } -/// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that -/// names both origins — the caller (UI) resolves it by asking for a scope. -/// (Kept for the qualified-name flow and the ambiguity tests; the list/detail -/// UI resolves by scope via load_fleet_in_scope.) -#[allow(dead_code)] -pub fn load_fleet( - name: &str, - workspace: &Path, -) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> { - let name = name.trim(); - if name.is_empty() { - return Err(FleetStoreError::NotFound("".to_string())); - } - let mut found: Vec<(FleetScope, PathBuf)> = Vec::new(); - if let Ok(dir) = personal_fleets_dir() { - let path = dir.join(format!("{}.toml", slugify(name))); - if path.is_file() { - found.push((FleetScope::Personal, path)); - } - } - let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name))); - if ws_path.is_file() { - found.push((FleetScope::Workspace, ws_path)); - } - if found.len() > 1 { - return Err(FleetStoreError::Ambiguous( - name.to_string(), - FleetScope::Personal.label().to_string(), - FleetScope::Workspace.label().to_string(), - )); - } - let Some((scope, path)) = found.pop() else { - return Err(FleetStoreError::NotFound(name.to_string())); - }; - let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io { - path: path.display().to_string(), - message: e.to_string(), - })?; - let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse { - path: path.display().to_string(), - message: e.to_string(), - })?; - Ok((fleet, scope, path)) -} - -/// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`], -/// this never resolves ambiguity — the caller already knows where the Fleet +/// Load a v2 Fleet by name in one explicit scope. This never resolves +/// ambiguity — the caller already knows where the Fleet /// lives (e.g. the row the user just picked). pub fn load_fleet_in_scope( name: &str, @@ -1121,32 +1072,6 @@ role = "scout" assert!(without_name.members[0].display_name.is_none()); } - #[test] - fn save_load_round_trips_in_workspace_scope() { - let _lock = crate::test_support::lock_test_env(); - let ws = tempfile::TempDir::new().unwrap(); - let fleet = sample_fleet(); - let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save"); - assert!( - path.ends_with(".codewhale/fleets/deepseek-flash.toml"), - "{path:?}" - ); - - let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load"); - assert_eq!(loaded, fleet); - assert_eq!(scope, FleetScope::Workspace); - assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2); - - // A same-name Fleet in the personal scope makes the bare name - // ambiguous — the reader names both origins instead of shadowing. - let _home = set_sealed_home(); - save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal"); - let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err(); - let msg = err.to_string(); - assert!(msg.contains("defined in both"), "{msg}"); - assert!(msg.contains("user") && msg.contains("folder"), "{msg}"); - } - #[test] fn selection_is_scope_explicit_and_workspace_wins() { let _lock = crate::test_support::lock_test_env(); 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/goal_loop.rs b/crates/tui/src/goal_loop.rs index f4c0abe72b..99fb3af680 100644 --- a/crates/tui/src/goal_loop.rs +++ b/crates/tui/src/goal_loop.rs @@ -246,14 +246,6 @@ pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget false } -/// Whether a stop reason represents success (Completed) vs. an early/forced exit. -/// Useful for the UI/status projection (#2666 token/time visibility). -#[must_use] -#[allow(dead_code)] -pub fn is_success(reason: StopReason) -> bool { - matches!(reason, StopReason::Completed) -} - #[cfg(test)] mod tests { use super::*; @@ -266,7 +258,6 @@ mod tests { GoalBudget::unbounded(), ); assert_eq!(d, ContinuationDecision::Stop(StopReason::Completed)); - assert!(is_success(StopReason::Completed)); } #[test] @@ -277,7 +268,6 @@ mod tests { GoalBudget::unbounded(), ); assert_eq!(d, ContinuationDecision::Stop(StopReason::Blocked)); - assert!(!is_success(StopReason::Blocked)); } #[test] 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..f522af6d53 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -1593,29 +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 { - self.config.enabled - } - /// Get the session ID /// Read-only access to the underlying configuration. Used by /// `/hooks` (#460 read-only MVP) so the user can list configured @@ -3140,13 +3117,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 +3381,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 +3395,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 +3411,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 +3451,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 +4112,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 +5627,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 1beb7f71da..da22cf7801 100644 --- a/crates/tui/src/llm_client/mod.rs +++ b/crates/tui/src/llm_client/mod.rs @@ -127,90 +127,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 { @@ -218,19 +137,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, } } @@ -241,15 +147,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() } } @@ -265,54 +163,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 @@ -545,57 +395,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, @@ -945,7 +744,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 @@ -972,10 +770,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, @@ -995,7 +789,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 } @@ -1074,11 +867,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 @@ -1095,19 +883,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. @@ -1341,13 +1116,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(); @@ -1427,126 +1195,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] @@ -1715,11 +1363,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..158d141423 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1549,7 +1549,6 @@ pub enum MessageId { StatusMcpConfigured, StatusContextUsage, StatusContextSourceConfigured, - StatusContextSourceProviderReported, StatusContextSourceKimiSafeFloor, StatusContextSourceCatalog, StatusContextSourceModelHint, @@ -3671,7 +3670,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::StatusMcpConfigured, MessageId::StatusContextUsage, MessageId::StatusContextSourceConfigured, - MessageId::StatusContextSourceProviderReported, MessageId::StatusContextSourceKimiSafeFloor, MessageId::StatusContextSourceCatalog, MessageId::StatusContextSourceModelHint, @@ -6391,19 +6389,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/external_import.rs b/crates/tui/src/mcp/external_import.rs index 57a281deeb..5e2182f9f1 100644 --- a/crates/tui/src/mcp/external_import.rs +++ b/crates/tui/src/mcp/external_import.rs @@ -211,30 +211,6 @@ pub fn save_consent_store(path: &Path, store: &ImportConsentStore) -> std::io::R Ok(()) } -/// Filter candidates that still need a user decision for this content hash. -#[allow(dead_code)] // used by future selector UI + unit tests -pub fn candidates_needing_consent( - candidates: &[ImportCandidate], - store: &ImportConsentStore, -) -> Vec { - candidates - .iter() - .filter(|c| { - let key = consent_key(&c.source_path, &c.content_hash); - match store.entries.get(&key) { - Some(entry) if entry.decision == ImportDecision::Decline => false, - Some(entry) if entry.decision == ImportDecision::Approve => { - // Re-prompt only when the specific server was not part of - // the prior approval list (partial approval). - !entry.servers.iter().any(|s| s == &c.name) - } - _ => true, - } - }) - .cloned() - .collect() -} - /// Apply approvals: returns servers to merge into user mcp.json. /// Hard-blocked candidates are never returned even if decision is Approve. pub fn apply_approved( @@ -414,32 +390,6 @@ mod tests { assert!(config.servers["ok"].is_enabled()); } - #[test] - fn declined_consent_skips_reprompt_until_hash_changes() { - let dir = tempdir().unwrap(); - let path = write_claude_json( - dir.path(), - r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#, - ); - let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); - let mut store = ImportConsentStore::default(); - let mut decisions = HashMap::new(); - decisions.insert("x".into(), ImportDecision::Decline); - record_decisions(&mut store, &candidates, &decisions, 1); - let needing = candidates_needing_consent(&candidates, &store); - assert!(needing.is_empty(), "declined should not re-prompt"); - - // Content change → new hash → re-prompt. - fs::write( - &path, - r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#, - ) - .unwrap(); - let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); - let needing = candidates_needing_consent(&refreshed, &store); - assert_eq!(needing.len(), 1); - } - #[test] fn provenance_display_includes_source_and_hash() { let dir = tempdir().unwrap(); 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/network_policy.rs b/crates/tui/src/network_policy.rs index bbbbd06891..f9ce915e35 100644 --- a/crates/tui/src/network_policy.rs +++ b/crates/tui/src/network_policy.rs @@ -19,9 +19,8 @@ //! 3. [`NetworkAuditor`] — appends one plaintext line per outbound call to //! `~/.codewhale/audit.log` in the format described below. //! -//! In addition, [`NetworkSessionCache`] holds in-process "approve once for -//! this session" state for the `Prompt` flow, and [`NetworkDenied`] is the -//! structured error surfaced to callers when a host is blocked. +//! In addition, [`NetworkSessionCache`] holds in-process session state that +//! the decider's `evaluate` short-circuit consults. //! //! # Host-matching rules //! @@ -52,14 +51,13 @@ use std::sync::{Arc, Mutex}; use chrono::Utc; use serde::{Deserialize, Serialize}; -use thiserror::Error; /// What the policy decided about an outbound network call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Decision { /// Allow the call without prompting. Allow, - /// Deny the call. Surfaced to callers as [`NetworkDenied`]. + /// Deny the call. Deny, /// Defer to the user via an approval prompt. Prompt, @@ -201,22 +199,6 @@ impl NetworkPolicy { self.default.into() } - /// Append `host` to the allow list (de-duplicated, case-insensitive). - /// Used by the prompt flow when the user picks "always for this host". - pub fn add_allow(&mut self, host: &str) { - let normalized = normalize_host(host); - if normalized.is_empty() { - return; - } - if !self - .allow - .iter() - .any(|existing| normalize_host(existing) == normalized) - { - self.allow.push(normalized); - } - } - /// Whether audit logging is enabled. #[must_use] pub fn audit_enabled(&self) -> bool { @@ -414,37 +396,6 @@ impl NetworkSessionCache { .map(|guard| guard.denied.contains(&normalized)) .unwrap_or(false) } - - /// Mark the host as approved for the rest of this session. - pub fn approve(&self, host: &str) { - let normalized = normalize_host(host); - if let Ok(mut guard) = self.inner.lock() { - guard.denied.remove(&normalized); - guard.approved.insert(normalized); - } - } - - /// Mark the host as denied for the rest of this session. - pub fn deny(&self, host: &str) { - let normalized = normalize_host(host); - if let Ok(mut guard) = self.inner.lock() { - guard.approved.remove(&normalized); - guard.denied.insert(normalized); - } - } -} - -/// Structured error surfaced to callers when an outbound call is blocked. -#[derive(Debug, Clone, Error)] -#[error("network call to '{0}' blocked by network policy")] -pub struct NetworkDenied(pub String); - -impl NetworkDenied { - /// The host that was denied. - #[must_use] - pub fn host(&self) -> &str { - &self.0 - } } /// Glue type that bundles a [`NetworkPolicy`] with a session cache and an @@ -565,29 +516,6 @@ impl NetworkPolicyDecider { decision } - /// Approve `host` for the rest of the session (one-shot). Audit log gets - /// `Prompt-Approved`. - pub fn approve_session(&self, host: &str, tool: &str) { - self.cache.approve(host); - self.audit_record(host, tool, "Prompt-Approved"); - } - - /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`. - pub fn deny_session(&self, host: &str, tool: &str) { - self.cache.deny(host); - self.audit_record(host, tool, "Prompt-Denied"); - } - - /// Persist `host` into the policy's allow list (so it survives the session) - /// **and** approve it in-session. Returns the updated policy so callers can - /// write it back to disk. - pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy { - self.policy.add_allow(host); - self.cache.approve(host); - self.audit_record(host, tool, "Prompt-Approved"); - &self.policy - } - /// Whether this host is explicitly configured for trusted proxy fake-IP /// DNS handling. #[must_use] @@ -696,15 +624,6 @@ mod tests { assert_eq!(p.decide(" "), Decision::Deny); } - #[test] - fn add_allow_dedupes_case_insensitively() { - let mut p = mk(Decision::Deny, &[], &[]); - p.add_allow("Example.COM"); - p.add_allow("example.com"); - assert_eq!(p.allow.len(), 1); - assert_eq!(p.allow[0], "example.com"); - } - #[test] fn trusted_proxy_fakeip_hosts_match_exact_and_subdomains() { let mut p = mk(Decision::Deny, &[], &[]); @@ -818,53 +737,6 @@ mod tests { assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty()); } - #[test] - fn session_cache_short_circuits_evaluate() { - let policy = mk(Decision::Prompt, &[], &[]); - let decider = NetworkPolicyDecider::new(policy, None); - // First call returns Prompt. - assert_eq!( - decider.evaluate("api.example.com", "fetch_url"), - Decision::Prompt - ); - decider.approve_session("api.example.com", "fetch_url"); - // After approve_session, the same host returns Allow without prompting. - assert_eq!( - decider.evaluate("api.example.com", "fetch_url"), - Decision::Allow - ); - } - - #[test] - fn approve_persistent_writes_back_to_policy() { - let policy = mk(Decision::Prompt, &[], &[]); - let mut decider = NetworkPolicyDecider::new(policy, None); - decider.approve_persistent("api.example.com", "fetch_url"); - assert!( - decider - .policy() - .allow - .iter() - .any(|h| h == "api.example.com") - ); - // And the session cache also got updated, so fresh evaluate returns Allow. - assert_eq!( - decider.evaluate("api.example.com", "fetch_url"), - Decision::Allow - ); - } - - #[test] - fn deny_session_blocks_subsequent_evaluate() { - let policy = mk(Decision::Allow, &[], &[]); - let decider = NetworkPolicyDecider::new(policy, None); - decider.deny_session("evil.example.com", "fetch_url"); - assert_eq!( - decider.evaluate("evil.example.com", "fetch_url"), - Decision::Deny - ); - } - #[test] fn audit_records_terminal_decisions_through_decider() { let dir = tempdir().expect("tempdir"); @@ -892,11 +764,4 @@ mod tests { assert_eq!(Decision::parse("prompt"), Decision::Prompt); assert_eq!(Decision::parse("garbage"), Decision::Prompt); } - - #[test] - fn network_denied_carries_host() { - let err = NetworkDenied("api.example.com".to_string()); - assert_eq!(err.host(), "api.example.com"); - assert!(format!("{err}").contains("api.example.com")); - } } 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/prefix_cache.rs b/crates/tui/src/prefix_cache.rs index 7d23f99982..b49f186ce5 100644 --- a/crates/tui/src/prefix_cache.rs +++ b/crates/tui/src/prefix_cache.rs @@ -312,35 +312,11 @@ impl ToolCatalogCache { entry } - /// Drop every cached entry. Used by tool-registry mutation paths - /// (e.g. plugin hot-reload, MCP attach) when the caller cannot - /// easily prove the tool set is unchanged. - #[allow(dead_code)] // observability; called by /cache flush and tests - pub fn invalidate(&mut self) { - self.by_identity.clear(); - self.insertion_order.clear(); - } - /// Returns the number of cached entries. #[must_use] pub fn len(&self) -> usize { self.by_identity.len() } - - /// Returns `true` if the cache has no entries. - #[allow(dead_code)] // observability; surfaced via /status - #[must_use] - pub fn is_empty(&self) -> bool { - self.by_identity.is_empty() - } - - /// Returns `(current_entries, capacity)` for observability. Surfaced via - /// the `/status` chip in a follow-up; tests exercise the path. - #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it - #[must_use] - pub fn stats(&self) -> (usize, usize) { - (self.len(), self.capacity) - } } /// Content-derived identity for a tool slice. Order-sensitive: two slices @@ -425,6 +401,7 @@ fn hash_json_value(value: &serde_json::Value, state: &mut H) { #[allow(dead_code)] impl PrefixStabilityManager { /// Create a new manager and immediately pin the first fingerprint. + #[cfg(test)] pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self { let mut cache = ToolCatalogCache::new(); let fp = PrefixFingerprint::compute_with_tool_cache(system_text, tools, &mut cache); @@ -443,7 +420,7 @@ impl PrefixStabilityManager { } /// Create a manager in "unpinned" state — no initial fingerprint. - /// Call `pin()` or `check_and_update()` to establish the baseline. + /// Call `check(.., Some(reason))` to establish the baseline. pub fn new_unpinned() -> Self { Self { pinned: None, @@ -459,33 +436,6 @@ impl PrefixStabilityManager { } } - /// Explicitly pin a fingerprint, replacing any prior pinned state. - /// Returns `true` if this is the first pin, or `false` if replacing. - /// Note: does NOT increment `check_count` — that counter is reserved - /// for `check_and_update` calls so `stability_ratio()` stays accurate. - pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { - self.pin_with_reason(system_text, tools, "initial") - } - - /// Pin under an explicit reason (`initial`, `resume`, `change:`). - pub fn pin_with_reason( - &mut self, - system_text: &str, - tools: Option<&[Tool]>, - reason: &str, - ) -> bool { - let fp = PrefixFingerprint::compute_with_tool_cache( - system_text, - tools, - &mut self.tool_catalog_cache, - ); - let was_unpinned = self.pinned.is_none(); - self.pinned = Some(fp.clone()); - self.current = Some(fp); - self.pin_reason = Some(reason.to_string()); - was_unpinned - } - /// Record an expected miss that is not a header change (compaction, /// `/clear`, an edited turn). The pin is untouched; the reason is kept so /// `/cache stats` can explain the next low hit-rate turn. @@ -616,6 +566,7 @@ impl PrefixStabilityManager { } /// Attributed change/drift/reset history, oldest first. + #[cfg(test)] pub fn history(&self) -> impl Iterator { self.history.iter() } @@ -632,6 +583,7 @@ impl PrefixStabilityManager { /// - `Err(change)` when the prefix drifted. The pin is **kept**: an /// undeclared change never becomes the new baseline. Use [`Self::check`] /// with a declared reason to move the pin on purpose. + #[cfg(test)] pub fn check_and_update( &mut self, system_text: &str, @@ -646,6 +598,7 @@ impl PrefixStabilityManager { } /// Returns the most recent prefix change, if any. + #[cfg(test)] pub fn last_change(&self) -> Option<&PrefixChange> { self.last_change.as_ref() } @@ -656,11 +609,13 @@ impl PrefixStabilityManager { } /// Returns the current (most recently computed) fingerprint. + #[cfg(test)] pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> { self.current.as_ref() } /// Returns the total number of prefix changes detected. + #[cfg(test)] pub fn change_count(&self) -> u64 { self.change_count } @@ -681,31 +636,6 @@ impl PrefixStabilityManager { stable_checks as f64 / self.check_count as f64 } } - - /// Returns a human-readable stability summary. - pub fn summary(&self) -> String { - let pct = self.stability_ratio() * 100.0; - let pinned_short = self - .pinned - .as_ref() - .map(|fp| { - if fp.combined_sha256.len() >= 12 { - &fp.combined_sha256[..12] - } else { - &fp.combined_sha256 - } - }) - .unwrap_or("none"); - - format!( - "Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}", - pct = pct, - stable = self.check_count.saturating_sub(self.change_count), - total = self.check_count, - pinned_short = pinned_short, - changes = self.change_count, - ) - } } /// Serialize a tool to the same JSON shape the chat API receives, @@ -1166,16 +1096,6 @@ mod tests { assert_eq!(re_entry.sha256_hex, fresh.sha256_hex); } - #[test] - fn tool_catalog_cache_invalidate_clears_all() { - let mut cache = ToolCatalogCache::new(); - cache.fingerprint_for(&[make_tool("a")]); - cache.fingerprint_for(&[make_tool("b")]); - cache.invalidate(); - assert!(cache.is_empty()); - assert_eq!(cache.len(), 0); - } - #[test] fn tool_catalog_cache_empty_slice_uses_zero_capacity_path() { // Empty input is fine — should produce a stable, non-empty digest. diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index 7c500fcad2..46d0811d56 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -160,39 +160,3 @@ pub(crate) fn repo_relative_source_label(path: &Path, root: Option<&Path>) -> St } path.display().to_string() } - -/// Merge multiple project contexts (e.g., from nested directories) -#[allow(dead_code)] // Public API for monorepo context merging -pub fn merge_contexts(contexts: &[ProjectContext]) -> Option { - let non_empty: Vec<_> = contexts - .iter() - .filter_map(ProjectContext::as_system_block) - .collect(); - - if non_empty.is_empty() { - None - } else { - Some(non_empty.join("\n\n")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_merge_contexts() { - let mut ctx1 = ProjectContext::empty(PathBuf::from("/a")); - ctx1.instructions = Some("Instructions A".to_string()); - ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md")); - - let mut ctx2 = ProjectContext::empty(PathBuf::from("/b")); - ctx2.instructions = Some("Instructions B".to_string()); - ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md")); - - let merged = merge_contexts(&[ctx1, ctx2]).expect("merge"); - - assert!(merged.contains("Instructions A")); - assert!(merged.contains("Instructions B")); - } -} diff --git a/crates/tui/src/prompt_zones.rs b/crates/tui/src/prompt_zones.rs index 06a850ab40..87c6ed35a2 100644 --- a/crates/tui/src/prompt_zones.rs +++ b/crates/tui/src/prompt_zones.rs @@ -9,19 +9,14 @@ //! ├─────────────────────────────────────────┤ //! │ AppendLog (append-only) │ ← conversation history //! │ push() only, no insert / remove / edit │ preserves prefix of prior turns -//! ├─────────────────────────────────────────┤ -//! │ TurnScratch (ephemeral) │ ← per-turn metadata -//! │ cleared at every turn boundary │ the only new content per request //! └─────────────────────────────────────────┘ //! ``` //! -//! ## Status (Phase 1 foundation) +//! ## Status //! -//! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` are ready for use. -//! `AppendLog` / `TurnScratch` / `ThreeZoneRequest` are type scaffolding -//! for future phases — not yet wired into the request path. +//! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` / `AppendLog` are ready +//! for use; `AppendLog` itself is wired through `Session`'s message log. -use crate::models::Role; use crate::models::{Message, SystemPrompt, Tool}; // ── helpers ──────────────────────────────────────────────────────────── @@ -201,10 +196,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. @@ -281,124 +272,13 @@ impl std::ops::Deref for AppendLog { } } -// ── TurnScratch ──────────────────────────────────────────────────────── - -/// Per-turn ephemeral data. Cleared at every turn boundary. -/// -/// **Phase 1 scaffolding** — not yet wired into the engine request path. -#[cfg_attr(not(test), allow(dead_code))] -#[derive(Debug, Clone, Default)] -pub struct TurnScratch { - pub working_set: Vec, - pub user_message: Option, -} - -#[cfg_attr(not(test), allow(dead_code))] -impl TurnScratch { - pub fn new() -> Self { - Self::default() - } - - pub fn clear(&mut self) { - self.working_set.clear(); - self.user_message = None; - } - - #[must_use] - pub fn is_empty(&self) -> bool { - self.working_set.is_empty() && self.user_message.is_none() - } -} - -// ── ThreeZoneRequest ─────────────────────────────────────────────────── - -/// A composed three-zone request ready for DeepSeek API serialization. -/// -/// **Phase 1 scaffolding** — not yet wired into the engine request path. -/// Currently the engine continues to use `MessageRequest` directly. -#[expect(dead_code)] -#[derive(Debug, Clone)] -pub struct ThreeZoneRequest<'a> { - pub prefix: &'a FrozenPrefix, - pub log: &'a AppendLog, - pub scratch: TurnScratch, - pub model: String, - pub max_tokens: u32, - pub system: Option, - pub tools: Option>, - pub tool_choice: Option, - pub reasoning_effort: Option, - pub thinking: Option, - pub stream: Option, - pub temperature: Option, - pub top_p: Option, - pub metadata: Option, -} - -#[cfg_attr(not(test), expect(dead_code))] -impl<'a> ThreeZoneRequest<'a> { - /// Build the full message list from system prompt, append-log messages, - /// and scratch user message. The returned vector is serialized as the - /// `messages` field in the DeepSeek chat-completion request. - #[must_use] - pub fn build_messages(&self) -> Vec { - let mut messages = Vec::with_capacity(self.message_count()); - - match self.system.as_ref() { - Some(SystemPrompt::Text(text)) => { - messages.push(Message { - role: Role::System, - content: vec![crate::models::ContentBlock::Text { - text: text.clone(), - cache_control: None, - }], - }); - } - Some(SystemPrompt::Blocks(blocks)) => { - let content: Vec = blocks - .iter() - .map(|block| crate::models::ContentBlock::Text { - text: block.text.clone(), - cache_control: block.cache_control.clone(), - }) - .collect(); - messages.push(Message { - role: Role::System, - content, - }); - } - None => {} - } - - for msg in self.log.iter() { - messages.push(msg.clone()); - } - - if let Some(ref user_msg) = self.scratch.user_message { - messages.push(user_msg.clone()); - } - - messages - } - - #[must_use] - pub fn message_count(&self) -> usize { - let system_count = if self.system.is_some() { 1 } else { 0 }; - let scratch_count = if self.scratch.user_message.is_some() { - 1 - } else { - 0 - }; - system_count + self.log.len() + scratch_count - } -} - // ── tests ────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::models::ContentBlock; + use crate::models::Role; fn make_tool(name: &str) -> Tool { Tool { @@ -554,149 +434,4 @@ mod tests { let messages: Vec<_> = log.iter().collect(); 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] - fn scratch_clear_empties_all_fields() { - let mut scratch = TurnScratch::new(); - scratch.working_set.push("file.rs".to_string()); - scratch.user_message = Some(make_message("user", "task")); - - assert!(!scratch.is_empty()); - scratch.clear(); - assert!(scratch.is_empty()); - assert!(scratch.working_set.is_empty()); - assert!(scratch.user_message.is_none()); - } - - // ── ThreeZoneRequest ────────────────────────────────────────── - - #[test] - fn build_messages_concatenates_zones() { - let sys = SystemPrompt::Text("you are helpful".to_string()); - let tools = vec![make_tool("read")]; - let prefix = PinnedPrefix::new(Some(&sys), tools).freeze(); - - let mut log = AppendLog::new(); - log.push(make_message("user", "prev question")); - log.push(make_message("assistant", "prev answer")); - - let scratch = TurnScratch { - working_set: vec!["main.rs".to_string()], - user_message: Some(make_message("user", "current task")), - }; - - let request = ThreeZoneRequest { - prefix: &prefix, - log: &log, - scratch, - model: "deepseek-v4-pro".to_string(), - max_tokens: 4096, - system: Some(sys), - tools: None, - tool_choice: None, - reasoning_effort: None, - thinking: None, - stream: None, - temperature: None, - top_p: None, - metadata: None, - }; - - let messages = request.build_messages(); - assert_eq!(messages.len(), 4); - assert_eq!(messages[0].role, "system"); - assert_eq!(messages[1].role, "user"); - assert_eq!(messages[2].role, "assistant"); - assert_eq!(messages[3].role, "user"); - assert_eq!(request.message_count(), 4); - } - - #[test] - fn build_messages_no_system_no_scratch() { - let prefix = PinnedPrefix::new(None, vec![]).freeze(); - - let mut log = AppendLog::new(); - log.push(make_message("user", "hi")); - - let request = ThreeZoneRequest { - prefix: &prefix, - log: &log, - scratch: TurnScratch::new(), - model: "x".to_string(), - max_tokens: 1, - system: None, - tools: None, - tool_choice: None, - reasoning_effort: None, - thinking: None, - stream: None, - temperature: None, - top_p: None, - metadata: None, - }; - - let messages = request.build_messages(); - assert_eq!(messages.len(), 1); - assert_eq!(request.message_count(), 1); - } - - #[test] - fn blocks_system_prompt_preserves_cache_control() { - use crate::models::{CacheControl, SystemBlock}; - let cc = Some(CacheControl { - cache_type: "ephemeral".to_string(), - }); - let blocks = SystemPrompt::Blocks(vec![SystemBlock { - block_type: "text".to_string(), - text: "hello".to_string(), - cache_control: cc.clone(), - }]); - - let prefix = PinnedPrefix::new(Some(&blocks), vec![]).freeze(); - let log = AppendLog::new(); - let scratch = TurnScratch::new(); - let request = ThreeZoneRequest { - prefix: &prefix, - log: &log, - scratch, - model: "x".to_string(), - max_tokens: 1, - system: Some(blocks), - tools: None, - tool_choice: None, - reasoning_effort: None, - thinking: None, - stream: None, - temperature: None, - top_p: None, - metadata: None, - }; - - let messages = request.build_messages(); - assert_eq!(messages.len(), 1); - assert_eq!(messages[0].role, "system"); - // cache_control should be preserved on the block. - if let ContentBlock::Text { - cache_control: actual_cc, - .. - } = &messages[0].content[0] - { - assert_eq!( - actual_cc.as_ref().map(|c| c.cache_type.as_str()), - Some("ephemeral") - ); - } else { - panic!("expected Text content block"); - } - } } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 7b27b77f31..ecabd8f9ed 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -398,8 +398,6 @@ fn user_constitution_disabled_by_setup_state() -> bool { // prompt text in `text.rs` directly; the test suite below guards content // and ordering invariants (constitution structure and binding gates #4032, // byte-stable prefix ordering, prefix privacy #4632). -#[cfg(test)] -use text::CALM_PERSONALITY; pub use text::{ BASE_PROMPT, COMPACT_TEMPLATE, CORE_EXECUTION_PROFILE_PROMPT, GOAL_CONTINUATION_PROMPT, HEADLESS_BASE_PROMPT, LANGUAGE_PROMPT, MEMORY_GUIDANCE, OUTPUT_PROMPT, @@ -1062,14 +1060,6 @@ fn apply_static_prompt_composer( // ── Public API ──────────────────────────────────────────────────────── -/// Get the system prompt for a specific mode with project context. -pub fn system_prompt_for_mode_with_context( - workspace: &Path, - working_set_summary: Option<&str>, -) -> SystemPrompt { - system_prompt_for_mode_with_context_and_skills(workspace, working_set_summary, None, None, None) -} - /// Get the system prompt for a specific mode with project and skills context. /// /// **Volatile-content-last invariant.** Blocks are appended in order from @@ -2918,7 +2908,6 @@ mod tests { fn only_the_constitution_states_precedence() { // Composed overlays must describe behavior, never their own rank. let overlays = [ - ("CALM_PERSONALITY", CALM_PERSONALITY), ("COMPACT_TEMPLATE", COMPACT_TEMPLATE), ("MEMORY_GUIDANCE", MEMORY_GUIDANCE), ("LANGUAGE_PROMPT", LANGUAGE_PROMPT), @@ -3036,7 +3025,9 @@ mod tests { ) .unwrap(); - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + workspace, None, None, None, None, + )); assert!(prompt.contains(HANDOFF_BLOCK_MARKER)); assert!(prompt.contains("Finish #32.")); @@ -3099,8 +3090,13 @@ mod tests { #[test] fn missing_handoff_does_not_inject_block() { let tmp = tempdir().expect("tempdir"); - let prompt = - system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + tmp.path(), + None, + None, + None, + None, + )); assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); } @@ -3110,8 +3106,13 @@ mod tests { let dir = tmp.path().join(".deepseek"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("handoff.md"), " \n\n ").unwrap(); - let prompt = - system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + tmp.path(), + None, + None, + None, + None, + )); assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); } @@ -3286,8 +3287,13 @@ mod tests { #[test] fn compact_template_is_lazy_in_fresh_prompt() { let tmp = tempdir().expect("tempdir"); - let prompt = - system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + tmp.path(), + None, + None, + None, + None, + )); assert!(!prompt.contains("# Session relay")); assert!(!prompt.contains("## Verification")); } @@ -3653,8 +3659,12 @@ mod tests { let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); let workspace = workspace_tmp.path(); - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + workspace, None, None, None, None, + )); + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + workspace, None, None, None, None, + )); assert_byte_identical( "system_prompt_for_mode_with_context() on empty workspace", &a, @@ -3676,13 +3686,19 @@ mod tests { let workspace = tmp.path(); let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context( + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( workspace, Some(summary), + None, + None, + None, )); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context( + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( workspace, Some(summary), + None, + None, + None, )); assert_byte_identical( "system_prompt_for_mode_with_context with constant working_set summary", @@ -3716,8 +3732,12 @@ mod tests { ) .unwrap(); - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + workspace, None, None, None, None, + )); + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + workspace, None, None, None, None, + )); assert_byte_identical( "system_prompt_for_mode_with_context with constant handoff file", &a, @@ -3740,9 +3760,12 @@ mod tests { std::fs::write(handoff_dir.join("handoff.md"), "# handoff body\n").unwrap(); let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context( + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( workspace, Some(summary), + None, + None, + None, )); let execution_pos = prompt @@ -3790,14 +3813,16 @@ mod tests { let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str()); let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); let workspace = tmp.path(); - let prompt = match system_prompt_for_mode_with_context(workspace, None) { - SystemPrompt::Text(text) => text, - SystemPrompt::Blocks(blocks) => blocks - .iter() - .map(|block| block.text.as_str()) - .collect::>() - .join("\n"), - }; + let prompt = + match system_prompt_for_mode_with_context_and_skills(workspace, None, None, None, None) + { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .iter() + .map(|block| block.text.as_str()) + .collect::>() + .join("\n"), + }; // No absolute paths (Unix or Windows). let offending: Vec<&str> = prompt @@ -3990,20 +4015,6 @@ mod tests { ); } - /// #2953 — the Calm overlay (`CALM_PERSONALITY`) stays out of the default - /// model-prompt path to keep the static prefix slim. Voice and tone - /// guidance travels via the constitution preamble instead. - #[test] - fn default_prompt_does_not_include_calm_personality_overlay() { - let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale"); - let calm_text = CALM_PERSONALITY; - let first_calm_line = calm_text.lines().find(|l| !l.is_empty()).unwrap_or(""); - assert!( - !prompt.contains(first_calm_line), - "default agent prompt must not include the calm personality overlay" - ); - } - #[test] fn live_prompt_path_returns_world_state_blocks_with_markers() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 39c5e4a453..a2a046dced 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -14,9 +14,8 @@ //! in `../prompts.rs`): //! //! 1. Constitution (binding core: `BASE_PROMPT` + language/output law) -//! 2. Personality overlay (`CALM_PERSONALITY` — one overlay, not a set) -//! 3. Approval-policy overlays -//! 4. Runtime templates (compaction relay, goal continuation, memory, +//! 2. Approval-policy overlays +//! 3. Runtime templates (compaction relay, goal continuation, memory, //! core execution, sub-agent output contract) //! //! Edit prompt text here directly. Content and ordering invariants are @@ -154,40 +153,6 @@ Prefer plain prose for explanations; bulleted or numbered lists for sequential o If you genuinely need column-aligned data because the user asked for a table or for `/cost`-style output, keep columns narrow, ASCII-only, and limited to two or three columns. Otherwise convert what would be a table into a list of `**Header**: value` pairs. "#; -// ── Personality overlays — voice and tone ────────────────────────── -/// Calm personality overlay. -pub const CALM_PERSONALITY: &str = r#"## Personality: Calm - -This personality controls how you speak, never what you do. It cannot override -the constitution, any user directive, or any tool requirement. It is -presentation style only. - -Your voice is cool, spatial, and reserved. Think of yourself as an engineer in -a quiet room — competent, unhurried, precise. - -- State observations plainly. Leave room for the work to speak. -- Avoid exclamation marks, superlatives, and emotional signaling. -- When something goes wrong, describe the failure and the next step. A brief - acknowledgment is acceptable; do not over-apologize or dwell. -- Prefer concrete nouns and verbs over adjectives. "The patch applied cleanly" - over "That worked perfectly." -- In preambles, name the action: "Reading the module tree." not "Let me take a - look at this!" -- Brevity is clarity. Cut filler words. If a sentence can be six words instead - of twelve, make it six. -- Use spatial language when it helps: "deeper in the call stack," "one level - up," "across the module boundary." -- When the user is frustrated, acknowledge briefly and move to solution. Don't - dwell. - -This personality may never: -- Prevent a required tool call. -- Block a user-approved write. -- Override a verification step. -- Contradict a clear user directive. -- Supersede the constitution or the user's current request. -"#; - // ── Runtime templates ────────────────────────────────────────────── /// Session-relay template — injected only into the `/relay` request. Automatic /// compaction owns its separate successor-brief prompt in `compaction.rs`. 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..dbb80504ce 100644 --- a/crates/tui/src/resource_telemetry.rs +++ b/crates/tui/src/resource_telemetry.rs @@ -1,179 +1,11 @@ -#![allow(dead_code)] - -//! Resource-usage telemetry for long-running CodeWhale tasks. -//! -//! This module is a pure, side-effect-free foundation for surfacing how many -//! tokens and how much wall-clock time a task has consumed, optionally relative -//! to a budget. It performs no I/O and no rendering; consumers (status lines, -//! the cost panel, the goal/budget tooling) are wired up separately so the -//! formatting and pressure logic can be unit-tested in isolation. +//! Output-token throughput estimation for long-running CodeWhale tasks. //! -//! The shape intentionally mirrors the budget vocabulary already used by the -//! goal tooling (`token_budget: Option<_>`) so a consumer can adapt between the -//! two without inventing new concepts. We keep a local type rather than reusing -//! `tools::goal` here to avoid coupling a presentation-layer helper to the tool -//! domain model (whose budgets are `u32` and carry unrelated bookkeeping). - -use std::{ - fmt::{self, Write as _}, - time::Duration, -}; +//! This module is a pure, side-effect-free foundation for the footer's live +//! throughput readout. It performs no I/O and no rendering; consumers (the +//! status line and event loop) wire it up directly so the estimation logic +//! can be unit-tested in isolation. -/// A coarse, three-level read on how close a task is to exhausting its budget. -/// -/// The level is derived from the *highest* pressure across all bounded -/// dimensions (tokens and time), so a task that is comfortable on tokens but -/// nearly out of time still reports [`PressureLevel::High`]. When nothing is -/// bounded, pressure is [`PressureLevel::Low`] by definition. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum PressureLevel { - /// Plenty of headroom (under ~75% of every bounded budget). - Low, - /// Getting close (at/over ~75% but under 100% of some budget). - Medium, - /// At or over budget on some bounded dimension. - High, -} - -impl PressureLevel { - /// Fraction at/above which a dimension is considered medium pressure. - const MEDIUM_THRESHOLD: f64 = 0.75; - /// Fraction at/above which a dimension is considered high pressure. - const HIGH_THRESHOLD: f64 = 1.0; - - /// Classify a single budget fraction (e.g. `0.41` for 41% used). - /// - /// Negative or non-finite input is treated as [`PressureLevel::Low`]; the - /// telemetry helpers never produce such values, but classifying defensively - /// keeps this usable for arbitrary callers. - fn from_fraction(fraction: f64) -> Self { - if !fraction.is_finite() || fraction < Self::MEDIUM_THRESHOLD { - PressureLevel::Low - } else if fraction < Self::HIGH_THRESHOLD { - PressureLevel::Medium - } else { - PressureLevel::High - } - } - - /// A short lowercase label suitable for compact status output. - pub fn label(self) -> &'static str { - match self { - PressureLevel::Low => "low", - PressureLevel::Medium => "medium", - PressureLevel::High => "high", - } - } -} - -/// A snapshot of token and time usage for a single task, with optional budgets. -/// -/// All fields are plain counters; this type owns no clock and reads no -/// environment. Construct it from whatever the caller is already tracking and -/// use the helpers below to render or classify it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub struct ResourceTelemetry { - /// Total tokens consumed so far. - pub tokens_used: u64, - /// Total wall-clock seconds elapsed so far. - pub time_used_seconds: u64, - /// Optional token ceiling for the task; `None` means unbounded. - pub token_budget: Option, - /// Optional time ceiling in seconds; `None` means unbounded. - pub time_budget_seconds: Option, -} - -impl ResourceTelemetry { - /// Create a telemetry snapshot with no budgets (fully unbounded). - pub fn new(tokens_used: u64, time_used_seconds: u64) -> Self { - Self { - tokens_used, - time_used_seconds, - token_budget: None, - time_budget_seconds: None, - } - } - - /// Set the token budget, returning the updated snapshot (builder style). - pub fn with_token_budget(mut self, budget: u64) -> Self { - self.token_budget = Some(budget); - self - } - - /// Set the time budget in seconds, returning the updated snapshot. - pub fn with_time_budget_seconds(mut self, seconds: u64) -> Self { - self.time_budget_seconds = Some(seconds); - self - } - - /// Fraction of the token budget consumed, or `None` when unbounded. - /// - /// A zero budget yields `None` (a percentage of nothing is meaningless) - /// rather than infinity, keeping every downstream consumer safe. - pub fn token_fraction(&self) -> Option { - fraction(self.tokens_used, self.token_budget) - } - - /// Fraction of the time budget consumed, or `None` when unbounded. - pub fn time_fraction(&self) -> Option { - fraction(self.time_used_seconds, self.time_budget_seconds) - } - - /// The largest bounded budget fraction across tokens and time. - /// - /// Returns `None` only when *neither* dimension is bounded. When at least - /// one budget is present, the most-pressured bounded dimension wins. - pub fn budget_fraction(&self) -> Option { - match (self.token_fraction(), self.time_fraction()) { - (Some(t), Some(s)) => Some(t.max(s)), - (Some(t), None) => Some(t), - (None, Some(s)) => Some(s), - (None, None) => None, - } - } - - /// Budget fraction expressed as a whole-number percent (rounded), or `None` - /// when unbounded. This is the value surfaced in the human summary. - pub fn budget_percent(&self) -> Option { - self.budget_fraction().map(|f| (f * 100.0).round() as u64) - } - - /// Coarse pressure level derived from [`Self::budget_fraction`]. - /// - /// Unbounded tasks are always [`PressureLevel::Low`]. - pub fn pressure(&self) -> PressureLevel { - match self.budget_fraction() { - Some(fraction) => PressureLevel::from_fraction(fraction), - None => PressureLevel::Low, - } - } - - /// A compact, human-readable one-liner, e.g. `12.3k tok · 4m12s · 41% budget`. - /// - /// Tokens are abbreviated with `k`/`M` suffixes, time is rendered as - /// `Hh Mm Ss` (dropping leading zero units), and the budget segment is - /// omitted entirely when the task is unbounded. - pub fn human_summary(&self) -> String { - let mut out = String::new(); - // `write!` into a String is infallible; ignore the Result. - let _ = write!( - out, - "{} tok · {}", - format_tokens(self.tokens_used), - crate::elapsed::format_elapsed_secs(self.time_used_seconds), - ); - if let Some(percent) = self.budget_percent() { - let _ = write!(out, " · {percent}% budget"); - } - out - } -} - -impl fmt::Display for ResourceTelemetry { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.human_summary()) - } -} +use std::time::Duration; /// Output-token throughput for a live or completed turn. #[derive(Debug, Clone, Copy, PartialEq)] @@ -194,22 +26,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. @@ -226,100 +45,16 @@ pub fn estimate_output_tokens_from_text(text: &str) -> u64 { } } -/// Divide `used` by an optional budget, guarding against an absent or zero -/// budget. Returns `None` when the budget is `None` or `0`. -fn fraction(used: u64, budget: Option) -> Option { - match budget { - Some(budget) if budget > 0 => Some(used as f64 / budget as f64), - _ => None, - } -} - -/// Format a token count with a `k`/`M` suffix once it crosses each threshold. -/// -/// Values under 1_000 are printed verbatim. Thousands use one decimal place -/// (`12.3k`), trimming a trailing `.0` so round values read cleanly (`5k`). -/// Millions follow the same rule (`1.5M`, `2M`). -fn format_tokens(tokens: u64) -> String { - const K: u64 = 1_000; - const M: u64 = 1_000_000; - if tokens >= M { - format_scaled(tokens, M, 'M') - } else if tokens >= K { - format_scaled(tokens, K, 'k') - } else { - tokens.to_string() - } -} - -/// Render `value / divisor` to one decimal place with `suffix`, dropping a -/// trailing `.0`. The divisor is always one of the constants above (non-zero). -fn format_scaled(value: u64, divisor: u64, suffix: char) -> String { - let scaled = value as f64 / divisor as f64; - // Round to one decimal before deciding whether the fraction is ".0", so a - // value like 1_999_999 reads as "2M" rather than "1.9...M". - let rounded = (scaled * 10.0).round() / 10.0; - if (rounded.fract()).abs() < f64::EPSILON { - format!("{}{}", rounded as u64, suffix) - } else { - format!("{rounded:.1}{suffix}") - } -} - #[cfg(test)] mod tests { use super::*; - // ---- token formatting ------------------------------------------------- - - #[test] - fn format_tokens_under_a_thousand_is_verbatim() { - assert_eq!(format_tokens(0), "0"); - assert_eq!(format_tokens(1), "1"); - assert_eq!(format_tokens(999), "999"); - } - - #[test] - fn format_tokens_uses_k_suffix_with_trimmed_decimal() { - assert_eq!(format_tokens(1_000), "1k"); - assert_eq!(format_tokens(1_500), "1.5k"); - assert_eq!(format_tokens(12_345), "12.3k"); - // Exactly on a round thousand trims the ".0". - assert_eq!(format_tokens(5_000), "5k"); - // Just under the millions boundary stays in k. - assert_eq!(format_tokens(999_400), "999.4k"); - } - - #[test] - fn format_tokens_uses_m_suffix_for_millions() { - assert_eq!(format_tokens(1_000_000), "1M"); - assert_eq!(format_tokens(1_500_000), "1.5M"); - assert_eq!(format_tokens(2_340_000), "2.3M"); - } - - #[test] - fn format_tokens_rounds_up_across_a_unit_boundary() { - // 1_999_999 rounds to 2.0M -> "2M", not "1.9M" or "2.0M". - assert_eq!(format_tokens(1_999_999), "2M"); - // 999_950 rounds to 1000.0k; still within the k branch and trims ".0". - assert_eq!(format_tokens(999_950), "1000k"); - } - - #[test] - fn format_tokens_handles_very_large_values() { - assert_eq!(format_tokens(u64::MAX), "18446744073709.6M"); - } - // ---- 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,167 +69,5 @@ 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 ---------------------------------------------- - - #[test] - fn fractions_are_none_when_unbounded() { - let t = ResourceTelemetry::new(5_000, 120); - assert_eq!(t.token_fraction(), None); - assert_eq!(t.time_fraction(), None); - assert_eq!(t.budget_fraction(), None); - assert_eq!(t.budget_percent(), None); - } - - #[test] - fn zero_budget_yields_none_not_infinity() { - let t = ResourceTelemetry { - tokens_used: 100, - time_used_seconds: 0, - token_budget: Some(0), - time_budget_seconds: Some(0), - }; - assert_eq!(t.token_fraction(), None); - assert_eq!(t.time_fraction(), None); - assert_eq!(t.budget_fraction(), None); - assert_eq!(t.pressure(), PressureLevel::Low); - } - - #[test] - fn token_fraction_is_computed_when_bounded() { - let t = ResourceTelemetry::new(4_100, 0).with_token_budget(10_000); - let frac = t.token_fraction().expect("bounded"); - assert!((frac - 0.41).abs() < 1e-9, "got {frac}"); - assert_eq!(t.budget_percent(), Some(41)); - } - - #[test] - fn budget_fraction_takes_the_max_across_dimensions() { - // Tokens at 10%, time at 80% -> the time pressure dominates. - let t = ResourceTelemetry { - tokens_used: 1_000, - time_used_seconds: 80, - token_budget: Some(10_000), - time_budget_seconds: Some(100), - }; - let frac = t.budget_fraction().expect("bounded"); - assert!((frac - 0.80).abs() < 1e-9, "got {frac}"); - assert_eq!(t.budget_percent(), Some(80)); - } - - #[test] - fn budget_fraction_present_when_only_one_dimension_bounded() { - let only_time = ResourceTelemetry::new(9_999, 50).with_time_budget_seconds(200); - assert_eq!(only_time.budget_percent(), Some(25)); - - let only_tokens = ResourceTelemetry::new(2_500, 9_999).with_token_budget(10_000); - assert_eq!(only_tokens.budget_percent(), Some(25)); - } - - #[test] - fn budget_percent_rounds_to_nearest_whole() { - // 333 / 1000 = 33.3% -> 33 - let down = ResourceTelemetry::new(333, 0).with_token_budget(1_000); - assert_eq!(down.budget_percent(), Some(33)); - // 336 / 1000 = 33.6% -> 34 - let up = ResourceTelemetry::new(336, 0).with_token_budget(1_000); - assert_eq!(up.budget_percent(), Some(34)); - } - - // ---- pressure levels -------------------------------------------------- - - #[test] - fn pressure_low_when_unbounded_regardless_of_usage() { - let t = ResourceTelemetry::new(u64::MAX, u64::MAX); - assert_eq!(t.pressure(), PressureLevel::Low); - } - - #[test] - fn pressure_thresholds_just_under_and_over() { - // 74% -> Low (just under the medium threshold). - let low = ResourceTelemetry::new(7_400, 0).with_token_budget(10_000); - assert_eq!(low.pressure(), PressureLevel::Low); - - // Exactly 75% -> Medium (inclusive lower bound). - let medium_edge = ResourceTelemetry::new(7_500, 0).with_token_budget(10_000); - assert_eq!(medium_edge.pressure(), PressureLevel::Medium); - - // 99% -> Medium (just under the high threshold). - let medium = ResourceTelemetry::new(9_900, 0).with_token_budget(10_000); - assert_eq!(medium.pressure(), PressureLevel::Medium); - - // Exactly 100% -> High (at budget). - let high_edge = ResourceTelemetry::new(10_000, 0).with_token_budget(10_000); - assert_eq!(high_edge.pressure(), PressureLevel::High); - - // Over budget -> High. - let over = ResourceTelemetry::new(12_500, 0).with_token_budget(10_000); - assert_eq!(over.pressure(), PressureLevel::High); - } - - #[test] - fn pressure_level_labels_and_ordering() { - assert_eq!(PressureLevel::Low.label(), "low"); - assert_eq!(PressureLevel::Medium.label(), "medium"); - assert_eq!(PressureLevel::High.label(), "high"); - // Ord derive: Low < Medium < High. - assert!(PressureLevel::Low < PressureLevel::Medium); - assert!(PressureLevel::Medium < PressureLevel::High); - } - - #[test] - fn pressure_from_fraction_ignores_non_finite() { - assert_eq!(PressureLevel::from_fraction(f64::NAN), PressureLevel::Low); - assert_eq!( - PressureLevel::from_fraction(f64::INFINITY), - PressureLevel::Low - ); - assert_eq!(PressureLevel::from_fraction(-0.5), PressureLevel::Low); - } - - // ---- human summary ---------------------------------------------------- - - #[test] - fn human_summary_bounded_matches_example_shape() { - let t = ResourceTelemetry::new(12_345, 252).with_token_budget(30_000); - // 12_345 -> "12.3k", 252s -> "4m 12s", 12345/30000 = 41.15% -> 41%. - assert_eq!(t.human_summary(), "12.3k tok · 4m 12s · 41% budget"); - } - - #[test] - fn human_summary_unbounded_omits_budget_segment() { - let t = ResourceTelemetry::new(500, 5); - assert_eq!(t.human_summary(), "500 tok · 5s"); - // Display delegates to human_summary. - assert_eq!(t.to_string(), "500 tok · 5s"); - } - - #[test] - fn human_summary_zero_everything() { - let t = ResourceTelemetry::default(); - assert_eq!(t.human_summary(), "0 tok · 0s"); - } - - #[test] - fn human_summary_over_budget_can_exceed_one_hundred_percent() { - let t = ResourceTelemetry::new(15_000, 7_320).with_token_budget(10_000); - // 15000/10000 = 150%, 7320s -> 122m 00s. - assert_eq!(t.human_summary(), "15k tok · 122m 00s · 150% budget"); - assert_eq!(t.pressure(), PressureLevel::High); - } - - #[test] - fn human_summary_with_only_time_budget() { - let t = ResourceTelemetry::new(2_000_000, 300).with_time_budget_seconds(600); - // 2M tokens, 5m 00s, 300/600 = 50% budget. - assert_eq!(t.human_summary(), "2M tok · 5m 00s · 50% budget"); - assert_eq!(t.pressure(), PressureLevel::Low); } } diff --git a/crates/tui/src/route_billing.rs b/crates/tui/src/route_billing.rs index a7a28ef613..c197861a03 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 @@ -2031,50 +2010,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 +2491,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/route_runtime.rs b/crates/tui/src/route_runtime.rs index 67a0d42379..6a08e8aaa3 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Duration, Utc}; use codewhale_config::provider::{WireFormat, wire_dialect_override}; use codewhale_config::route::{ LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteLimits, RouteRequest, @@ -24,7 +23,6 @@ use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS; pub(crate) enum ContextWindowSource { EmbeddingHost, Configured, - ProviderReported, StaticKimiCodeSafeFloor, Catalog, /// Parsed from a vendor-agnostic `_Nk` suffix in the model name @@ -39,10 +37,9 @@ impl ContextWindowSource { /// Every rung, in precedence order. The name-suffix hint sits between /// catalog data and the conservative fallback: any concrete fact about /// the route beats a naming convention. - pub(crate) const ALL: [Self; 7] = [ + pub(crate) const ALL: [Self; 6] = [ Self::EmbeddingHost, Self::Configured, - Self::ProviderReported, Self::StaticKimiCodeSafeFloor, Self::Catalog, Self::NameSuffixHint, @@ -54,7 +51,6 @@ impl ContextWindowSource { match self { Self::EmbeddingHost => "embedding host", Self::Configured => "configured", - Self::ProviderReported => "provider-reported", Self::StaticKimiCodeSafeFloor => "static Kimi Code safe floor", Self::Catalog => "catalog", Self::NameSuffixHint => "model-name hint", @@ -167,19 +163,6 @@ fn classify_capability_fallback_window(model: &str, tokens: u32) -> ContextWindo } } -/// Authenticated Kimi Code `/models` metadata that a caller has already -/// validated. This is intentionally route-scoped: generic Moonshot metadata -/// can never promote a bare `k3` route. The current runtime has no implicit -/// network probe; an authenticated model-listing consumer may pass this value -/// to [`resolve_route_candidate_with_context_metadata`]. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ProviderReportedKimiCodeContext { - pub(crate) context_tokens: u32, - pub(crate) observed_at: DateTime, -} - -const KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS: i64 = 24; - #[derive(Debug)] pub(crate) struct RouteCandidateResolution { pub(crate) candidate: ReadyRouteCandidate, @@ -378,7 +361,6 @@ pub(crate) fn resolve_route_candidate( saved_provider_model, base_url_override, context_window_override, - None, // Config-free by contract: this wrapper's callers (unpinned child // admission, the fleet hermetic fallback) build no client from the // candidate. The fallback renders `candidate.protocol()` as a display @@ -431,10 +413,7 @@ pub(crate) fn resolve_unpinned_model_candidate( } /// Resolve a candidate together with a non-secret context-window provenance -/// receipt. `provider_reported_context` is accepted only for the exact Kimi -/// Code bare-K3 endpoint, only at the documented 1M entitlement, and only -/// while fresh; this prevents generic Moonshot or stale metadata from being -/// inherited by a membership-plan route. +/// receipt. /// /// `custom_wire_override` must carry the dialect of the same table that /// supplied `base_url_override` when `provider` is @@ -448,7 +427,6 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( saved_provider_model: Option<&str>, base_url_override: Option, context_window_override: Option, - provider_reported_context: Option, custom_wire_override: Option, ) -> Result { resolve_route_candidate_with_context_metadata_and_host_limits( @@ -457,7 +435,6 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( saved_provider_model, base_url_override, context_window_override, - provider_reported_context, None, custom_wire_override, ) @@ -492,7 +469,6 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( saved_provider_model: Option<&str>, base_url_override: Option, context_window_override: Option, - provider_reported_context: Option, host_limits: Option, custom_wire_override: Option, ) -> Result { @@ -523,12 +499,7 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( let resolved = resolver .resolve(&base_request) .map_err(|err| err.to_string())?; - let mut plan = plan_limit_overrides( - provider, - &resolved, - context_window_override, - provider_reported_context, - ); + let mut plan = plan_limit_overrides(provider, &resolved, context_window_override); if let Some(host_limits) = host_limits { append_embedding_host_limit_overrides(&mut plan, host_limits)?; } @@ -595,14 +566,12 @@ struct LimitOverridePlan { /// /// Precedence (unchanged from the previous post-hoc mutation order): /// provider-scoped roster/API corrections and exact-route documented output -/// facts first, then operator-configured context, then fresh route-scoped -/// provider-reported context, then the membership-plan safe floor, then -/// catalog data, then the conservative fallback. +/// facts first, then operator-configured context, then the membership-plan +/// safe floor, then catalog data, then the conservative fallback. fn plan_limit_overrides( provider: ApiProvider, resolved: &ReadyRouteCandidate, context_window_override: Option, - provider_reported_context: Option, ) -> LimitOverridePlan { let mut overrides = Vec::new(); let configured = context_window_override.filter(|window| *window > 0); @@ -671,34 +640,10 @@ fn plan_limit_overrides( &resolved.endpoint().base_url, resolved.wire_model_id().as_str(), ); - let now = Utc::now(); - if is_exact_kimi_code_k3 - && provider_reported_context.is_some_and(|reported| { - reported.context_tokens == 1_048_576 - && reported.observed_at <= now - && now.signed_duration_since(reported.observed_at) - <= Duration::hours(KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS) - }) - { - let reported = provider_reported_context.expect("checked above"); - overrides.push(SourcedLimitOverride { - field: LimitField::ContextTokens, - value: Some(u64::from(reported.context_tokens)), - source: OverrideSource::ProviderReportedContextWindow, - }); - return LimitOverridePlan { - overrides, - context_window: ContextWindowResolution { - tokens: reported.context_tokens, - source: ContextWindowSource::ProviderReported, - }, - }; - } // Kimi Code's bare `k3` is a membership-plan route, not an alias for // Moonshot's public `kimi-k3` catalog entry. The safe all-plan floor is - // the route's next precedence after an explicit config or fresh, scoped - // provider report. + // the route's next precedence after an explicit config. if is_exact_kimi_code_k3 { overrides.push(SourcedLimitOverride { field: LimitField::ContextTokens, @@ -832,7 +777,6 @@ fn resolve_runtime_route_for_identity_with_limits( saved_provider_model, Some(route_config.deepseek_base_url()), route_config.context_window_for_provider_config(provider), - None, host_limits, custom_wire_override, )?; @@ -1322,7 +1266,6 @@ mod tests { base.clone(), None, None, - None, ) .expect("Kimi Code route"); assert_eq!(static_floor.context_window.tokens, 262_144); @@ -1335,12 +1278,8 @@ mod tests { ApiProvider::Moonshot, Some("k3"), None, - base.clone(), + base, Some(1_048_576), - Some(ProviderReportedKimiCodeContext { - context_tokens: 1_048_576, - observed_at: Utc::now(), - }), None, ) .expect("configured route"); @@ -1350,53 +1289,12 @@ mod tests { ContextWindowSource::Configured ); - let reported = resolve_route_candidate_with_context_metadata( - ApiProvider::Moonshot, - Some("k3"), - None, - base.clone(), - None, - Some(ProviderReportedKimiCodeContext { - context_tokens: 1_048_576, - observed_at: Utc::now(), - }), - None, - ) - .expect("fresh documented provider metadata"); - assert_eq!(reported.context_window.tokens, 1_048_576); - assert_eq!( - reported.context_window.source, - ContextWindowSource::ProviderReported - ); - - let stale = resolve_route_candidate_with_context_metadata( - ApiProvider::Moonshot, - Some("k3"), - None, - base, - None, - Some(ProviderReportedKimiCodeContext { - context_tokens: 1_048_576, - observed_at: Utc::now() - Duration::hours(25), - }), - None, - ) - .expect("stale metadata falls back safely"); - assert_eq!( - stale.context_window.source, - ContextWindowSource::StaticKimiCodeSafeFloor - ); - let generic_err = resolve_route_candidate_with_context_metadata( ApiProvider::Moonshot, Some("k3"), None, Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()), None, - Some(ProviderReportedKimiCodeContext { - context_tokens: 1_048_576, - observed_at: Utc::now(), - }), None, ) .expect_err("bare k3 is rejected on the direct Moonshot endpoint (#4687)"); 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/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 61ece593cb..0ebd5350f3 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -10522,65 +10522,6 @@ fn tool_kind_for_name(name: &str) -> TurnItemKind { TurnItemKind::ToolCall } -/// One sub-agent rebind hint extracted from a thread's persisted event -/// timeline (issue #128). When the TUI resumes a session that was -/// mid-fanout, the in-transcript card stack is empty — these hints let the -/// UI know which agent_ids were live (or recently terminal) so it can -/// reconstruct the matching `DelegateCard` / `FanoutCard` placeholders -/// before fresh mailbox envelopes arrive on a re-attached engine. -/// -/// The helper is the testable contract here — actual TUI wire-up to the -/// resume flow is a follow-up. -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow(dead_code)] // consumed by #128 follow-up TUI resume wiring; tested here. -pub struct AgentRebindHint { - pub agent_id: String, - pub status: AgentRebindStatus, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] -pub enum AgentRebindStatus { - Spawned, - InProgress, - Completed, -} - -/// Collapse a chronologically ordered slice of `RuntimeEventRecord` into -/// the latest known status per `agent_id`. Drops entries that aren't in -/// the `agent.*` family. Cards built from these hints are immediately -/// open to mutation by subsequent live mailbox envelopes (each envelope's -/// `agent_id` matches one already in the rebind map). -#[must_use] -#[allow(dead_code)] -pub fn collect_agent_rebind_hints(events: &[RuntimeEventRecord]) -> Vec { - use std::collections::BTreeMap; - let mut latest: BTreeMap = BTreeMap::new(); - for event in events { - let id = match event.payload.get("agent_id").and_then(|v| v.as_str()) { - Some(id) => id.to_string(), - None => continue, - }; - let next_status = match event.event.as_str() { - "agent.spawned" => Some(AgentRebindStatus::Spawned), - "agent.progress" => Some(AgentRebindStatus::InProgress), - "agent.completed" => Some(AgentRebindStatus::Completed), - _ => None, - }; - if let Some(status) = next_status { - // Don't downgrade Completed → InProgress on out-of-order events. - let entry = latest.entry(id).or_insert(status); - if !matches!(*entry, AgentRebindStatus::Completed) { - *entry = status; - } - } - } - latest - .into_iter() - .map(|(agent_id, status)| AgentRebindHint { agent_id, status }) - .collect() -} - pub fn summarize_text(text: &str, limit: usize) -> String { let take = limit.saturating_sub(3); let mut count = 0; diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 627ec21196..eb0819f5bd 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -11610,98 +11610,6 @@ fn mode_only_override_preserves_legacy_full_access_posture() -> Result<()> { Ok(()) } -fn rebind_event(event: &str, agent_id: &str, seq: u64) -> RuntimeEventRecord { - RuntimeEventRecord { - schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, - seq, - timestamp: Utc::now(), - thread_id: "thr_test".to_string(), - turn_id: Some("turn_test".to_string()), - item_id: None, - event: event.to_string(), - payload: json!({ "agent_id": agent_id }), - } -} - -#[test] -fn collect_agent_rebind_hints_resumes_a_mid_fanout_session() { - // Mirror what runtime_threads persists during a real fanout: three - // workers spawned, two finished, one still running when the session - // was killed. The TUI re-attach must rebuild placeholders for the - // running worker AND the two completed workers (the fanout card - // tracks all of them so the dot-grid stays accurate post-resume). - let events = vec![ - rebind_event("agent.spawned", "agent_a", 1), - rebind_event("agent.spawned", "agent_b", 2), - rebind_event("agent.spawned", "agent_c", 3), - rebind_event("agent.progress", "agent_a", 4), - rebind_event("agent.completed", "agent_a", 5), - rebind_event("agent.progress", "agent_b", 6), - rebind_event("agent.completed", "agent_b", 7), - rebind_event("agent.progress", "agent_c", 8), - ]; - let hints = collect_agent_rebind_hints(&events); - assert_eq!(hints.len(), 3, "every fanout worker must be rebound"); - let by_id: std::collections::BTreeMap<&str, AgentRebindStatus> = hints - .iter() - .map(|h| (h.agent_id.as_str(), h.status)) - .collect(); - assert_eq!(by_id.get("agent_a"), Some(&AgentRebindStatus::Completed)); - assert_eq!(by_id.get("agent_b"), Some(&AgentRebindStatus::Completed)); - assert_eq!( - by_id.get("agent_c"), - Some(&AgentRebindStatus::InProgress), - "in-flight worker must rebind in InProgress, not downgrade" - ); -} - -#[test] -fn collect_agent_rebind_hints_ignores_unrelated_events() { - // Status / tool events should not produce phantom hints — only the - // agent.* family carries the contract we re-bind from. - let events = vec![ - RuntimeEventRecord { - schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, - seq: 1, - timestamp: Utc::now(), - thread_id: "thr".to_string(), - turn_id: None, - item_id: None, - event: "tool.completed".to_string(), - payload: json!({"name": "read_file"}), - }, - rebind_event("agent.spawned", "agent_x", 2), - RuntimeEventRecord { - schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, - seq: 3, - timestamp: Utc::now(), - thread_id: "thr".to_string(), - turn_id: None, - item_id: None, - event: "compaction.completed".to_string(), - payload: json!({"messages_after": 12}), - }, - ]; - let hints = collect_agent_rebind_hints(&events); - assert_eq!(hints.len(), 1); - assert_eq!(hints[0].agent_id, "agent_x"); -} - -#[test] -fn collect_agent_rebind_hints_does_not_downgrade_completed_to_in_progress() { - // Out-of-order replay: a stale `agent.progress` arriving after the - // completed event must NOT clobber the terminal status. This matters - // when an event log is concatenated from interrupted segments. - let events = vec![ - rebind_event("agent.spawned", "agent_y", 1), - rebind_event("agent.completed", "agent_y", 2), - rebind_event("agent.progress", "agent_y", 3), - ]; - let hints = collect_agent_rebind_hints(&events); - assert_eq!(hints.len(), 1); - assert_eq!(hints[0].status, AgentRebindStatus::Completed); -} - /// Helper for the `fork_at_user_message` tests: write a sequence of /// (user, assistant) turns under the given thread id. Each turn gets /// one UserMessage item carrying `user_text` in `detail` plus one 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/session_tree.rs b/crates/tui/src/session_tree.rs index 8b934059b9..1403b18d68 100644 --- a/crates/tui/src/session_tree.rs +++ b/crates/tui/src/session_tree.rs @@ -158,32 +158,6 @@ impl SessionJournal { pub fn append_message(&mut self, message: Message) -> EntryId { self.append(SessionEntryKind::Message { message }) } - pub fn append_compaction( - &mut self, - summary: String, - tokens_before: Option, - tokens_after: Option, - model: Option, - ) -> EntryId { - self.append(SessionEntryKind::Compaction { - summary, - tokens_before, - tokens_after, - model, - }) - } - pub fn append_branch_summary( - &mut self, - branch_id: String, - summary: String, - parent_branch_id: Option, - ) -> EntryId { - self.append(SessionEntryKind::BranchSummary { - branch_id, - summary, - parent_branch_id, - }) - } pub fn branch_to(&mut self, entry_id: &str) -> Result<(), String> { if self.entries.iter().any(|e| e.id == entry_id) { self.leaf_id = Some(entry_id.to_string()); @@ -547,25 +521,6 @@ mod tests { ); } #[test] - fn compaction_fits() { - let mut j = SessionJournal::new(); - let id = j.append_compaction("summary".into(), Some(1000), Some(100), None); - assert!(j.contains(&id)); - assert!(matches!( - j.leaf().unwrap().kind, - SessionEntryKind::Compaction { .. } - )); - } - #[test] - fn branch_summary_fits() { - let mut j = SessionJournal::new(); - let a = j.append(SessionEntryKind::User { - text: "root".into(), - }); - let b = j.append_branch_summary(a.clone(), "branch summary".into(), None); - assert!(j.contains(&b)); - } - #[test] fn spawn_depth_fork() { let mut j = SessionJournal::with_spawn_depth(1); let forked = j.fork_from(None).unwrap(); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6858c4eaeb..6589a957dd 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -2100,25 +2100,6 @@ impl Settings { true } - #[allow(dead_code)] // label editing surface is exposed through settings serialization first - pub fn set_pinned_model_label( - &mut self, - provider: &str, - model: &str, - label: Option, - ) -> bool { - self.pinned_models - .iter_mut() - .find(|pin| { - pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model) - }) - .map(|pin| { - pin.label = label.filter(|value| !value.trim().is_empty()); - true - }) - .unwrap_or(false) - } - pub fn move_pinned_model(&mut self, provider: &str, model: &str, delta: isize) -> bool { let Some(index) = self.pinned_models.iter().position(|pin| { pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model) @@ -3744,17 +3725,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"); @@ -5686,7 +5656,6 @@ mod tests { assert_eq!(settings.pinned_models[0].provider, "zai"); assert!(settings.move_pinned_model("openrouter", "glm-5.2", -1)); assert_eq!(settings.pinned_models[0].provider, "openrouter"); - assert!(settings.set_pinned_model_label("openrouter", "glm-5.2", Some("fast".to_string()))); let encoded = toml::to_string(&settings).unwrap(); let decoded: Settings = toml::from_str(&encoded).unwrap(); assert_eq!(decoded.pinned_models, settings.pinned_models); diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs index 6c47757e57..df6e70c9e3 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 @@ -864,28 +847,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 +889,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/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 69c411ccea..01b5c126f0 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -285,17 +285,6 @@ mod tests { assert_ne!(key_a, key_b); } - #[test] - fn grouping_key_collapses_shell_flag_variants() { - let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); - let key_b = - build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); - assert_eq!( - key_a, key_b, - "approving a command family must cover later flag variants" - ); - } - #[test] fn grouping_key_grants_mcp_tools_as_reviewed_kinds() { // A session grant for a reviewed plugin MCP tool is the kind diff --git a/crates/tui/src/tools/automation.rs b/crates/tui/src/tools/automation.rs index a242a5ee3b..b1be25dfd1 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(); @@ -685,16 +533,6 @@ 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!({}))); - 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/execution_envelope.rs b/crates/tui/src/tools/execution_envelope.rs index aab8ef0853..700410ee5c 100644 --- a/crates/tui/src/tools/execution_envelope.rs +++ b/crates/tui/src/tools/execution_envelope.rs @@ -96,22 +96,6 @@ impl ExecutionEnvelope { pub(crate) const fn is_unrestricted(self) -> bool { self.write && self.network && self.shell } - - /// Intersect with another envelope. Used wherever a child envelope is - /// derived from a parent one: every field takes the more restrictive side, - /// so a descendant can never widen an ancestor. - /// - /// Exercised by this module's tests today; the grandchild-derivation path - /// that consumes it in production lands with the ratification UI. - #[must_use] - #[allow(dead_code)] - pub(crate) const fn narrow(self, other: Self) -> Self { - Self { - write: self.write && other.write, - network: self.network && other.network, - shell: self.shell && other.shell, - } - } } /// Tool names whose execution is governed by a different, stricter mechanism @@ -786,22 +770,4 @@ mod tests { ) .expect("a write-capable, shell-capable child keeps its gates"); } - - #[test] - fn narrowing_never_widens() { - let parent = ExecutionEnvelope { - write: false, - network: true, - shell: true, - }; - let child = ExecutionEnvelope { - write: true, - network: false, - shell: true, - }; - let narrowed = parent.narrow(child); - assert!(!narrowed.write, "a child cannot regain the parent's writes"); - assert!(!narrowed.network, "a child cannot regain its own denial"); - assert!(narrowed.shell); - } } diff --git a/crates/tui/src/tools/large_output_router.rs b/crates/tui/src/tools/large_output_router.rs index 7e3b45b044..1dff2fd035 100644 --- a/crates/tui/src/tools/large_output_router.rs +++ b/crates/tui/src/tools/large_output_router.rs @@ -283,21 +283,6 @@ impl WorkshopVariables { self.last_tool_result = raw.to_string(); self.last_tool_name = tool_name.to_string(); } - - /// Retrieve and clear the stored raw output (consume semantics so the - /// variable is not accidentally promoted twice). - /// - /// Called by the `promote_to_context` tool (not yet wired in this PR). - #[must_use] - #[allow(dead_code)] // consumed by promote_to_context tool in follow-up - pub fn take_raw(&mut self) -> Option<(String, String)> { - if self.last_tool_result.is_empty() { - return None; - } - let content = std::mem::take(&mut self.last_tool_result); - let name = std::mem::take(&mut self.last_tool_name); - Some((name, content)) - } } // ── Adaptive evidence routing (#4619) ───────────────────────────────────────── @@ -428,11 +413,6 @@ mod tests { ); } - #[test] - fn default_threshold_is_32k_tokens() { - assert_eq!(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, 32_768); - } - #[test] fn synthesise_above_threshold() { let router = LargeOutputRouter::default(); @@ -535,17 +515,14 @@ mod tests { } #[test] - fn workshop_variables_store_and_take() { + fn workshop_variables_store_raw_records_last_output() { let mut vars = WorkshopVariables::default(); - assert!(vars.take_raw().is_none()); + assert!(vars.last_tool_result.is_empty()); + assert!(vars.last_tool_name.is_empty()); vars.store_raw("read_file", "raw content here"); - let taken = vars.take_raw().expect("should have content"); - assert_eq!(taken.0, "read_file"); - assert_eq!(taken.1, "raw content here"); - - // Second take is empty — consume semantics - assert!(vars.take_raw().is_none()); + assert_eq!(vars.last_tool_name, "read_file"); + assert_eq!(vars.last_tool_result, "raw content here"); } #[test] 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..d546e87a90 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); @@ -1334,18 +1316,6 @@ mod tests { ); } - #[test] - fn rlm_eval_schema_names_the_runtime_content_variable() { - let schema = RlmTool::alias("rlm_eval", "eval", None).input_schema(); - let description = schema["properties"]["code"]["description"] - .as_str() - .expect("rlm_eval code description"); - - assert!(description.contains("`content`")); - assert!(description.contains("print(len(content))")); - assert!(!description.contains("SOURCE")); - } - #[tokio::test] async fn rlm_session_open_eval_close_lifecycle() { let ctx = ctx(); 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/shell.rs b/crates/tui/src/tools/shell.rs index 048a5893ce..847d289b7e 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -3417,43 +3417,6 @@ impl ShellManager { owners } - /// Remember a restart-stale job so the UI can show it instead of hiding it. - #[allow(dead_code)] - pub fn remember_stale_job( - &mut self, - id: impl Into, - command: impl Into, - cwd: PathBuf, - linked_task_id: Option, - ) { - let id = id.into(); - self.stale_jobs.insert( - id.clone(), - ShellJobSnapshot { - id: id.clone(), - job_id: id, - command: command.into(), - cwd, - status: ShellStatus::Killed, - exit_code: None, - elapsed_ms: 0, - stdout_tail: String::new(), - stderr_tail: "Process is no longer attached to this TUI session.".to_string(), - stdout_len: 0, - stderr_len: 0, - stdin_available: false, - stale: true, - elapsed_since_output_ms: None, - linked_task_id, - owner_agent_id: None, - owner_agent_name: None, - origin_tool_call_id: None, - origin_turn_id: None, - owner_session_id: String::new(), - }, - ); - } - /// Clean up completed processes older than the given duration, then enforce /// the count and byte ceilings on what is left. /// diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 9f5bb83e56..03b222c84c 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -2146,20 +2146,6 @@ fn test_job_list_poll_cancel_and_stale_snapshot() { let detail = manager.inspect_job(&task_id).expect("inspect"); assert!(detail.stdout.contains("done")); assert_eq!(detail.snapshot.status, ShellStatus::Completed); - - manager.remember_stale_job( - "shell_stale", - "cargo test", - tmp.path().to_path_buf(), - Some("task_old".to_string()), - ); - let stale = manager - .list_jobs() - .into_iter() - .find(|job| job.id == "shell_stale") - .expect("stale job"); - assert!(stale.stale); - assert_eq!(stale.linked_task_id.as_deref(), Some("task_old")); } #[test] diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index a2ecc97172..19ce2933ba 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"], @@ -1543,19 +1386,6 @@ 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!({}))); - 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..2fce318275 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 @@ -452,19 +452,6 @@ fn truncated_preview( /// /// 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 +1189,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 +1205,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 +1219,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 +1530,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 +1566,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/verify.rs b/crates/tui/src/tools/verify.rs index c45fd06f94..ed2e60a06d 100644 --- a/crates/tui/src/tools/verify.rs +++ b/crates/tui/src/tools/verify.rs @@ -291,17 +291,6 @@ impl VerifyTool { critic_effort: ReasoningEffort::Max, } } - - /// Override the critic reasoning tier. Values below `High` are clamped up to - /// `High` — elevated reasoning is the whole point of this tool. This is the - /// seam for a future `[verify] critic_effort` config knob; production - /// registration currently uses the `Max` default from [`Self::new`]. - #[allow(dead_code)] - #[must_use] - pub fn with_critic_effort(mut self, effort: ReasoningEffort) -> Self { - self.critic_effort = clamp_to_elevated(effort); - self - } } #[async_trait] @@ -918,14 +907,6 @@ mod tests { assert!(req_max.tools.is_none()); } - #[test] - fn with_critic_effort_clamps_below_high() { - let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Low); - assert_eq!(tool.critic_effort, ReasoningEffort::High); - let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Max); - assert_eq!(tool.critic_effort, ReasoningEffort::Max); - } - // === Critic-finds-a-planted-bug (mocked model) === #[tokio::test] 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/active_cell.rs b/crates/tui/src/tui/active_cell.rs index ed923f5585..97c1f966ae 100644 --- a/crates/tui/src/tui/active_cell.rs +++ b/crates/tui/src/tui/active_cell.rs @@ -105,15 +105,6 @@ impl ActiveCell { } } - /// Current revision counter. Wraps on overflow which is fine for cache - /// invalidation; the chance of a wrap-around collision is astronomical - /// over a single session and any miss only causes one extra re-render. - #[must_use] - #[allow(dead_code)] // Used by App::bump_active_cell_revision and future cache wiring. - pub fn revision(&self) -> u64 { - self.revision - } - /// Increment the revision counter. Call any time an entry is mutated. pub fn bump_revision(&mut self) { self.revision = self.revision.wrapping_add(1); @@ -358,13 +349,11 @@ mod tests { } #[test] - fn push_tool_records_entry_and_revision_advances() { + fn push_tool_records_entry_at_index() { let mut cell = ActiveCell::new(); - let r0 = cell.revision(); let idx = cell.push_tool("t1", exec_cell("ls")); assert_eq!(idx, 0); assert_eq!(cell.entry_count(), 1); - assert!(cell.revision() != r0); assert_eq!(cell.entry_index_for_tool("t1"), Some(0)); } @@ -417,11 +406,9 @@ mod tests { #[test] fn push_thinking_records_entry_at_tail() { let mut cell = ActiveCell::new(); - let r0 = cell.revision(); let idx = cell.push_thinking(thinking_cell("planning…", true)); assert_eq!(idx, 0); assert_eq!(cell.entry_count(), 1); - assert!(cell.revision() != r0); } #[test] diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9a091acfe2..dedf4321bb 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -854,18 +854,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 { @@ -3203,14 +3191,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 +3675,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 +3892,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 +3967,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 +5397,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/init.rs b/crates/tui/src/tui/app/init.rs index 2d55dd7236..a78769f53d 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -401,7 +401,6 @@ impl App { saved_provider_model, Some(configured_route_base_url.clone()), active_context_window_override, - None, // The launch identity's dialect — `effective_auth_config` // is scoped to the same identity that resolved // `configured_route_base_url` above, which is not always diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 10afc3707e..a7d2af10cc 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] @@ -4319,7 +4303,6 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { is_diff: false, }))); let app_active_before = app.active_cell_revision; - let cell_active_before = app.active_cell.as_ref().expect("active cell").revision(); app.mark_live_motion_updated(); @@ -4329,14 +4312,9 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { assert_eq!(app.history_revisions[3], history_before[3]); assert_eq!(app.history_revisions[4], history_before[4]); assert_ne!(app.active_cell_revision, app_active_before); - assert_ne!( - app.active_cell.as_ref().expect("active cell").revision(), - cell_active_before - ); let history_after_all_live = app.history_revisions.clone(); let app_active_after_all_live = app.active_cell_revision; - let cell_active_after_all_live = app.active_cell.as_ref().expect("active cell").revision(); app.mark_live_history_motion_updated(); assert_eq!(app.history_revisions[0], history_after_all_live[0]); @@ -4345,10 +4323,6 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { assert_eq!(app.history_revisions[3], history_after_all_live[3]); assert_eq!(app.history_revisions[4], history_after_all_live[4]); assert_eq!(app.active_cell_revision, app_active_after_all_live); - assert_eq!( - app.active_cell.as_ref().expect("active cell").revision(), - cell_active_after_all_live - ); } #[test] @@ -4996,7 +4970,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); } @@ -5357,10 +5333,12 @@ fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() { } #[test] -fn push_pending_steer_arms_resend_flag() { +fn pending_steer_seed_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)); + app.pending_steers + .push_back(QueuedMessage::new("steer me".to_string(), None)); + app.submit_pending_steers_after_interrupt = true; assert_eq!(app.pending_steers.len(), 1); assert!(app.submit_pending_steers_after_interrupt); } @@ -5371,9 +5349,13 @@ fn drain_pending_scenario() { // 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); @@ -5394,10 +5376,13 @@ fn drain_pending_scenario() { } #[test] -fn double_push_pending_steer_is_idempotent_on_flag() { +fn double_steer_seed_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)); + app.pending_steers + .push_back(QueuedMessage::new("a".to_string(), None)); + app.pending_steers + .push_back(QueuedMessage::new("b".to_string(), None)); + app.submit_pending_steers_after_interrupt = true; assert!(app.submit_pending_steers_after_interrupt); assert_eq!(app.pending_steers.len(), 2); } @@ -7171,7 +7156,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 +7165,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/goldens/notifications_100x30.txt b/crates/tui/src/tui/goldens/notifications_100x30.txt deleted file mode 100644 index e50b7b7808..0000000000 --- a/crates/tui/src/tui/goldens/notifications_100x30.txt +++ /dev/null @@ -1,30 +0,0 @@ -NOTIFICATIONS · 2 unread - ▸ ◆ approval rm -rf target/ in worktree — 14:41 - whale-2 wants to clean the build directory - ◆ turn done turn surfaced ✓ — 14:38 - ○ whale done whale-3 done — 14:20 - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/tui/src/tui/goldens/notifications_120x32.txt b/crates/tui/src/tui/goldens/notifications_120x32.txt deleted file mode 100644 index 756834b5ba..0000000000 --- a/crates/tui/src/tui/goldens/notifications_120x32.txt +++ /dev/null @@ -1,32 +0,0 @@ -NOTIFICATIONS · 2 unread - ▸ ◆ approval rm -rf target/ in worktree — 14:41 - whale-2 wants to clean the build directory - ◆ turn done turn surfaced ✓ — 14:38 - ○ whale done whale-3 done — 14:20 - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/tui/src/tui/goldens/notifications_160x40.txt b/crates/tui/src/tui/goldens/notifications_160x40.txt deleted file mode 100644 index ba46279c2d..0000000000 --- a/crates/tui/src/tui/goldens/notifications_160x40.txt +++ /dev/null @@ -1,40 +0,0 @@ -NOTIFICATIONS · 2 unread - ▸ ◆ approval rm -rf target/ in worktree — 14:41 - whale-2 wants to clean the build directory - ◆ turn done turn surfaced ✓ — 14:38 - ○ whale done whale-3 done — 14:20 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/crates/tui/src/tui/goldens/notifications_80x24.txt b/crates/tui/src/tui/goldens/notifications_80x24.txt deleted file mode 100644 index 2b8ff0bdff..0000000000 --- a/crates/tui/src/tui/goldens/notifications_80x24.txt +++ /dev/null @@ -1,24 +0,0 @@ -NOTIFICATIONS · 2 unread - ▸ ◆ approval rm -rf target/ in worktree — 14:41 - whale-2 wants to clean the build directory - ◆ turn done turn surfaced ✓ — 14:38 - ○ whale done whale-3 done — 14:20 - - - - - - - - - - - - - - - - - - - diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 3006cc2c7f..fa41fb1e9e 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -3200,7 +3200,6 @@ pub(crate) mod tideline_exports { pub use super::tideline_stream::{ TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream, - tideline_stream_hitboxes, }; } diff --git a/crates/tui/src/tui/history/tideline_stream.rs b/crates/tui/src/tui/history/tideline_stream.rs index 39738d028b..c1abb363f4 100644 --- a/crates/tui/src/tui/history/tideline_stream.rs +++ b/crates/tui/src/tui/history/tideline_stream.rs @@ -289,37 +289,3 @@ pub fn render_tideline_stream(area: Rect, buf: &mut Buffer, stream: &TidelineStr ); } } - -/// Row hitboxes for the stream (transcript click path, spec §6): one rect -/// per event, fleet trees spanning their edges. -#[must_use] -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub fn tideline_stream_hitboxes(area: Rect, stream: &TidelineStream<'_>) -> Vec { - let mut out = Vec::new(); - if area.width < 10 || area.height < 1 { - return out; - } - let legend_reserve = if area.height > 2 { 1 } else { 0 }; - let body_height = area.height - legend_reserve; - let mut y = area.y; - let bottom = area.y + body_height; - for event in stream.events { - if y >= bottom { - break; - } - let height = match event { - TidelineStreamEvent::PodFormation { edges } => edges.len().max(1) as u16, - _ => 1, - }; - if y + height <= bottom { - out.push(Rect { - x: area.x, - y, - width: area.width, - height, - }); - } - y += height; - } - out -} diff --git a/crates/tui/src/tui/history/tideline_stream_tests.rs b/crates/tui/src/tui/history/tideline_stream_tests.rs index 73eec1f021..edcdf2ebff 100644 --- a/crates/tui/src/tui/history/tideline_stream_tests.rs +++ b/crates/tui/src/tui/history/tideline_stream_tests.rs @@ -2,7 +2,7 @@ //! stage suite lives in `work_surface::tideline::tests` — both components //! share the reserved `work_*` golden names). -use super::tideline_stream::{TidelineReceiptState, TidelineStreamEvent, tideline_stream_hitboxes}; +use super::tideline_stream::TidelineReceiptState; use crate::palette::ChromeInk; #[test] @@ -30,13 +30,3 @@ fn receipt_states_pair_marks_with_words_and_ink_families() { } } } - -#[test] -fn stream_hitboxes_empty_on_degenerate_area() { - let events = vec![TidelineStreamEvent::UserTurn { - text: "x".to_string(), - }]; - let theme = crate::palette::UI_THEME; - let stream = super::tideline_stream::TidelineStream::new(&theme, &events); - assert!(tideline_stream_hitboxes(ratatui::layout::Rect::new(0, 0, 2, 1), &stream).is_empty()); -} 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/live_transcript.rs b/crates/tui/src/tui/live_transcript.rs index cb01327238..44fe56c39f 100644 --- a/crates/tui/src/tui/live_transcript.rs +++ b/crates/tui/src/tui/live_transcript.rs @@ -161,22 +161,6 @@ impl LiveTranscriptOverlay { self.preview_pin_pending.set(true); } - /// Return the overlay to live-tail mode (used when backtrack is - /// confirmed or canceled). Re-arms sticky-tail so streaming resumes. - #[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view. - pub fn set_tail_mode(&mut self) { - self.mode = Mode::Tail; - self.sticky_to_bottom.set(true); - self.preview_pin_pending.set(false); - } - - /// For tests + UI: current mode. - #[allow(dead_code)] // currently consumed only by tests; kept public for symmetry with `set_*` setters. - #[must_use] - pub fn mode(&self) -> Mode { - self.mode - } - /// Pull the latest cells + revisions from `App` so the next `render` shows /// streaming mutations. Must be called before `view_stack.render` while /// this overlay is on top; otherwise the cells stay frozen at whatever @@ -1019,19 +1003,6 @@ mod tests { assert!(v.is_sticky()); v.set_backtrack_preview(0); assert!(!v.is_sticky()); - assert!(matches!( - v.mode(), - Mode::BacktrackPreview { selected_idx: 0 } - )); - } - - #[test] - fn set_tail_mode_re_arms_sticky() { - let mut v = LiveTranscriptOverlay::new(); - v.set_backtrack_preview(2); - v.set_tail_mode(); - assert!(v.is_sticky()); - assert!(matches!(v.mode(), Mode::Tail)); } #[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/menu_style.rs b/crates/tui/src/tui/menu_style.rs index dd193afae4..53381ac5f8 100644 --- a/crates/tui/src/tui/menu_style.rs +++ b/crates/tui/src/tui/menu_style.rs @@ -163,43 +163,6 @@ pub const fn status_mark(kind: StatusKind) -> StatusMark { mod tests { use super::*; - #[test] - fn selected_row_style_is_the_canonical_trio() { - assert_eq!( - selected_row_style(), - Style::default() - .fg(palette::SELECTION_TEXT) - .bg(palette::SELECTION_BG) - .add_modifier(Modifier::BOLD) - ); - } - - #[test] - fn selected_row_variants_keep_the_shared_background() { - assert_eq!( - selected_row_bg_style(), - Style::default().bg(palette::SELECTION_BG) - ); - assert_eq!( - selected_row_style_with_fg(palette::WHALE_ACTION), - Style::default() - .fg(palette::WHALE_ACTION) - .bg(palette::SELECTION_BG) - .add_modifier(Modifier::BOLD) - ); - } - - #[test] - fn disabled_selected_row_is_muted_ink_on_elevated_surface() { - assert_eq!( - disabled_selected_row_style(), - Style::default() - .fg(palette::TEXT_MUTED) - .bg(palette::SURFACE_ELEVATED) - .add_modifier(Modifier::DIM) - ); - } - #[test] fn hovered_row_is_elevated_band_without_selection_ink() { let hovered = hovered_row_style(); @@ -208,18 +171,6 @@ mod tests { assert_ne!(hovered, selected_row_bg_style()); } - #[test] - fn theme_variant_uses_the_previewed_themes_own_tokens() { - let theme = palette::UI_THEME; - assert_eq!( - theme_selected_row_style(&theme), - Style::default() - .fg(theme.text_body) - .bg(theme.selection_bg) - .add_modifier(Modifier::BOLD) - ); - } - #[test] fn every_status_kind_pairs_a_glyph_with_a_word() { // Non-color redundancy: neither cue may be empty, so a status never diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 2f8bcbd4ee..5613e5058b 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -2431,265 +2431,3 @@ mod tests { COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst); } } - -// --------------------------------------------------------------------------- -// Tideline notifications inbox (spec §5a "Notifications inbox"): the -// attention surface that replaces the toast soup. Records are typed — the -// same `NotificationKind` disclosure policy the desktop payloads use — and -// the unread mark is the sanctioned gold ◆. Translation scaffolding in the -// topbar mold: a pure deterministic widget over injected records (`App` -// projects `status_toasts`/`sticky_status` into it at the landing slice); -// not wired into `ui/frame.rs` (#5698 gate). - -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::{Modifier, Style}, -}; -use unicode_width::UnicodeWidthStr; - -use crate::palette::{ChromeInk, UiTheme, chrome_style}; - -/// One attention record: a typed projection of a status toast / sticky -/// status / desktop payload. `at` is an injected clock string so renders -/// stay deterministic (spec §5a: caller owns the wall clock). -#[derive(Debug, Clone)] -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub struct TidelineInboxRecord { - pub kind: NotificationKind, - pub title: String, - /// One-line body, already disclosure-approved per kind. - pub body: Option, - /// Wall-clock label, e.g. `14:42`. - pub at: String, - pub read: bool, -} - -impl TidelineInboxRecord { - /// Kind word — a noun, never "Error" (spec §7 failure microcopy rule). - #[must_use] - pub fn kind_word(&self) -> &'static str { - match self.kind { - NotificationKind::TurnComplete => "turn done", - NotificationKind::SubagentTerminal => "whale done", - NotificationKind::ApprovalNeeded => "approval", - NotificationKind::InputNeeded => "question", - NotificationKind::ElevationNeeded => "sandbox", - NotificationKind::ModelNotify => "notify", - } - } - - /// Per-kind ink per the §5d table: interactive asks read as cognition - /// (permission family), completions as outcome, terminal whales as info. - #[must_use] - pub fn kind_ink(&self) -> ChromeInk { - match self.kind { - NotificationKind::TurnComplete => ChromeInk::Outcome, - NotificationKind::SubagentTerminal => ChromeInk::Info, - NotificationKind::ApprovalNeeded | NotificationKind::InputNeeded => { - ChromeInk::PermissionAsk - } - NotificationKind::ElevationNeeded => ChromeInk::PermissionFullAccess, - NotificationKind::ModelNotify => ChromeInk::MetadataValue, - } - } -} - -/// What the caller owes the inbox render. -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub struct TidelineInbox<'a> { - pub theme: &'a UiTheme, - pub records: &'a [TidelineInboxRecord], - /// Selected row (Enter inspects, `r` marks read, Esc backs out). - pub selected: usize, - pub ascii_safe: bool, -} - -#[allow(dead_code)] // translation scaffolding: builder methods feed tests + the landing slice -impl<'a> TidelineInbox<'a> { - #[allow(dead_code)] // translation scaffolding: wired by the landing slice - #[must_use] - pub fn new(theme: &'a UiTheme, records: &'a [TidelineInboxRecord]) -> Self { - Self { - theme, - records, - selected: 0, - ascii_safe: false, - } - } - - #[must_use] - pub fn selected(mut self, selected: usize) -> Self { - self.selected = selected; - self - } - - #[must_use] - pub fn ascii_safe(mut self, ascii_safe: bool) -> Self { - self.ascii_safe = ascii_safe; - self - } - - fn sym(&self, glyph: &str) -> String { - if !self.ascii_safe { - return glyph.to_string(); - } - if let Some(fb) = crate::tui::glyphs::ascii_fallback(glyph) { - return fb.to_string(); - } - glyph - .chars() - .map(|c| { - crate::tui::glyphs::ascii_fallback(&c.to_string()) - .map(str::to_string) - .unwrap_or_else(|| c.to_string()) - }) - .collect() - } -} - -fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style { - chrome_style(theme, ink) -} - -fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) { - buf.set_stringn(x, y, text, text.width(), style); -} - -/// Paint the notifications inbox: header row (count of unread), then one -/// row per record — unread gold ◆, read hollow ○, selected `▸`, kind word, -/// title, injected time. Truncates, never wraps. -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub fn render_tideline_inbox(area: Rect, buf: &mut Buffer, inbox: &TidelineInbox<'_>) { - if area.width < 8 || area.height < 2 { - return; - } - let theme = inbox.theme; - let unread = inbox.records.iter().filter(|record| !record.read).count(); - let header = if unread == 0 { - "NOTIFICATIONS".to_string() - } else { - format!("NOTIFICATIONS · {unread} unread") - }; - put( - buf, - area.x, - area.y, - &header, - chrome(theme, ChromeInk::Metadata).add_modifier(Modifier::BOLD), - ); - - if inbox.records.is_empty() { - put( - buf, - area.x, - area.y + 1, - "quiet water — nothing needs you", - chrome(theme, ChromeInk::MetadataHint), - ); - return; - } - - let width = area.width as usize; - let mut y = area.y + 1; - for (index, record) in inbox.records.iter().enumerate() { - if y >= area.y + area.height { - break; - } - let selected = inbox.selected == index; - let marker = if selected { "▸ " } else { " " }; - let mark = if record.read { "○" } else { "◆" }; - let mark_ink = if record.read { - ChromeInk::MetadataDim - } else { - ChromeInk::Attention - }; - let row = format!("{} {} — {}", record.kind_word(), record.title, record.at); - let row = truncate_to_width_owned(&inbox.sym(&row), width.saturating_sub(6)); - put( - buf, - area.x + 2, - y, - &inbox.sym(marker), - chrome(theme, ChromeInk::Identity), - ); - put( - buf, - area.x + 4, - y, - &inbox.sym(mark), - chrome(theme, mark_ink), - ); - let mut style = chrome(theme, record.kind_ink()); - if record.read { - style = chrome(theme, ChromeInk::MetadataDim); - } - if selected { - style = style.add_modifier(Modifier::BOLD); - } - put(buf, area.x + 6, y, &row, style); - // The selected record's approved body earns its own indented row — - // the inspect affordance; other bodies stay collapsed. - if selected - && let Some(body) = record.body.as_deref() - && y + 1 < area.y + area.height - { - put( - buf, - area.x + 8, - y + 1, - &truncate_to_width_owned(&inbox.sym(body), width.saturating_sub(10)), - chrome(theme, ChromeInk::MetadataHint), - ); - y += 1; - } - y += 1; - } -} - -fn truncate_to_width_owned(text: &str, width: usize) -> String { - let mut out = String::new(); - let mut used = 0; - for ch in text.chars() { - let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); - if used + w > width { - break; - } - out.push(ch); - used += w; - } - out -} - -/// Row hitboxes for one render (spec §6): one rect per record, matching the -/// painted rows exactly — the selected record's body row belongs to its -/// rect. Must be called with the same inputs as [`render_tideline_inbox`]. -#[must_use] -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub fn tideline_inbox_hitboxes(area: Rect, inbox: &TidelineInbox<'_>) -> Vec { - let mut out = Vec::new(); - if area.width < 8 || area.height < 2 { - return out; - } - let mut y = area.y + 1; - for (index, record) in inbox.records.iter().enumerate() { - let mut height = 1; - if inbox.selected == index && record.body.is_some() { - height = 2; - } - if y + height > area.y + area.height { - break; - } - out.push(Rect { - x: area.x + 2, - y, - width: area.width.saturating_sub(2), - height, - }); - y += height; - } - out -} - -#[cfg(test)] -mod tideline_tests; diff --git a/crates/tui/src/tui/notifications/tideline_tests.rs b/crates/tui/src/tui/notifications/tideline_tests.rs deleted file mode 100644 index bc25199c67..0000000000 --- a/crates/tui/src/tui/notifications/tideline_tests.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! Golden-buffer contract for the Tideline notifications inbox (spec §5a/ -//! §5c). Goldens: `notifications_{w}x{h}` at the four blocker sizes. -//! Re-bless with `CODEWHALE_BLESS_GOLDENS=1`. - -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use unicode_width::UnicodeWidthChar; - -use super::{ - NotificationKind, TidelineInbox, TidelineInboxRecord, render_tideline_inbox, - tideline_inbox_hitboxes, -}; -use crate::palette::UI_THEME; -use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; - -fn record(kind: NotificationKind, title: &str, at: &str, read: bool) -> TidelineInboxRecord { - TidelineInboxRecord { - kind, - title: title.to_string(), - body: None, - at: at.to_string(), - read, - } -} - -/// The approved inbox fixture: one interactive ask, one completion, one read -/// terminal whale, second row selected (so its body row shows). -fn records() -> Vec { - vec![ - TidelineInboxRecord { - kind: NotificationKind::ApprovalNeeded, - title: "rm -rf target/ in worktree".to_string(), - body: Some("whale-2 wants to clean the build directory".to_string()), - at: "14:41".to_string(), - read: false, - }, - record( - NotificationKind::TurnComplete, - "turn surfaced ✓", - "14:38", - false, - ), - record( - NotificationKind::SubagentTerminal, - "whale-3 done", - "14:20", - true, - ), - ] -} - -fn draw(width: u16, height: u16, inbox: &TidelineInbox<'_>) -> String { - render_golden_text(width, height, |buf| { - render_tideline_inbox(Rect::new(0, 0, width, height), buf, inbox); - }) -} - -#[test] -fn notifications_matches_goldens_at_blocker_sizes() { - for (w, h) in BLOCKER_SIZES { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); - assert_matches_golden(&format!("notifications_{w}x{h}"), &draw(w, h, &inbox)); - } -} - -#[test] -fn notifications_header_counts_unread() { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs); - let text = draw(80, 24, &inbox); - assert!(text.contains("NOTIFICATIONS · 2 unread"), "{text}"); - let all_read: Vec = recs - .iter() - .map(|r| TidelineInboxRecord { - read: true, - ..r.clone() - }) - .collect(); - let text = draw(80, 24, &TidelineInbox::new(&UI_THEME, &all_read)); - let header = text.lines().next().unwrap_or_default().trim(); - assert_eq!(header, "NOTIFICATIONS", "read inbox drops the count"); -} - -#[test] -fn notifications_unread_rows_carry_the_gold_mark() { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs); - let text = draw(80, 24, &inbox); - assert!( - text.contains("◆ approval"), - "unread ask is gold-marked: {text}" - ); - assert!(text.contains("○ whale done"), "read row is hollow: {text}"); -} - -#[test] -fn notifications_selected_body_replaces_not_doubles() { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); - let text = draw(80, 24, &inbox); - assert!( - text.contains("whale-2 wants to clean"), - "selected body row: {text}" - ); - // Row 2 (the completion) must still be present exactly once. - assert_eq!(text.matches("turn surfaced").count(), 1, "{text}"); -} - -#[test] -fn notifications_empty_state_is_quiet_not_blank() { - let inbox = TidelineInbox::new(&UI_THEME, &[]); - let text = draw(80, 24, &inbox); - assert!(text.contains("quiet water"), "{text}"); -} - -#[test] -fn notifications_ascii_safe_projects_marks() { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs).ascii_safe(true); - let text = draw(80, 24, &inbox); - assert!(text.contains("* approval"), "gold ◆ projects to *: {text}"); - assert!( - text.contains(". whale done"), - "read ○ projects to .: {text}" - ); - for ch in text.chars() { - if ch != '\n' { - assert_eq!(ch.width(), Some(1), "ascii-safe single-width: {ch:?}"); - } - } -} - -#[test] -fn notifications_hitboxes_match_painted_rows() { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); - let (w, h) = (100, 30); - let area = Rect::new(0, 0, w, h); - let mut buf = Buffer::empty(area); - render_tideline_inbox(area, &mut buf, &inbox); - let hitboxes = tideline_inbox_hitboxes(area, &inbox); - assert_eq!(hitboxes.len(), 3, "one rect per record"); - // First rect covers the selected record's body row. - assert_eq!(hitboxes[0].height, 2); - // No overlaps, all inside, all cover painted text. - for pair in hitboxes.windows(2) { - assert!(pair[0].y + pair[0].height <= pair[1].y, "no overlap"); - } - for rect in &hitboxes { - let cells: String = (rect.x..rect.x + rect.width) - .map(|x| buf[(x, rect.y)].symbol().to_string()) - .collect(); - assert!(!cells.trim().is_empty(), "rect {rect:?} covers empty cells"); - } -} - -#[test] -fn notifications_kind_words_never_say_error() { - for kind in [ - NotificationKind::TurnComplete, - NotificationKind::SubagentTerminal, - NotificationKind::ApprovalNeeded, - NotificationKind::InputNeeded, - NotificationKind::ElevationNeeded, - NotificationKind::ModelNotify, - ] { - let rec = record(kind, "t", "00:00", false); - assert_ne!(rec.kind_word(), "error"); - assert_ne!(rec.kind_word(), "Error"); - } -} - -#[test] -fn notifications_degenerate_sizes_do_not_panic() { - for (w, h) in [(0u16, 0), (4, 1), (8, 2), (200, 50)] { - let recs = records(); - let inbox = TidelineInbox::new(&UI_THEME, &recs); - let _ = draw(w, h, &inbox); - } -} diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 95690b0954..3a3e88d3b7 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -1117,18 +1117,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/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index c5f5c5cf55..9df56467b0 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -696,7 +696,6 @@ impl ProviderDashboardRow { .then(|| configured_base_url.clone()) .flatten(), config.context_window_for_provider_config(provider), - None, // The dialect of the same table that supplied the base URL above // (this row's scoped config), so the row's supported-protocol // display matches what a turn on this row would bind. 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/transcript_cache.rs b/crates/tui/src/tui/transcript_cache.rs index bb9fca8fec..9ed5852c35 100644 --- a/crates/tui/src/tui/transcript_cache.rs +++ b/crates/tui/src/tui/transcript_cache.rs @@ -121,14 +121,6 @@ impl TranscriptCache { self.insertion_order.push_back(key); } - /// Drop every cached entry. Used when the underlying transcript shape - /// changes drastically (e.g. session reset). - #[allow(dead_code)] // Reserved for /clear and session-reset call sites. - pub fn clear(&mut self) { - self.entries.clear(); - self.insertion_order.clear(); - } - #[cfg(test)] pub fn len(&self) -> usize { self.entries.len() @@ -222,13 +214,4 @@ mod tests { assert!(cache.get(CellId::History(2), 80, 1).is_some()); assert_eq!(cache.len(), 2); } - - #[test] - fn clear_drops_everything() { - let mut cache = TranscriptCache::new(); - cache.insert(CellId::History(0), 80, 1, vec![line("v1")]); - cache.clear(); - assert!(cache.get(CellId::History(0), 80, 1).is_none()); - assert_eq!(cache.len(), 0); - } } diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 4a4ec1c022..c7e53653b1 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -809,7 +809,6 @@ pub(crate) async fn apply_model_picker_choice( saved_provider_model, Some(config.deepseek_base_url()), config.context_window_for_provider_config(app.api_provider), - None, // The ambient table's dialect — the same table `deepseek_base_url` // above resolves — so this receipt cannot disagree with the turn. (app.api_provider == ApiProvider::Custom) diff --git a/crates/tui/src/tui/ui/session_state.rs b/crates/tui/src/tui/ui/session_state.rs index 7c2ad636e5..232daa6aff 100644 --- a/crates/tui/src/tui/ui/session_state.rs +++ b/crates/tui/src/tui/ui/session_state.rs @@ -947,7 +947,6 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) { saved_provider_model, Some(config.deepseek_base_url()), context_override, - None, // The ambient table's dialect — the same table `deepseek_base_url` // above resolves — so this receipt cannot disagree with the turn. (app.api_provider == ApiProvider::Custom) 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..88e2591940 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}, 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, }; @@ -614,12 +610,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 +918,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 +942,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 +1391,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 +1407,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 +1414,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 +1425,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 +1439,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 +1452,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/whales.rs b/crates/tui/src/tui/whales.rs index b8b8a90e69..c7c5688f2e 100644 --- a/crates/tui/src/tui/whales.rs +++ b/crates/tui/src/tui/whales.rs @@ -16,8 +16,8 @@ //! roles map onto the six species; roles without a species (`worker`, //! `general`, `custom`, unknown) render the plain Codewhale whale. //! - **State is evidence, never decoration.** [`WhaleState`] is derived only -//! from real runtime facts ([`WhaleState::for_subagent`], -//! [`WhaleState::for_shell_phase`]). *Working* is asserted only for a child +//! from real runtime facts ([`WhaleState::for_subagent`]). *Working* is +//! asserted only for a child //! or turn that is actually running. //! - **Every state pairs a glyph cue with a word** ([`WhaleState::word`]), so //! state never depends on color alone (same rule as `menu_style`). @@ -38,7 +38,6 @@ use crate::palette::{self, UiTheme}; use crate::tools::subagent::{AgentWorkerStatus, FleetRole, SubAgentResult, SubAgentStatus}; use crate::tui::glyphs; use crate::tui::motion::mode::MotionMode; -use crate::tui::underwater::ShellPhase; /// Cells occupied by a badge (species mark + body). pub const BADGE_WIDTH: usize = 2; @@ -196,21 +195,6 @@ impl WhaleState { Self::Offline, ]; - /// CWC state priority; higher wins when several facts apply. Public - /// contract for surfaces that fold several children into one whale. - #[cfg(test)] - #[must_use] - pub const fn priority(self) -> u8 { - match self { - Self::Waiting => 60, - Self::Blocked => 50, - Self::Working => 40, - Self::Thinking => 30, - Self::Offline => 20, - Self::Resting => 10, - } - } - /// Localized state word — always rendered next to the glyph cue. #[must_use] pub fn word(self, locale: Locale) -> Cow<'static, str> { @@ -264,20 +248,6 @@ impl WhaleState { SubAgentStatus::Cancelled => Self::Offline, } } - - /// State from the operator session phase. Public contract for the shell - /// header / Fleet setup role pane (no consumer in this lane yet). - #[allow(dead_code)] - #[must_use] - pub const fn for_shell_phase(phase: ShellPhase) -> Self { - match phase { - ShellPhase::Idle | ShellPhase::Done => Self::Resting, - ShellPhase::Typing => Self::Thinking, - ShellPhase::Working | ShellPhase::Verifying => Self::Working, - ShellPhase::Waiting | ShellPhase::Approval => Self::Waiting, - ShellPhase::Failed => Self::Blocked, - } - } } /// Resolved inks for one theme. Accents are contrast-enforced against the @@ -516,14 +486,6 @@ mod tests { ); } - #[test] - fn state_priority_orders_attention_before_work() { - assert!( - WhaleState::Waiting.priority() > WhaleState::Working.priority() - && WhaleState::Working.priority() > WhaleState::Resting.priority() - ); - } - #[test] fn badge_glyphs_have_ascii_fallbacks_and_stay_distinct() { for species in WhaleSpecies::ALL { @@ -629,48 +591,6 @@ mod tests { question: "which branch?".into(), }); assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); - // Priorities match CWC. - assert!(WhaleState::Waiting.priority() > WhaleState::Blocked.priority()); - assert!(WhaleState::Blocked.priority() > WhaleState::Working.priority()); - assert!(WhaleState::Working.priority() > WhaleState::Thinking.priority()); - assert!(WhaleState::Thinking.priority() > WhaleState::Offline.priority()); - assert!(WhaleState::Offline.priority() > WhaleState::Resting.priority()); - } - - #[test] - fn shell_phase_maps_without_inventing_work() { - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Idle), - WhaleState::Resting - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Done), - WhaleState::Resting - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Typing), - WhaleState::Thinking - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Working), - WhaleState::Working - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Verifying), - WhaleState::Working - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Waiting), - WhaleState::Waiting - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Approval), - WhaleState::Waiting - ); - assert_eq!( - WhaleState::for_shell_phase(ShellPhase::Failed), - WhaleState::Blocked - ); } #[test] diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 3a6f57c97a..5c4028bc41 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -139,39 +139,6 @@ impl DelegateCard { self.todo.as_ref().and_then(card_todo_projection) } - /// Project this direct sub-agent card onto the shared workflow history - /// renderer (#4122) so collapsed/expanded concepts stay aligned. - #[must_use] - #[allow(dead_code)] // public #4122 convergence API; covered by unit tests - pub fn as_workflow_history_panel( - &self, - started_at_ms: u64, - completed_at_ms: Option, - ) -> crate::tui::widgets::workflow_panel::WorkflowPanel { - use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle}; - let lifecycle = match self.status { - AgentLifecycle::Pending => WorkflowPanelLifecycle::Pending, - AgentLifecycle::Running => WorkflowPanelLifecycle::Running, - AgentLifecycle::Completed => WorkflowPanelLifecycle::Succeeded, - AgentLifecycle::Failed => WorkflowPanelLifecycle::Failed, - AgentLifecycle::Cancelled => WorkflowPanelLifecycle::Cancelled, - AgentLifecycle::Interrupted => WorkflowPanelLifecycle::Failed, - }; - WorkflowPanel::from_direct_subagent( - self.agent_id.clone(), - public_role_label(&self.agent_type), - lifecycle, - started_at_ms, - completed_at_ms, - self.summary.clone(), - if matches!(self.status, AgentLifecycle::Failed) { - self.summary.clone() - } else { - None - }, - ) - } - pub fn push_action(&mut self, action: impl Into) { self.actions.push(action.into()); if self.actions.len() > DELEGATE_MAX_ACTIONS { @@ -347,20 +314,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 +815,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 +834,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 +877,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 +900,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 +930,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 +970,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 +1017,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 +1306,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( @@ -1350,35 +1321,4 @@ mod tests { assert!(!rendered.contains("worker one work"), "{rendered}"); assert!(!rendered.contains("To-do"), "{rendered}"); } - - #[test] - fn direct_subagent_projects_onto_shared_workflow_history_card() { - use crate::tui::widgets::workflow_panel::WorkflowHistoryExtras; - - let mut card = DelegateCard::new("agent_xyz", "explore"); - card.status = AgentLifecycle::Completed; - card.summary = Some("mapped 4 call sites".to_string()); - let panel = card.as_workflow_history_panel(1_000, Some(5_000)); - let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default()); - let joined = render_to_strings(&compact).join("\n"); - assert!( - joined.contains("success") || joined.contains("explore"), - "shared compact lifecycle: {joined}" - ); - assert!( - joined.contains("1 child") || joined.contains("children"), - "shared child count: {joined}" - ); - let expanded = panel.render_history_card( - 100, - true, - &WorkflowHistoryExtras { - result_summary: Some("mapped 4 call sites".to_string()), - ..WorkflowHistoryExtras::default() - }, - ); - let joined = render_to_strings(&expanded).join("\n"); - assert!(joined.contains("result:"), "{joined}"); - assert!(joined.contains("mapped 4 call sites"), "{joined}"); - } } 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..5ad7899071 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(); @@ -8860,60 +8828,6 @@ diff --git a/src/b.rs b/src/b.rs\n\ ); } - #[test] - fn ghost_text_hidden_when_input_not_empty() { - let mut app = create_test_app(); - app.prompt_suggestion = Some("A suggestion".to_string()); - app.input = "hello".to_string(); - app.cursor_position = 5; - 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); - - let has_suggestion = buf - .content - .iter() - .any(|c| c.symbol().contains("A suggestion")); - assert!( - !has_suggestion, - "suggestion should not render when input is non-empty" - ); - } - - #[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..4faab1adf5 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 { @@ -1234,88 +1216,6 @@ impl WorkflowPanel { lines } - /// Full history-card lines including a simple self-contained header so - /// unit tests (and direct sub-agent cards) can render without history.rs. - /// - /// Public convergence API for #4122 — also exercised by unit tests and - /// `DelegateCard::as_workflow_history_panel`. - #[must_use] - #[allow(dead_code)] // public API used by direct sub-agent projection + tests - pub fn render_history_card( - &self, - width: u16, - expanded: bool, - extras: &WorkflowHistoryExtras, - ) -> Vec> { - let content_width = usize::from(width).max(1); - let mut lines = Vec::new(); - let glyph = if expanded { '▼' } else { '▶' }; - let summary = self.compact_summary_text(content_width.saturating_sub(2)); - lines.push(Line::from(Span::styled( - truncate_line_to_width(&format!("{glyph} {summary}"), content_width), - Style::default() - .fg(self.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))); - if expanded { - lines.extend(self.history_expanded_lines(width, extras)); - } - lines - } - - /// Single-agent "mini workflow" view for direct sub-agent cards so they - /// share the same lifecycle/elapsed/result concepts as workflow runs. - #[must_use] - #[allow(dead_code)] // public API used by DelegateCard + tests - pub fn from_direct_subagent( - agent_id: impl Into, - role: impl Into, - lifecycle: WorkflowPanelLifecycle, - started_at_ms: u64, - completed_at_ms: Option, - summary: Option, - error: Option, - ) -> Self { - let agent_id = agent_id.into(); - let role = role.into(); - let mut panel = Self::new(agent_id.clone(), role.clone(), started_at_ms); - panel.lifecycle = lifecycle; - panel.completed_at_ms = completed_at_ms; - panel.expanded = false; - panel.show_workflow_receipts = false; - panel.result_summary = summary.clone(); - panel.error = error.clone(); - let status = match lifecycle { - WorkflowPanelLifecycle::Pending => WorkflowRowStatus::Pending, - WorkflowPanelLifecycle::Running => WorkflowRowStatus::Running, - WorkflowPanelLifecycle::Succeeded => WorkflowRowStatus::Succeeded, - WorkflowPanelLifecycle::Degraded => WorkflowRowStatus::Failed, - WorkflowPanelLifecycle::Failed => WorkflowRowStatus::Failed, - WorkflowPanelLifecycle::Cancelled => WorkflowRowStatus::Cancelled, - }; - let mut phase = WorkflowPanelPhase::new("Agent"); - phase.rows.push(WorkflowPanelRow { - task_id: agent_id, - label: role, - profile: None, - model: None, - strength: None, - worktree: false, - workspace: None, - status, - started_at_ms, - completed_at_ms, - error, - schema_error: None, - // A direct sub-agent card projects a single agent, not a Workflow - // task, so it carries no Workflow launch/usage receipt (#4039). - route: WorkflowRowRoute::default(), - usage: completed_at_ms.map(|_| WorkflowRowUsage::default()), - }); - panel.phases.push(phase); - panel - } - /// Apply a stream of events. `RunStarted` replaces any prior completed run. pub fn apply_event(&mut self, event: WorkflowPanelEvent) { match event { @@ -3295,8 +3195,19 @@ mod tests { let restored = WorkflowPanel::from_run_json(&snapshot).expect("panel rehydrates"); assert_eq!(restored.done_total(), (0, 1)); assert_eq!(restored.failure_cancel_counts(), (1, 0)); - let history = restored - .render_history_card(120, true, &WorkflowHistoryExtras::default()) + let history_lines = { + let content_width = usize::from(120u16).max(1); + let summary = restored.compact_summary_text(content_width.saturating_sub(2)); + let mut lines = vec![Line::from(Span::styled( + truncate_line_to_width(&format!("▼ {summary}"), content_width), + Style::default() + .fg(restored.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))]; + lines.extend(restored.history_expanded_lines(120, &WorkflowHistoryExtras::default())); + lines + }; + let history = history_lines .iter() .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect::(); @@ -3487,7 +3398,16 @@ mod tests { error: Some("scout failed".to_string()), at_ms: 2_100, }); - let lines = panel.render_history_card(120, false, &WorkflowHistoryExtras::default()); + let lines = { + let content_width = usize::from(120u16).max(1); + let summary = panel.compact_summary_text(content_width.saturating_sub(2)); + vec![Line::from(Span::styled( + truncate_line_to_width(&format!("▶ {summary}"), content_width), + Style::default() + .fg(panel.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))] + }; assert_eq!(lines.len(), 1, "compact is a single summary line"); let joined: String = lines .iter() @@ -3538,7 +3458,18 @@ mod tests { spillover_path: Some(PathBuf::from("/tmp/workflow-out.json")), verification_summary: None, }; - let lines = panel.render_history_card(120, true, &extras); + let lines = { + let content_width = usize::from(120u16).max(1); + let summary = panel.compact_summary_text(content_width.saturating_sub(2)); + let mut lines = vec![Line::from(Span::styled( + truncate_line_to_width(&format!("▼ {summary}"), content_width), + Style::default() + .fg(panel.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))]; + lines.extend(panel.history_expanded_lines(120, &extras)); + lines + }; let joined: String = lines .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3566,111 +3497,6 @@ mod tests { ); } - #[test] - fn direct_subagent_card_reuses_history_renderer() { - let panel = WorkflowPanel::from_direct_subagent( - "agent_abc", - "explore", - WorkflowPanelLifecycle::Succeeded, - 1_000, - Some(4_500), - Some("found 3 call sites".to_string()), - None, - ); - let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default()); - let joined: String = compact - .iter() - .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) - .collect(); - assert!( - joined.contains("success") || joined.contains("explore"), - "{joined}" - ); - assert!( - joined.contains("1 child") || joined.contains("1 children"), - "{joined}" - ); - assert!(joined.contains("3s") || joined.contains("s"), "{joined}"); - - let expanded = panel.render_history_card( - 100, - true, - &WorkflowHistoryExtras { - result_summary: Some("found 3 call sites".to_string()), - ..WorkflowHistoryExtras::default() - }, - ); - let joined: String = expanded - .iter() - .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) - .collect::>() - .join("\n"); - assert!(joined.contains("children:"), "{joined}"); - assert!(joined.contains("result:"), "{joined}"); - assert!(joined.contains("found 3 call sites"), "{joined}"); - assert!(!joined.contains("reasoning unknown"), "{joined}"); - assert!(!joined.contains("via unknown"), "{joined}"); - assert!(!joined.contains("tokens unknown"), "{joined}"); - assert!( - joined.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()), - "history details hint must use the platform chord: {joined}" - ); - assert!(!joined.contains("details (v)"), "{joined}"); - } - - #[test] - fn from_run_json_round_trips_events_into_history_card() { - let value = json!({ - "run_id": "workflow_demo", - "status": "completed", - "workflow_goal": "ship it", - "started_at_ms": 1000, - "completed_at_ms": 5000, - "events": [ - { - "type": "run_started", - "at_ms": 1000, - "run_id": "workflow_demo", - "workflow_goal": "ship it" - }, - {"type": "phase_started", "at_ms": 1100, "title": "Build"}, - { - "type": "task_started", - "at_ms": 1200, - "task_id": "t1", - "label": "compile", - "profile": "implementer" - }, - { - "type": "task_completed", - "at_ms": 4000, - "task_id": "t1", - "status": "succeeded" - }, - {"type": "run_completed", "at_ms": 5000, "status": "completed"} - ] - }); - let panel = WorkflowPanel::from_run_json(&value).expect("hydrate"); - assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded); - let compact = panel.compact_summary_text(120); - assert!(compact.contains("1 child"), "{compact}"); - assert!(compact.contains("success"), "{compact}"); - let expanded = panel.history_expanded_lines(120, &WorkflowHistoryExtras::default()); - let joined: String = expanded - .iter() - .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) - .collect::>() - .join("\n"); - assert!(joined.contains("goal:"), "{joined}"); - assert!(joined.contains("ship it"), "{joined}"); - assert!(joined.contains("Build"), "{joined}"); - assert!(joined.contains("compile"), "{joined}"); - } - - // ── #4131 dogfood scenario projections ────────────────────────────────── - - /// WF-A1: read-only repo audit — scout phase on main workspace, labeled - /// children, no worktree marker, synthesizer phase present. #[test] fn dogfood_read_only_repo_audit_panel() { let mut panel = WorkflowPanel::new("wf_a1", "read-only repo audit", 1_000); @@ -3761,14 +3587,22 @@ mod tests { "read-only scouts stay on main: {scout_joined}" ); - let card = panel.render_history_card( - 120, - true, - &WorkflowHistoryExtras { + let card = { + let extras = WorkflowHistoryExtras { result_summary: Some("no critical issues".to_string()), ..WorkflowHistoryExtras::default() - }, - ); + }; + let content_width = usize::from(120u16).max(1); + let summary = panel.compact_summary_text(content_width.saturating_sub(2)); + let mut lines = vec![Line::from(Span::styled( + truncate_line_to_width(&format!("▼ {summary}"), content_width), + Style::default() + .fg(panel.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))]; + lines.extend(panel.history_expanded_lines(120, &extras)); + lines + }; let card_text: String = card .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3947,14 +3781,22 @@ mod tests { let header = panel.header_text(140); assert!(header.contains("1 fail"), "{header}"); - let card = panel.render_history_card( - 140, - true, - &WorkflowHistoryExtras { + let card = { + let extras = WorkflowHistoryExtras { result_summary: Some("2/3 scouts ok; scout-b failed".to_string()), ..WorkflowHistoryExtras::default() - }, - ); + }; + let content_width = usize::from(140u16).max(1); + let summary = panel.compact_summary_text(content_width.saturating_sub(2)); + let mut lines = vec![Line::from(Span::styled( + truncate_line_to_width(&format!("▼ {summary}"), content_width), + Style::default() + .fg(panel.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))]; + lines.extend(panel.history_expanded_lines(140, &extras)); + lines + }; let joined: String = card .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) 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/src/tui/work_surface/tideline/tests.rs b/crates/tui/src/tui/work_surface/tideline/tests.rs index ec9e70b93e..1300eb21f9 100644 --- a/crates/tui/src/tui/work_surface/tideline/tests.rs +++ b/crates/tui/src/tui/work_surface/tideline/tests.rs @@ -17,7 +17,6 @@ use crate::palette::UI_THEME; use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; use crate::tui::history::tideline_exports::{ TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream, - tideline_stream_hitboxes, }; fn events() -> Vec { @@ -185,7 +184,6 @@ fn work_stage_hitboxes_match_painted_rows() { let groups = groups(); let events = events(); let rail = TidelineRail::new(&UI_THEME, &groups).focused(true); - let stream = TidelineStream::new(&UI_THEME, &events).selected(Some(3)); let stage = TidelineWorkStage { rail: TidelineRail::new(&UI_THEME, &groups).focused(true), stream: TidelineStream::new(&UI_THEME, &events).selected(Some(3)), @@ -202,18 +200,6 @@ fn work_stage_hitboxes_match_painted_rows() { .collect(); assert!(!cells.trim().is_empty(), "rail rect covers empty cells"); } - - let stream_area = Rect::new(22, 0, w - 22, h); - let stream_boxes = tideline_stream_hitboxes(stream_area, &stream); - assert_eq!(stream_boxes.len(), events.len(), "one rect per event"); - // The fleet tree's rect spans its three edge rows. - assert_eq!(stream_boxes[1].height, 3); - for rect in &stream_boxes { - let cells: String = (rect.x..rect.x + rect.width) - .map(|x| buf[(x, rect.y)].symbol().to_string()) - .collect(); - assert!(!cells.trim().is_empty(), "stream rect covers empty cells"); - } } #[test] diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index b450020820..2e6ef40e59 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -2415,29 +2415,6 @@ mod tests { ); } - /// Regression for #1921 — typing `@/` (or `@.`) must NOT trigger the - /// `local_reference_paths` walk, which scans up to - /// `LOCAL_REFERENCE_SCAN_LIMIT` paths on the UI thread. On WSL2 with a - /// `/mnt/c/...` workspace this hangs the composer for seconds to minutes. - #[test] - fn should_try_local_reference_completion_skips_bare_separators_and_dots() { - // The trigger gate must reject bare separators/dots. - assert!(!should_try_local_reference_completion("/")); - assert!(!should_try_local_reference_completion("\\")); - assert!(!should_try_local_reference_completion(".")); - assert!(!should_try_local_reference_completion("..")); - // Empty string was already rejected; keep that. - assert!(!should_try_local_reference_completion("")); - - // Actionable references must still trigger. - assert!(should_try_local_reference_completion("./foo")); - assert!(should_try_local_reference_completion("../bar")); - assert!(should_try_local_reference_completion(".env")); - assert!(should_try_local_reference_completion("path/")); - assert!(should_try_local_reference_completion("path/to/file")); - assert!(should_try_local_reference_completion("/usr")); - } - #[test] fn cached_candidates_rank_like_live_completions() { // #3757: the composer caches one full candidate walk and ranks per diff --git a/crates/tui/tests/README.md b/crates/tui/tests/README.md index 17a76179aa..13ef32bb3f 100644 --- a/crates/tui/tests/README.md +++ b/crates/tui/tests/README.md @@ -16,7 +16,7 @@ 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/integration/main.rs` | `integration` | 14 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`, `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 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. | 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 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..60d9c983c3 100644 --- a/crates/tui/tests/integration/integration_mock_llm.rs +++ b/crates/tui/tests/integration/integration_mock_llm.rs @@ -526,28 +526,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..2a4d553ebb 100644 --- a/crates/tui/tests/integration/main.rs +++ b/crates/tui/tests/integration/main.rs @@ -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_preflight.rs b/crates/workflow/src/fleet_preflight.rs index a441d0cb31..be8b376e87 100644 --- a/crates/workflow/src/fleet_preflight.rs +++ b/crates/workflow/src/fleet_preflight.rs @@ -223,17 +223,6 @@ impl RoutePreflight { self.workers.iter().find(|route| route.member_id == key) } - /// Fail unless every worker route is runnable. - /// - /// Called before a Workflow is allowed to start, so a member with no - /// credential is a startup error rather than a first-task surprise. - pub fn require_all_ready(&self) -> Result<(), PreflightError> { - for route in &self.workers { - route.require_ready()?; - } - Ok(()) - } - /// Whether any worker route talks to a different provider than the Router. /// This is what a receipt discloses as cross-provider inference. #[must_use] @@ -364,9 +353,8 @@ mod tests { detail: "no ZAI_API_KEY".to_string(), }; - let preflight = RoutePreflight::new(vec![route], None); - let err = preflight - .require_all_ready() + let err = route + .require_ready() .expect_err("a member with no credential must not start"); assert!(matches!(err, PreflightError::CredentialMissing { .. })); let message = err.to_string(); 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/fleet_snapshot.rs b/crates/workflow/src/fleet_snapshot.rs index 3cc72410f3..53b7e59a01 100644 --- a/crates/workflow/src/fleet_snapshot.rs +++ b/crates/workflow/src/fleet_snapshot.rs @@ -431,18 +431,6 @@ fn exact_members(exact: &ExactFleet) -> Vec { .collect() } -/// Verify a snapshot that arrived from anywhere other than [`FleetSnapshot::capture`]. -/// -/// The free function exists for load/deserialize seams that hold a snapshot by -/// reference and only need the yes/no answer — a durable-write guard, a replay -/// loader, a cache read. It is the same check as -/// [`FleetSnapshot::verify_content_hash`]; having a named entry point is what -/// lets those call sites read as "verify before use" rather than as an -/// incidental method call. -pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> { - snapshot.verify_content_hash() -} - /// Normalize an exact Fleet's **legacy inline** Router into the captured /// service, if it used the prototype form. /// @@ -500,7 +488,6 @@ permissions = "analyst" fn a_freshly_captured_snapshot_verifies() { let snapshot = captured(); assert!(snapshot.verify_content_hash().is_ok()); - assert!(verify_snapshot_content_hash(&snapshot).is_ok()); assert!(snapshot.into_verified().is_ok()); } diff --git a/crates/workflow/src/gates.rs b/crates/workflow/src/gates.rs index 3a53ba409a..1b6e51c8de 100644 --- a/crates/workflow/src/gates.rs +++ b/crates/workflow/src/gates.rs @@ -13,7 +13,6 @@ //! wire in later; unit tests cover block/approve/retry/escalate paths. use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -255,20 +254,6 @@ impl LaneGateBoard { Ok(()) } - /// Latest handoff of `kind` from `from_role` to `to_role`, if any. - pub fn latest_handoff( - &self, - from_role: &str, - to_role: &str, - kind: &str, - ) -> Option<&HandoffArtifact> { - self.artifacts.iter().rev().find(|a| { - a.from_role.eq_ignore_ascii_case(from_role) - && a.to_role.eq_ignore_ascii_case(to_role) - && a.kind.eq_ignore_ascii_case(kind) - }) - } - /// Remove and return the latest handoffs addressed to `to_role` (newest /// first), up to `limit`. /// @@ -295,22 +280,6 @@ impl LaneGateBoard { consumed } - /// Persist board under a lane directory (JSON). - pub fn save_to_dir(&self, dir: &Path) -> Result { - std::fs::create_dir_all(dir).map_err(|e| GateError::Io(e.to_string()))?; - let path = dir.join("gates.json"); - let json = - serde_json::to_string_pretty(self).map_err(|e| GateError::Serde(e.to_string()))?; - std::fs::write(&path, json).map_err(|e| GateError::Io(e.to_string()))?; - Ok(path) - } - - pub fn load_from_dir(dir: &Path) -> Result { - let path = dir.join("gates.json"); - let text = std::fs::read_to_string(&path).map_err(|e| GateError::Io(e.to_string()))?; - serde_json::from_str(&text).map_err(|e| GateError::Serde(e.to_string())) - } - /// Compact status for `lane status` / panel surfaces. pub fn status_summary(&self) -> Vec { self.gates @@ -338,60 +307,58 @@ pub enum GateError { EmptyGateId, #[error("handoff lane id `{got}` does not match board lane `{expected}`")] LaneMismatch { expected: String, got: String }, - #[error("io error: {0}")] - Io(String), - #[error("serde error: {0}")] - Serde(String), -} - -/// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead). -pub fn stopship_gate_pipeline() -> Vec { - vec![ - GateSpec { - id: "scout-findings".into(), - role: "scout".into(), - on: GateOn::RoleComplete, - gate: GateKind::Approve, - on_fail: GateOnFail::Block, - blocks_role: Some("implementer".into()), - max_retries: 0, - artifact_kind: Some("findings".into()), - require_explicit_verdict: false, - }, - GateSpec { - id: "reviewer-diff".into(), - role: "reviewer".into(), - on: GateOn::RoleComplete, - gate: GateKind::Review, - on_fail: GateOnFail::Block, - blocks_role: Some("verifier".into()), - max_retries: 1, - artifact_kind: Some("diff_review".into()), - require_explicit_verdict: false, - }, - GateSpec { - id: "verifier-suite".into(), - role: "verifier".into(), - on: GateOn::RoleComplete, - gate: GateKind::Verify, - on_fail: GateOnFail::Retry, - blocks_role: Some("release_lead".into()), - max_retries: 2, - artifact_kind: Some("verify_report".into()), - require_explicit_verdict: false, - }, - ] } #[cfg(test)] mod tests { use super::*; - use tempfile::tempdir; + + /// Inline copy of the canonical stopship-style gate specs (scout → + /// implementer → reviewer → verifier → release_lead). The production + /// constructor was removed with the unwired pipeline scaffolding; these + /// literals keep exercising the live `LaneGateBoard` semantics. + fn stopship_gate_specs() -> Vec { + vec![ + GateSpec { + id: "scout-findings".into(), + role: "scout".into(), + on: GateOn::RoleComplete, + gate: GateKind::Approve, + on_fail: GateOnFail::Block, + blocks_role: Some("implementer".into()), + max_retries: 0, + artifact_kind: Some("findings".into()), + require_explicit_verdict: false, + }, + GateSpec { + id: "reviewer-diff".into(), + role: "reviewer".into(), + on: GateOn::RoleComplete, + gate: GateKind::Review, + on_fail: GateOnFail::Block, + blocks_role: Some("verifier".into()), + max_retries: 1, + artifact_kind: Some("diff_review".into()), + require_explicit_verdict: false, + }, + GateSpec { + id: "verifier-suite".into(), + role: "verifier".into(), + on: GateOn::RoleComplete, + gate: GateKind::Verify, + on_fail: GateOnFail::Retry, + blocks_role: Some("release_lead".into()), + max_retries: 2, + artifact_kind: Some("verify_report".into()), + require_explicit_verdict: false, + }, + ] + } #[test] fn scout_handoff_passes_findings_to_implementer() { let mut board = LaneGateBoard::new("lane-test"); - let gates = stopship_gate_pipeline(); + let gates = stopship_gate_specs(); board.install_gates(&gates); board @@ -406,16 +373,16 @@ mod tests { }) .unwrap(); - let art = board - .latest_handoff("scout", "implementer", "findings") - .expect("findings artifact"); - assert_eq!(art.id, "art-1"); - assert!(art.payload.contains("4090")); - // Approve scout gate so implementer unblocks. let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap(); assert_eq!(state, GateState::Passed); assert!(board.role_is_blocked(&gates, "implementer").is_none()); + assert!( + board + .status_summary() + .iter() + .any(|l| l.gate_id == "scout-findings" && l.state == "passed") + ); } #[test] @@ -458,7 +425,7 @@ mod tests { #[test] fn reviewer_block_prevents_verifier() { let mut board = LaneGateBoard::new("lane-rev"); - let gates = stopship_gate_pipeline(); + let gates = stopship_gate_specs(); board.install_gates(&gates); let state = board @@ -479,7 +446,7 @@ mod tests { #[test] fn verifier_retry_then_escalate() { let mut board = LaneGateBoard::new("lane-ver"); - let gates = stopship_gate_pipeline(); + let gates = stopship_gate_specs(); board.install_gates(&gates); let verify = &gates[2]; assert_eq!(verify.max_retries, 2); @@ -519,7 +486,7 @@ mod tests { #[test] fn human_approve_clears_block() { let mut board = LaneGateBoard::new("lane-hum"); - let gates = stopship_gate_pipeline(); + let gates = stopship_gate_specs(); board.install_gates(&gates); board .evaluate( @@ -542,27 +509,4 @@ mod tests { assert_eq!(state, GateState::Passed); assert!(board.role_is_blocked(&gates, "verifier").is_none()); } - - #[test] - fn board_persists_for_lane_status() { - let dir = tempdir().unwrap(); - let mut board = LaneGateBoard::new("lane-persist"); - board.install_gates(&stopship_gate_pipeline()); - board - .evaluate( - &stopship_gate_pipeline()[0], - GateOutcome::Fail { - reason: "scout incomplete".into(), - }, - ) - .unwrap(); - board.save_to_dir(dir.path()).unwrap(); - let loaded = LaneGateBoard::load_from_dir(dir.path()).unwrap(); - let summary = loaded.status_summary(); - assert!( - summary - .iter() - .any(|l| l.gate_id == "scout-findings" && l.state == "blocked") - ); - } } 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..84ed470c87 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -19,16 +19,12 @@ pub mod fleet_reasoning; pub mod fleet_snapshot; mod gates; mod js_authoring; -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,27 +44,26 @@ 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, - QualifiedFleetId, captured_legacy_inline_router, verify_snapshot_content_hash, + QualifiedFleetId, captured_legacy_inline_router, }; pub use gates::{ GateError, GateKind, GateOn, GateOnFail, GateOutcome, GateSpec, GateState, GateStatusLine, - HandoffArtifact, LaneGateBoard, stopship_gate_pipeline, + HandoffArtifact, LaneGateBoard, }; pub use js_authoring::{ JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow, compile_typescript_workflow, }; -pub use model_policy::*; pub use named_fleet::{ FleetDocument, FleetSchema, FleetSearchRoot, NamedFleet, NamedFleetError, STOPSHIP_REQUIRED_ROLES, exact_schema_revision, load_named_fleet, load_named_fleet_file, @@ -83,42 +78,11 @@ 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, -}; pub use role_resolve::{ FleetRoleMap, FleetRoleResolveError, ResolvedWorkflowAgent, normalize_token, 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 +106,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 +288,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 +434,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 +450,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, @@ -806,465 +527,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 +1006,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 +1031,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 +1172,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 +1184,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 +1205,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 +1236,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 +1253,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 +1533,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 { @@ -3120,82 +1657,6 @@ mod tests { 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")); - } - #[test] fn leaf_role_token_rule_rejects_invalid_names() { for bad in ["", "has space", "role=scout"] { @@ -3206,8 +1667,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 +1713,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 +1728,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 +1754,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 +1776,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 +1801,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 +1825,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/model_policy.rs b/crates/workflow/src/model_policy.rs deleted file mode 100644 index 70754f2128..0000000000 --- a/crates/workflow/src/model_policy.rs +++ /dev/null @@ -1,654 +0,0 @@ -use std::collections::BTreeMap; - -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; -use thiserror::Error; - -use crate::{AgentType, ModelPolicy, WorkflowUsage}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ModelRole { - Planner, - LeafReasoner, - Implementer, - Reviewer, - Teacher, - Student, - JsonExtractor, -} - -impl From for ModelRole { - fn from(agent_type: AgentType) -> Self { - match agent_type { - AgentType::General | AgentType::Explore => Self::LeafReasoner, - AgentType::Plan => Self::Planner, - AgentType::Review | AgentType::Verifier => Self::Reviewer, - AgentType::Implementer => Self::Implementer, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -pub struct ModelCapabilities { - #[serde(default)] - pub tool_calls: bool, - #[serde(default)] - pub json_mode: bool, - #[serde(default)] - pub prompt_cache: bool, - #[serde(default)] - pub large_context: bool, - #[serde(default)] - pub streaming: bool, -} - -impl ModelCapabilities { - #[must_use] - pub fn satisfies(self, required: Self) -> bool { - (!required.tool_calls || self.tool_calls) - && (!required.json_mode || self.json_mode) - && (!required.prompt_cache || self.prompt_cache) - && (!required.large_context || self.large_context) - && (!required.streaming || self.streaming) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProviderModel { - pub provider: String, - pub model: String, - #[serde(default)] - pub capabilities: ModelCapabilities, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ResolvedModel { - pub role: ModelRole, - pub provider: String, - pub model: String, - pub capabilities: ModelCapabilities, - pub source: ModelSelectionSource, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ModelSelectionSource { - Primary, - Fallback, - RoleDefault, -} - -#[derive(Debug, Clone, Default)] -pub struct ProviderRegistry { - models: BTreeMap, - role_policies: BTreeMap, -} - -impl ProviderRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn with_model(mut self, model: ProviderModel) -> Self { - self.insert_model(model); - self - } - - pub fn with_role_policy(mut self, role: ModelRole, policy: ModelPolicy) -> Self { - self.role_policies.insert(role, policy); - self - } - - pub fn insert_model(&mut self, model: ProviderModel) { - self.models - .insert(model_key(&model.provider, &model.model), model); - } - - pub fn resolve_role( - &self, - role: ModelRole, - policy: Option<&ModelPolicy>, - required: ModelCapabilities, - ) -> Result { - let policy = match policy { - Some(policy) => (policy, ModelSelectionSource::Primary), - None => ( - self.role_policies - .get(&role) - .ok_or(ModelPolicyError::MissingPolicy { role })?, - ModelSelectionSource::RoleDefault, - ), - }; - self.resolve_policy(role, policy.0, policy.1, required) - } - - fn resolve_policy( - &self, - role: ModelRole, - policy: &ModelPolicy, - primary_source: ModelSelectionSource, - required: ModelCapabilities, - ) -> Result { - let candidates = model_candidates(policy)?; - let mut rejected = Vec::new(); - for (index, candidate) in candidates.iter().enumerate() { - let source = if index == 0 { - primary_source - } else { - ModelSelectionSource::Fallback - }; - let Some(model) = self - .models - .get(&model_key(&candidate.provider, &candidate.model)) - else { - rejected.push(format!( - "{}/{}: unknown", - candidate.provider, candidate.model - )); - continue; - }; - if model.capabilities.satisfies(required) { - return Ok(ResolvedModel { - role, - provider: model.provider.clone(), - model: model.model.clone(), - capabilities: model.capabilities, - source, - }); - } - rejected.push(format!( - "{}/{}: missing required capabilities", - model.provider, model.model - )); - } - Err(ModelPolicyError::NoCapableModel { role, rejected }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompletionRequest { - pub role: ModelRole, - pub prompt: String, - #[serde(default)] - pub require_json: bool, - #[serde(default)] - pub model_policy: ModelPolicy, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CompletionResponse { - pub text: String, - #[serde(default)] - pub usage: WorkflowUsage, -} - -pub trait ModelProvider { - fn provider(&self) -> &str; - fn model(&self) -> &str; - fn capabilities(&self) -> ModelCapabilities; - fn complete( - &self, - request: &CompletionRequest, - ) -> Result; -} - -#[derive(Debug, Clone)] -pub struct MockModelProvider { - provider: String, - model: String, - capabilities: ModelCapabilities, - response: CompletionResponse, -} - -impl MockModelProvider { - pub fn new( - provider: impl Into, - model: impl Into, - capabilities: ModelCapabilities, - response: impl Into, - ) -> Self { - Self { - provider: provider.into(), - model: model.into(), - capabilities, - response: CompletionResponse { - text: response.into(), - usage: WorkflowUsage::default(), - }, - } - } -} - -impl ModelProvider for MockModelProvider { - fn provider(&self) -> &str { - &self.provider - } - - fn model(&self) -> &str { - &self.model - } - - fn capabilities(&self) -> ModelCapabilities { - self.capabilities - } - - fn complete( - &self, - _request: &CompletionRequest, - ) -> Result { - Ok(self.response.clone()) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ModelPolicyError { - #[error("no model policy configured for role `{role:?}`")] - MissingPolicy { role: ModelRole }, - #[error("model policy must include a model for role resolution")] - MissingModel, - #[error("fallback model `{model}` requires a provider when the primary policy has none")] - MissingFallbackProvider { model: String }, - #[error("no configured model satisfies role `{role:?}` requirements: {rejected:?}")] - NoCapableModel { - role: ModelRole, - rejected: Vec, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum ModelProviderError { - #[error("model provider `{provider}/{model}` failed: {reason}")] - Failed { - provider: String, - model: String, - reason: String, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum JsonRepairError { - #[error("json parse failed before and after one repair pass: {reason}")] - Parse { reason: String }, -} - -pub fn parse_json_with_repair(raw: &str) -> Result { - match serde_json::from_str(raw) { - Ok(parsed) => Ok(parsed), - Err(first) => { - let repaired = repair_json_text_once(raw); - serde_json::from_str(&repaired).map_err(|second| JsonRepairError::Parse { - reason: format!("{first}; repair failed: {second}"), - }) - } - } -} - -pub fn repair_json_text_once(raw: &str) -> String { - let trimmed = raw.trim(); - let without_fence = trimmed - .strip_prefix("```json") - .or_else(|| trimmed.strip_prefix("```")) - .and_then(|value| value.strip_suffix("```")) - .map(str::trim) - .unwrap_or(trimmed); - - first_valid_json_payload(without_fence) - .unwrap_or(without_fence) - .to_string() -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelCandidate { - provider: String, - model: String, -} - -fn model_candidates(policy: &ModelPolicy) -> Result, ModelPolicyError> { - let mut candidates = Vec::new(); - let Some(primary_model) = policy.model.as_ref() else { - return Err(ModelPolicyError::MissingModel); - }; - candidates.push(candidate_from_model( - policy.provider.as_deref(), - primary_model, - )?); - for fallback in &policy.fallback_models { - candidates.push(candidate_from_model(policy.provider.as_deref(), fallback)?); - } - Ok(candidates) -} - -fn candidate_from_model( - default_provider: Option<&str>, - model: &str, -) -> Result { - if let Some((provider, model)) = model.split_once('/') { - return Ok(ModelCandidate { - provider: provider.to_string(), - model: model.to_string(), - }); - } - let Some(provider) = default_provider else { - return Err(ModelPolicyError::MissingFallbackProvider { - model: model.to_string(), - }); - }; - Ok(ModelCandidate { - provider: provider.to_string(), - model: model.to_string(), - }) -} - -fn model_key(provider: &str, model: &str) -> String { - format!("{provider}/{model}") -} - -// Repair scans untrusted model text once per possible container start. Keep the -// fallback bounded when malformed output contains a long delimiter flood. -const JSON_REPAIR_CANDIDATE_LIMIT: usize = 64; - -fn first_valid_json_payload(raw: &str) -> Option<&str> { - let mut attempted = 0; - for (start, open) in raw.char_indices() { - if !matches!(open, '{' | '[') { - continue; - } - attempted += 1; - if attempted > JSON_REPAIR_CANDIDATE_LIMIT { - break; - } - - let Some(candidate) = balanced_json_payload(&raw[start..]) else { - continue; - }; - if serde_json::from_str::(candidate).is_ok() { - return Some(candidate); - } - } - None -} - -fn balanced_json_payload(raw: &str) -> Option<&str> { - let mut stack = Vec::new(); - let mut in_string = false; - let mut escaped = false; - - for (offset, character) in raw.char_indices() { - if in_string { - if escaped { - escaped = false; - } else { - match character { - '\\' => escaped = true, - '"' => in_string = false, - _ => {} - } - } - continue; - } - - match character { - '"' => in_string = true, - '{' | '[' => stack.push(character), - '}' | ']' => { - let expected_open = if character == '}' { '{' } else { '[' }; - if stack.pop() != Some(expected_open) { - return None; - } - if stack.is_empty() { - return Some(&raw[..offset + character.len_utf8()]); - } - } - _ => {} - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - - fn model(provider: &str, model: &str, capabilities: ModelCapabilities) -> ProviderModel { - ProviderModel { - provider: provider.to_string(), - model: model.to_string(), - capabilities, - } - } - - #[test] - fn provider_capability_fallback() { - let registry = ProviderRegistry::new() - .with_model(model("mock", "plain", ModelCapabilities::default())) - .with_model(model( - "mock", - "json", - ModelCapabilities { - json_mode: true, - ..ModelCapabilities::default() - }, - )); - let policy = ModelPolicy { - provider: Some("mock".to_string()), - model: Some("plain".to_string()), - fallback_models: vec!["json".to_string()], - }; - - let resolved = registry - .resolve_role( - ModelRole::JsonExtractor, - Some(&policy), - ModelCapabilities { - json_mode: true, - ..ModelCapabilities::default() - }, - ) - .expect("fallback json model should satisfy the role"); - - assert_eq!(resolved.model, "json"); - assert_eq!(resolved.source, ModelSelectionSource::Fallback); - } - - #[test] - fn role_default_policy_resolves_model() { - let registry = ProviderRegistry::new() - .with_model(model( - "mock", - "planner", - ModelCapabilities { - large_context: true, - ..ModelCapabilities::default() - }, - )) - .with_role_policy( - ModelRole::Planner, - ModelPolicy { - provider: Some("mock".to_string()), - model: Some("planner".to_string()), - fallback_models: Vec::new(), - }, - ); - - let resolved = registry - .resolve_role( - ModelRole::Planner, - None, - ModelCapabilities { - large_context: true, - ..ModelCapabilities::default() - }, - ) - .expect("role default should resolve"); - - assert_eq!(resolved.role, ModelRole::Planner); - assert_eq!(resolved.source, ModelSelectionSource::RoleDefault); - } - - #[test] - fn agent_type_maps_to_model_role() { - assert_eq!(ModelRole::from(AgentType::Plan), ModelRole::Planner); - assert_eq!( - ModelRole::from(AgentType::Implementer), - ModelRole::Implementer - ); - assert_eq!(ModelRole::from(AgentType::Verifier), ModelRole::Reviewer); - } - - #[test] - fn json_repair_fallback() { - #[derive(Debug, Deserialize, PartialEq, Eq)] - struct Payload { - answer: String, - } - - let parsed: Payload = parse_json_with_repair( - r#"Here is the JSON: -```json -{"answer":"ok"} -``` -"#, - ) - .expect("repair should extract fenced JSON"); - - assert_eq!( - parsed, - Payload { - answer: "ok".to_string() - } - ); - } - - #[test] - fn json_repair_fallback_fails_closed() { - let err = parse_json_with_repair::("not json") - .expect_err("non-json text should fail closed"); - - assert!(matches!(err, JsonRepairError::Parse { .. })); - } - - #[test] - fn mock_provider_returns_configured_response() { - let provider = MockModelProvider::new( - "mock", - "fast", - ModelCapabilities::default(), - "mock response", - ); - let request = CompletionRequest { - role: ModelRole::LeafReasoner, - prompt: "say something".to_string(), - require_json: false, - model_policy: ModelPolicy::default(), - }; - - let response = provider.complete(&request).expect("mock should respond"); - - assert_eq!(provider.provider(), "mock"); - assert_eq!(provider.model(), "fast"); - assert_eq!(response.text, "mock response"); - } - - #[test] - fn repair_json_text_once_extracts_supported_payloads() { - // Plain JSON object - assert_eq!( - repair_json_text_once(r#"{"key": "value"}"#), - r#"{"key": "value"}"# - ); - - // Plain JSON array - assert_eq!(repair_json_text_once(r#"[1, 2, 3]"#), r#"[1, 2, 3]"#); - - // Markdown fenced JSON object - assert_eq!( - repair_json_text_once("```json\n{\"key\": \"value\"}\n```"), - r#"{"key": "value"}"# - ); - - // Markdown fenced JSON array - assert_eq!( - repair_json_text_once("```json\n[1, 2, 3]\n```"), - r#"[1, 2, 3]"# - ); - - // Generic markdown fence - assert_eq!( - repair_json_text_once("```\n{\"key\": \"value\"}\n```"), - r#"{"key": "value"}"# - ); - - // JSON object embedded in text - assert_eq!( - repair_json_text_once("Here is the JSON:\n\n{\"key\": \"value\"}\n\nHope this helps!"), - r#"{"key": "value"}"# - ); - - // JSON array embedded in text - assert_eq!( - repair_json_text_once("Some text before [1, 2, 3] and some text after"), - r#"[1, 2, 3]"# - ); - - // Nested structures (object containing array) - assert_eq!( - repair_json_text_once(r#"{"key": [1, 2, 3]}"#), - r#"{"key": [1, 2, 3]}"# - ); - - // Nested structures (array containing object) - assert_eq!( - repair_json_text_once(r#"[{"key": "value"}]"#), - r#"[{"key": "value"}]"# - ); - - // Fenced JSON embedded in text - assert_eq!( - repair_json_text_once("Here is the JSON:\n```json\n{\"key\": \"value\"}\n```\nDone."), - r#"{"key": "value"}"# - ); - - // No valid JSON, fallback to trimmed text - assert_eq!( - repair_json_text_once("Just some plain text without json"), - "Just some plain text without json" - ); - - // Fenced plain text, falls back to stripped and trimmed text - assert_eq!( - repair_json_text_once("```json\nJust some plain text\n```"), - "Just some plain text" - ); - - // An unmatched opening bracket must not block a later valid object. - assert_eq!(repair_json_text_once("[note {\"ok\":1}"), r#"{"ok":1}"#); - - // Delimiters inside strings must not terminate the outer object. - assert_eq!( - repair_json_text_once(r#"Prefix text {"data": "[nested]"} postfix text"#), - r#"{"data": "[nested]"}"# - ); - - // The earliest balanced candidate may still be invalid JSON. Keep scanning - // until a balanced candidate also parses successfully. - assert_eq!( - repair_json_text_once(r#"[not-json] then {"ok":true}"#), - r#"{"ok":true}"# - ); - - // Escaped quotes and backslashes stay inside the JSON string. - assert_eq!( - repair_json_text_once( - r#"prefix {"value":"a \"quoted\" [item]","path":"C:\\tmp"} suffix"# - ), - r#"{"value":"a \"quoted\" [item]","path":"C:\\tmp"}"# - ); - - // A mismatched outer delimiter must not consume a later valid payload. - assert_eq!( - repair_json_text_once(r#"[broken} then [1,{"ok":true}]"#), - r#"[1,{"ok":true}]"# - ); - } -} 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/crates/workflow/src/review_repair.rs b/crates/workflow/src/review_repair.rs deleted file mode 100644 index b318c18b15..0000000000 --- a/crates/workflow/src/review_repair.rs +++ /dev/null @@ -1,890 +0,0 @@ -//! Bounded review→repair automation (#3832). -//! -//! ## What this is not -//! -//! It is **not a fourth Mode.** Codewhale has exactly three Modes — Plan, Act, -//! Operate — and exactly three permission postures — Ask, Auto-Review, Full -//! Access. A review→repair loop is a *Workflow shape*: an ordering of existing -//! roles over existing gates. It therefore lives here, beside [`crate::gates`], -//! and carries no mode, no posture, and no permission of its own. Whatever Mode -//! and posture the session already has continue to govern every step; this type -//! only decides *when to stop*. -//! -//! ## The four things that make it safe -//! -//! 1. **Explicit ceilings.** [`ReviewRepairBounds`] caps iterations, wall-clock -//! seconds, and tool calls. Every ceiling is checked *before* work starts, so -//! the loop cannot overrun by one extra iteration; a zero ceiling means the -//! loop never runs rather than running forever. -//! 2. **Exact routes on the record.** Each iteration must carry a -//! [`RouteReceipt`] for the reviewer and, where the policy requires one, the -//! verifier: role, exact provider/model, requested→effective reasoning, and -//! who routed it. An iteration with no reviewer route is refused. -//! 3. **Human ratification where policy says.** When -//! [`ReviewRepairPolicy::require_human_ratification`] is set, a clean verify -//! does not finish the loop: it parks at -//! [`StopReason::AwaitingRatification`] until [`ReviewRepairLoop::ratify`] -//! records an explicit human decision. -//! 4. **Fail closed on stale input.** The loop pins the digest of the artifact -//! under review. If an iteration reports a different digest — the branch -//! moved, the diff was rebuilt, the findings came from an older tree — the -//! loop halts at [`StopReason::StaleInput`] instead of repairing against -//! something the reviewer never saw. - -use serde::{Deserialize, Serialize}; - -/// Explicit ceilings on a review→repair loop. There is no unbounded variant. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReviewRepairBounds { - /// Maximum review→repair iterations. `0` means the loop never starts. - pub max_iterations: u32, - /// Maximum cumulative wall-clock seconds across all iterations. - pub max_wall_clock_secs: u64, - /// Maximum cumulative tool calls across all iterations. - pub max_tool_calls: u32, -} - -impl ReviewRepairBounds { - /// Conservative defaults for an unattended loop: short, cheap, and easy to - /// re-run by hand if it stops early. - #[must_use] - pub fn conservative() -> Self { - Self { - max_iterations: 3, - max_wall_clock_secs: 900, - max_tool_calls: 120, - } - } -} - -impl Default for ReviewRepairBounds { - fn default() -> Self { - Self::conservative() - } -} - -/// Who chose the route for a step. Distinguishing these is the difference -/// between "the user picked this model" and "a Router picked it". -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum RoutedBy { - /// The exact Fleet member route, frozen at Fleet capture. - Fleet, - /// A Router chose reasoning for the frozen member route. The Router's own - /// exact identity is recorded so its spend is attributable. - Router { provider: String, model: String }, - /// The session's current model selection. - SessionDefault, -} - -impl RoutedBy { - /// Human-readable source label for receipts and Workflow rows. - #[must_use] - pub fn label(&self) -> String { - match self { - Self::Fleet => "fleet".to_string(), - Self::Router { provider, model } => format!("router {provider}/{model}"), - Self::SessionDefault => "session default".to_string(), - } - } -} - -/// The exact route a reviewer or verifier ran on. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RouteReceipt { - /// Fleet role (`reviewer`, `verifier`, `implementer`, …). - pub role: String, - pub provider: String, - pub model: String, - /// What the plan asked for. - pub requested_reasoning: String, - /// What the provider actually applied. Divergence is shown, not smoothed. - pub effective_reasoning: String, - pub routed_by: RoutedBy, -} - -impl RouteReceipt { - /// True when every identity field is present. An incomplete route is not a - /// receipt; it is a claim. - #[must_use] - pub fn is_complete(&self) -> bool { - !self.role.trim().is_empty() - && !self.provider.trim().is_empty() - && !self.model.trim().is_empty() - && !self.requested_reasoning.trim().is_empty() - && !self.effective_reasoning.trim().is_empty() - } - - /// One-line display: role, exact route, requested→effective, routed by. - #[must_use] - pub fn display_line(&self) -> String { - let reasoning = if self.requested_reasoning == self.effective_reasoning { - self.effective_reasoning.clone() - } else { - format!("{}→{}", self.requested_reasoning, self.effective_reasoning) - }; - format!( - "{}: {}/{} reasoning {reasoning} (routed by {})", - self.role, - self.provider, - self.model, - self.routed_by.label() - ) - } -} - -/// The verdict a reviewer/verifier pair produced for one iteration. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IterationVerdict { - /// Review and verification both passed. The loop may finish. - Clean, - /// Findings remain; another repair iteration is warranted. - RepairsNeeded, - /// The reviewer refused to judge (missing input, tool failure, …). The loop - /// stops rather than treating an absent judgment as a pass. - Inconclusive, -} - -/// What one iteration actually did. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct IterationReceipt { - /// 1-based iteration number, assigned by the loop. - pub iteration: u32, - /// Digest of the artifact this iteration reviewed. - pub input_digest: String, - pub reviewer: RouteReceipt, - /// Present when the policy requires a separate verify step. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub verifier: Option, - pub verdict: IterationVerdict, - /// Bounded finding summaries. Not the findings themselves. - #[serde(default)] - pub finding_summaries: Vec, - pub tool_calls_used: u32, - pub elapsed_secs: u64, -} - -/// Why a review→repair loop stopped. Every variant is terminal. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum StopReason { - /// Clean verdict, and either no ratification was required or it was given. - Verified, - /// A human explicitly rejected the result. - RatificationDeclined { - note: String, - }, - /// Clean verdict, waiting on the human the policy requires. - AwaitingRatification, - IterationCeiling { - max_iterations: u32, - }, - TimeCeiling { - max_wall_clock_secs: u64, - elapsed_secs: u64, - }, - ToolCeiling { - max_tool_calls: u32, - used: u32, - }, - /// The artifact under review changed underneath the loop. - StaleInput { - pinned: String, - reported: String, - }, - /// The reviewer could not judge, or its receipt was incomplete. - Inconclusive { - reason: String, - }, -} - -impl StopReason { - /// True only for the one outcome that means "this loop succeeded". - #[must_use] - pub fn is_success(&self) -> bool { - matches!(self, Self::Verified) - } - - /// True when the loop stopped because it hit an explicit ceiling. These are - /// honest incompletions, not failures. - #[must_use] - pub fn is_ceiling(&self) -> bool { - matches!( - self, - Self::IterationCeiling { .. } | Self::TimeCeiling { .. } | Self::ToolCeiling { .. } - ) - } - - /// Stable receipt line. UI surfaces localize around it; the numbers here are - /// the ones the user must be able to check. - #[must_use] - pub fn receipt(&self) -> String { - match self { - Self::Verified => "verified".to_string(), - Self::RatificationDeclined { note } => format!("ratification declined: {note}"), - Self::AwaitingRatification => { - "clean; awaiting human ratification before this counts as done".to_string() - } - Self::IterationCeiling { max_iterations } => { - format!("stopped at the iteration ceiling ({max_iterations})") - } - Self::TimeCeiling { - max_wall_clock_secs, - elapsed_secs, - } => format!("stopped at the time ceiling ({elapsed_secs}s of {max_wall_clock_secs}s)"), - Self::ToolCeiling { - max_tool_calls, - used, - } => format!("stopped at the tool ceiling ({used} of {max_tool_calls} calls)"), - Self::StaleInput { pinned, reported } => format!( - "stopped: input changed under review (pinned {pinned}, reported {reported})" - ), - Self::Inconclusive { reason } => format!("stopped: no usable verdict ({reason})"), - } - } -} - -/// Policy knobs that are not ceilings. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReviewRepairPolicy { - /// A clean verdict parks at [`StopReason::AwaitingRatification`] until a - /// human decides. - #[serde(default)] - pub require_human_ratification: bool, - /// Every iteration must carry a verifier receipt in addition to the - /// reviewer's. A missing verifier is inconclusive, never a pass. - #[serde(default)] - pub require_verifier_receipt: bool, -} - -impl Default for ReviewRepairPolicy { - fn default() -> Self { - // Fail closed by default: a human confirms, and review alone is not - // verification. - Self { - require_human_ratification: true, - require_verifier_receipt: true, - } - } -} - -/// A bounded review→repair loop over one lane's artifact. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ReviewRepairLoop { - pub lane_id: String, - pub bounds: ReviewRepairBounds, - pub policy: ReviewRepairPolicy, - /// Digest of the artifact this loop was authorized to work on. - pub pinned_input_digest: String, - #[serde(default)] - pub iterations: Vec, - #[serde(default)] - pub elapsed_secs: u64, - #[serde(default)] - pub tool_calls_used: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub stopped: Option, -} - -/// Refusal from [`ReviewRepairLoop::begin_iteration`] or -/// [`ReviewRepairLoop::record_iteration`]. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReviewRepairError { - /// The loop already stopped; it does not restart. - AlreadyStopped(StopReason), - /// A ceiling or fail-closed condition ended the loop on this call. - Stopped(StopReason), - /// The receipt itself was unusable. - IncompleteReceipt(String), -} - -impl ReviewRepairError { - /// The terminal reason, when this refusal carries one. - #[must_use] - pub fn stop_reason(&self) -> Option<&StopReason> { - match self { - Self::AlreadyStopped(reason) | Self::Stopped(reason) => Some(reason), - Self::IncompleteReceipt(_) => None, - } - } -} - -impl std::fmt::Display for ReviewRepairError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::AlreadyStopped(reason) => { - write!(f, "review-repair already stopped: {}", reason.receipt()) - } - Self::Stopped(reason) => write!(f, "{}", reason.receipt()), - Self::IncompleteReceipt(detail) => { - write!(f, "review-repair receipt is unusable: {detail}") - } - } - } -} - -impl std::error::Error for ReviewRepairError {} - -impl ReviewRepairLoop { - /// Start a loop pinned to the digest of the artifact under review. - #[must_use] - pub fn new( - lane_id: impl Into, - pinned_input_digest: impl Into, - bounds: ReviewRepairBounds, - policy: ReviewRepairPolicy, - ) -> Self { - Self { - lane_id: lane_id.into(), - bounds, - policy, - pinned_input_digest: pinned_input_digest.into(), - iterations: Vec::new(), - elapsed_secs: 0, - tool_calls_used: 0, - stopped: None, - } - } - - /// Whether the loop has stopped, and why. - #[must_use] - pub fn stop_reason(&self) -> Option<&StopReason> { - self.stopped.as_ref() - } - - /// Claim the next iteration slot, or refuse. - /// - /// Ceilings are checked here — *before* any model or tool spend — so a loop - /// that has exhausted its budget cannot buy one more iteration to find out. - pub fn begin_iteration(&mut self) -> Result { - if let Some(reason) = self.stopped.clone() { - return Err(ReviewRepairError::AlreadyStopped(reason)); - } - let next = self.iterations.len() as u32 + 1; - if next > self.bounds.max_iterations { - return Err(self.stop(StopReason::IterationCeiling { - max_iterations: self.bounds.max_iterations, - })); - } - if self.elapsed_secs >= self.bounds.max_wall_clock_secs { - return Err(self.stop(StopReason::TimeCeiling { - max_wall_clock_secs: self.bounds.max_wall_clock_secs, - elapsed_secs: self.elapsed_secs, - })); - } - if self.tool_calls_used >= self.bounds.max_tool_calls { - return Err(self.stop(StopReason::ToolCeiling { - max_tool_calls: self.bounds.max_tool_calls, - used: self.tool_calls_used, - })); - } - Ok(next) - } - - /// Record a completed iteration and decide whether the loop continues. - /// - /// Returns `Ok(None)` when another iteration is warranted, `Ok(Some(reason))` - /// when the loop is done. - pub fn record_iteration( - &mut self, - receipt: IterationReceipt, - ) -> Result, ReviewRepairError> { - if let Some(reason) = self.stopped.clone() { - return Err(ReviewRepairError::AlreadyStopped(reason)); - } - - // Fail closed on stale input before anything in the receipt is trusted: - // a verdict about a different tree is not a verdict about this one. - if receipt.input_digest != self.pinned_input_digest { - return Err(self.stop(StopReason::StaleInput { - pinned: self.pinned_input_digest.clone(), - reported: receipt.input_digest.clone(), - })); - } - if !receipt.reviewer.is_complete() { - return Err(ReviewRepairError::IncompleteReceipt(format!( - "reviewer route for iteration {} is missing identity fields", - receipt.iteration - ))); - } - if self.policy.require_verifier_receipt { - match receipt.verifier.as_ref() { - None => { - return Err(self.stop(StopReason::Inconclusive { - reason: "policy requires a verifier receipt and none was produced" - .to_string(), - })); - } - Some(verifier) if !verifier.is_complete() => { - return Err(ReviewRepairError::IncompleteReceipt(format!( - "verifier route for iteration {} is missing identity fields", - receipt.iteration - ))); - } - Some(_) => {} - } - } - - self.elapsed_secs = self.elapsed_secs.saturating_add(receipt.elapsed_secs); - self.tool_calls_used = self.tool_calls_used.saturating_add(receipt.tool_calls_used); - let verdict = receipt.verdict; - self.iterations.push(receipt); - - // Overrun is reported honestly: the ceiling is the reason we stop, even - // though this iteration's spend already happened. - if self.tool_calls_used > self.bounds.max_tool_calls { - let reason = StopReason::ToolCeiling { - max_tool_calls: self.bounds.max_tool_calls, - used: self.tool_calls_used, - }; - self.stopped = Some(reason.clone()); - return Ok(Some(reason)); - } - if self.elapsed_secs > self.bounds.max_wall_clock_secs { - let reason = StopReason::TimeCeiling { - max_wall_clock_secs: self.bounds.max_wall_clock_secs, - elapsed_secs: self.elapsed_secs, - }; - self.stopped = Some(reason.clone()); - return Ok(Some(reason)); - } - - match verdict { - IterationVerdict::Inconclusive => { - let reason = StopReason::Inconclusive { - reason: "reviewer returned no usable verdict".to_string(), - }; - self.stopped = Some(reason.clone()); - Ok(Some(reason)) - } - IterationVerdict::Clean => { - let reason = if self.policy.require_human_ratification { - StopReason::AwaitingRatification - } else { - StopReason::Verified - }; - self.stopped = Some(reason.clone()); - Ok(Some(reason)) - } - IterationVerdict::RepairsNeeded => { - if self.iterations.len() as u32 >= self.bounds.max_iterations { - let reason = StopReason::IterationCeiling { - max_iterations: self.bounds.max_iterations, - }; - self.stopped = Some(reason.clone()); - return Ok(Some(reason)); - } - Ok(None) - } - } - } - - /// Record the human decision a parked loop is waiting for. - /// - /// Only a loop actually parked at [`StopReason::AwaitingRatification`] can be - /// ratified: a loop that stopped at a ceiling, on stale input, or - /// inconclusively cannot be waved through, because there is no clean result - /// to approve. - pub fn ratify( - &mut self, - approved: bool, - note: impl Into, - ) -> Result<&StopReason, ReviewRepairError> { - match self.stopped.clone() { - Some(StopReason::AwaitingRatification) => { - self.stopped = Some(if approved { - StopReason::Verified - } else { - StopReason::RatificationDeclined { note: note.into() } - }); - Ok(self.stopped.as_ref().expect("just set")) - } - Some(other) => Err(ReviewRepairError::AlreadyStopped(other)), - None => Err(ReviewRepairError::IncompleteReceipt( - "the loop has not produced a clean result to ratify".to_string(), - )), - } - } - - /// Every route this loop ran, in order, for the Workflow row and receipts. - #[must_use] - pub fn route_lines(&self) -> Vec { - let mut lines = Vec::new(); - for iteration in &self.iterations { - lines.push(format!( - "#{} {}", - iteration.iteration, - iteration.reviewer.display_line() - )); - if let Some(verifier) = iteration.verifier.as_ref() { - lines.push(format!( - "#{} {}", - iteration.iteration, - verifier.display_line() - )); - } - } - lines - } - - /// One-line budget statement: what was used against what was allowed. - #[must_use] - pub fn budget_line(&self) -> String { - format!( - "iterations {}/{}, tools {}/{}, elapsed {}s/{}s", - self.iterations.len(), - self.bounds.max_iterations, - self.tool_calls_used, - self.bounds.max_tool_calls, - self.elapsed_secs, - self.bounds.max_wall_clock_secs, - ) - } - - fn stop(&mut self, reason: StopReason) -> ReviewRepairError { - self.stopped = Some(reason.clone()); - ReviewRepairError::Stopped(reason) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn route(role: &str) -> RouteReceipt { - RouteReceipt { - role: role.to_string(), - provider: "zhipu".to_string(), - model: "glm-5.2".to_string(), - requested_reasoning: "high".to_string(), - effective_reasoning: "high".to_string(), - routed_by: RoutedBy::Fleet, - } - } - - fn receipt(iteration: u32, digest: &str, verdict: IterationVerdict) -> IterationReceipt { - IterationReceipt { - iteration, - input_digest: digest.to_string(), - reviewer: route("reviewer"), - verifier: Some(route("verifier")), - verdict, - finding_summaries: Vec::new(), - tool_calls_used: 5, - elapsed_secs: 10, - } - } - - fn loop_with(bounds: ReviewRepairBounds, policy: ReviewRepairPolicy) -> ReviewRepairLoop { - ReviewRepairLoop::new("lane-1", "digest-a", bounds, policy) - } - - #[test] - fn iteration_ceiling_stops_before_spending_another_turn() { - let bounds = ReviewRepairBounds { - max_iterations: 2, - ..ReviewRepairBounds::conservative() - }; - let mut lane = loop_with(bounds, ReviewRepairPolicy::default()); - - for i in 1..=2 { - let n = lane.begin_iteration().expect("iteration within ceiling"); - assert_eq!(n, i); - let stop = lane - .record_iteration(receipt(i, "digest-a", IterationVerdict::RepairsNeeded)) - .expect("recorded"); - if i < 2 { - assert!(stop.is_none()); - } else { - assert_eq!( - stop, - Some(StopReason::IterationCeiling { max_iterations: 2 }) - ); - } - } - - let err = lane.begin_iteration().expect_err("loop is done"); - assert!(matches!(err, ReviewRepairError::AlreadyStopped(_))); - assert!(lane.stop_reason().expect("stopped").is_ceiling()); - assert!(!lane.stop_reason().expect("stopped").is_success()); - } - - #[test] - fn zero_iteration_ceiling_never_starts() { - let mut lane = loop_with( - ReviewRepairBounds { - max_iterations: 0, - ..ReviewRepairBounds::conservative() - }, - ReviewRepairPolicy::default(), - ); - let err = lane - .begin_iteration() - .expect_err("a zero ceiling runs nothing"); - assert_eq!( - err.stop_reason(), - Some(&StopReason::IterationCeiling { max_iterations: 0 }) - ); - } - - #[test] - fn tool_and_time_ceilings_stop_the_loop_and_report_the_overrun() { - let mut lane = loop_with( - ReviewRepairBounds { - max_iterations: 5, - max_wall_clock_secs: 600, - max_tool_calls: 4, - }, - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let stop = lane - .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) - .expect("recorded"); - assert_eq!( - stop, - Some(StopReason::ToolCeiling { - max_tool_calls: 4, - used: 5 - }) - ); - assert!(lane.budget_line().contains("tools 5/4")); - - let mut timed = loop_with( - ReviewRepairBounds { - max_iterations: 5, - max_wall_clock_secs: 5, - max_tool_calls: 100, - }, - ReviewRepairPolicy::default(), - ); - timed.begin_iteration().expect("first iteration"); - let stop = timed - .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) - .expect("recorded"); - assert_eq!( - stop, - Some(StopReason::TimeCeiling { - max_wall_clock_secs: 5, - elapsed_secs: 10 - }) - ); - } - - #[test] - fn stale_input_fails_closed_without_repairing() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - - let err = lane - .record_iteration(receipt(1, "digest-b", IterationVerdict::Clean)) - .expect_err("a verdict about another tree is not a verdict about this one"); - - assert_eq!( - err.stop_reason(), - Some(&StopReason::StaleInput { - pinned: "digest-a".to_string(), - reported: "digest-b".to_string(), - }) - ); - assert!(lane.iterations.is_empty(), "stale work is not recorded"); - assert!(!lane.stop_reason().expect("stopped").is_success()); - } - - #[test] - fn clean_verdict_parks_for_human_ratification() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let stop = lane - .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) - .expect("recorded"); - - assert_eq!(stop, Some(StopReason::AwaitingRatification)); - assert!(!lane.stop_reason().expect("stopped").is_success()); - - let after = lane.ratify(true, "").expect("ratify a parked loop"); - assert_eq!(after, &StopReason::Verified); - assert!(lane.stop_reason().expect("stopped").is_success()); - } - - #[test] - fn declined_ratification_is_not_success() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - lane.record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) - .expect("recorded"); - - lane.ratify(false, "diff touches release scripts") - .expect("decline is a valid decision"); - assert_eq!( - lane.stop_reason(), - Some(&StopReason::RatificationDeclined { - note: "diff touches release scripts".to_string() - }) - ); - assert!(!lane.stop_reason().expect("stopped").is_success()); - } - - #[test] - fn a_ceiling_stop_cannot_be_waved_through_by_ratification() { - let mut lane = loop_with( - ReviewRepairBounds { - max_iterations: 1, - ..ReviewRepairBounds::conservative() - }, - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - lane.record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) - .expect("recorded"); - - let err = lane - .ratify(true, "looks fine to me") - .expect_err("there is no clean result to approve"); - assert!(matches!(err, ReviewRepairError::AlreadyStopped(_))); - assert!(!lane.stop_reason().expect("stopped").is_success()); - } - - #[test] - fn without_the_ratification_policy_a_clean_verdict_verifies_directly() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy { - require_human_ratification: false, - require_verifier_receipt: true, - }, - ); - lane.begin_iteration().expect("first iteration"); - let stop = lane - .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) - .expect("recorded"); - assert_eq!(stop, Some(StopReason::Verified)); - } - - #[test] - fn a_missing_verifier_is_inconclusive_not_a_pass() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let mut r = receipt(1, "digest-a", IterationVerdict::Clean); - r.verifier = None; - - let err = lane - .record_iteration(r) - .expect_err("review alone is not verification"); - assert!(matches!( - err.stop_reason(), - Some(StopReason::Inconclusive { .. }) - )); - } - - #[test] - fn an_incomplete_route_is_refused_without_ending_the_loop() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let mut r = receipt(1, "digest-a", IterationVerdict::Clean); - r.reviewer.model = " ".to_string(); - - let err = lane - .record_iteration(r) - .expect_err("a route without a model is a claim"); - assert!(matches!(err, ReviewRepairError::IncompleteReceipt(_))); - assert!( - lane.stop_reason().is_none(), - "the caller may retry with a real receipt" - ); - } - - #[test] - fn inconclusive_review_stops_instead_of_passing() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let stop = lane - .record_iteration(receipt(1, "digest-a", IterationVerdict::Inconclusive)) - .expect("recorded"); - assert!(matches!(stop, Some(StopReason::Inconclusive { .. }))); - } - - #[test] - fn route_lines_show_exact_routes_and_requested_to_effective_reasoning() { - let mut lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - lane.begin_iteration().expect("first iteration"); - let mut r = receipt(1, "digest-a", IterationVerdict::Clean); - r.reviewer.requested_reasoning = "max".to_string(); - r.reviewer.effective_reasoning = "high".to_string(); - r.reviewer.routed_by = RoutedBy::Router { - provider: "moonshot".to_string(), - model: "kimi-k3".to_string(), - }; - lane.record_iteration(r).expect("recorded"); - - let lines = lane.route_lines(); - assert_eq!( - lines.len(), - 2, - "reviewer and verifier both appear: {lines:?}" - ); - assert!(lines[0].contains("reviewer: zhipu/glm-5.2")); - assert!(lines[0].contains("max→high"), "{lines:?}"); - assert!(lines[0].contains("routed by router moonshot/kimi-k3")); - assert!(lines[1].contains("verifier: zhipu/glm-5.2")); - } - - #[test] - fn default_policy_fails_closed() { - let policy = ReviewRepairPolicy::default(); - assert!(policy.require_human_ratification); - assert!(policy.require_verifier_receipt); - let bounds = ReviewRepairBounds::default(); - assert!(bounds.max_iterations > 0); - assert!(bounds.max_wall_clock_secs > 0); - assert!(bounds.max_tool_calls > 0); - } - - #[test] - fn review_repair_defines_no_mode_or_permission_posture() { - // #3832 must not grow a fourth Mode or a fourth posture. This module's - // serialized surface is the check: a loop carries ceilings, routes, and - // verdicts — never a mode, posture, approval policy, or sandbox setting. - let lane = loop_with( - ReviewRepairBounds::conservative(), - ReviewRepairPolicy::default(), - ); - let json = serde_json::to_string(&lane).expect("serialize"); - for forbidden in [ - "mode", - "posture", - "approval_policy", - "sandbox", - "permission", - "auto_review", - "full_access", - ] { - assert!( - !json.contains(forbidden), - "review-repair leaked {forbidden} into its own state: {json}" - ); - } - } -} From b8925b011fc0d5ff85f41d16210bab5363a12207 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 11:39:00 +0800 Subject: [PATCH 02/18] test: restore reserved scaffolding and seams Follow-up to the worthless-test sweep: an audit against open PRs and in-code reservation markers showed these deletions targeted code that is explicitly staged for not-yet-landed work (Phase-2 slices, follow-up wiring, pending ratification UI). Restored from the pre-sweep commit with their tests and wiring: - workflow: route authority catalog, CatalogPolicy chain, require_all_ready, verify_snapshot_content_hash, gates handoff persistence, review_repair module, resolve_bounded_path (#3832/#5766/Phase-1 markers); model_policy module (staged model-execution layer; carries no in-code reservation marker - restored on author's pending-work knowledge and disclosed here rather than marker-verified), - core/protocol: session/journal placeholder modules (#5261/#5262), OpEnvelope (Phase A1 extraction spec), - tui: tideline inbox scaffold (#5698), tideline_stream_hitboxes, ThreeZoneRequest and TurnScratch (Phase-1 scaffolding), TranscriptCache::clear, TokenEstimateCache audit ring (/status follow-up), rebind-hint cluster (#128), WhaleState::for_shell_phase, resource_telemetry PressureLevel half, external-import consent filter (candidates_needing_consent), prefix-cache observability methods, verify critic_effort seam, ExecutionEnvelope::narrow (ratification UI), WorkshopVariables::take_raw (promote_to_context follow-up), ActiveCell::revision (future cache wiring), session_tree branch_summary/compaction appenders (#5262 deferred strategies), system_prompt_for_mode_with_context wrapper (open PR #90 calls it), merge_contexts (monorepo context merging API). Signed-off-by: asto Signed-off-by: asto18089 --- crates/cli/src/config_bundles.rs | 66 ++ crates/config/src/catalog.rs | 19 +- crates/config/src/catalog/tests.rs | 51 + crates/config/src/route/auth.rs | 46 + crates/config/src/route/authority.rs | 309 ++++++ crates/config/src/route/mod.rs | 6 + crates/config/src/route/policy.rs | 160 ++++ crates/core/src/journal.rs | 78 ++ crates/core/src/lib.rs | 2 + crates/core/src/session.rs | 137 +++ crates/protocol/src/op.rs | 84 +- .../src/core/engine/token_estimate_cache.rs | 176 +++- crates/tui/src/mcp/external_import.rs | 50 + crates/tui/src/prefix_cache.rs | 94 +- crates/tui/src/project_context/types.rs | 36 + crates/tui/src/prompt_zones.rs | 261 ++++- crates/tui/src/prompts.rs | 88 +- crates/tui/src/resource_telemetry.rs | 416 +++++++- crates/tui/src/runtime_threads.rs | 59 ++ crates/tui/src/runtime_threads/tests.rs | 92 ++ crates/tui/src/session_tree.rs | 45 + crates/tui/src/tools/execution_envelope.rs | 34 + crates/tui/src/tools/large_output_router.rs | 28 +- crates/tui/src/tools/verify.rs | 19 + crates/tui/src/tui/active_cell.rs | 15 +- crates/tui/src/tui/app/tests.rs | 10 + .../src/tui/goldens/notifications_100x30.txt | 30 + .../src/tui/goldens/notifications_120x32.txt | 32 + .../src/tui/goldens/notifications_160x40.txt | 40 + .../src/tui/goldens/notifications_80x24.txt | 24 + crates/tui/src/tui/history.rs | 1 + crates/tui/src/tui/history/tideline_stream.rs | 34 + .../src/tui/history/tideline_stream_tests.rs | 12 +- crates/tui/src/tui/notifications.rs | 262 ++++++ .../src/tui/notifications/tideline_tests.rs | 181 ++++ crates/tui/src/tui/transcript_cache.rs | 17 + crates/tui/src/tui/whales.rs | 55 +- .../src/tui/work_surface/tideline/tests.rs | 14 + crates/workflow/src/fleet_preflight.rs | 16 +- crates/workflow/src/fleet_snapshot.rs | 13 + crates/workflow/src/gates.rs | 65 ++ crates/workflow/src/lib.rs | 9 +- crates/workflow/src/model_policy.rs | 654 +++++++++++++ crates/workflow/src/review_repair.rs | 890 ++++++++++++++++++ 44 files changed, 4592 insertions(+), 138 deletions(-) create mode 100644 crates/config/src/route/authority.rs create mode 100644 crates/config/src/route/policy.rs create mode 100644 crates/core/src/journal.rs create mode 100644 crates/core/src/session.rs create mode 100644 crates/tui/src/tui/goldens/notifications_100x30.txt create mode 100644 crates/tui/src/tui/goldens/notifications_120x32.txt create mode 100644 crates/tui/src/tui/goldens/notifications_160x40.txt create mode 100644 crates/tui/src/tui/goldens/notifications_80x24.txt create mode 100644 crates/tui/src/tui/notifications/tideline_tests.rs create mode 100644 crates/workflow/src/model_policy.rs create mode 100644 crates/workflow/src/review_repair.rs diff --git a/crates/cli/src/config_bundles.rs b/crates/cli/src/config_bundles.rs index b4e3131f3b..544d3b6d09 100644 --- a/crates/cli/src/config_bundles.rs +++ b/crates/cli/src/config_bundles.rs @@ -677,6 +677,55 @@ fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink +/// escapes. Returns the resolved path or an error naming the refusal — the +/// candidate string itself is safe to echo (it is config data, not a secret). +/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink +/// escapes. Returns the joined path or an error naming the refusal. +/// Reserved for path-carrying bundle sections (none shipped yet); exercised +/// by the traversal tests so the contract cannot silently rot. +#[cfg_attr( + not(test), + expect(dead_code, reason = "path-carrying sections land with the next schema") +)] +pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result { + if candidate.contains('\0') { + bail!("bundle path contains a NUL byte; refused"); + } + let candidate_path = Path::new(candidate); + if candidate_path.is_absolute() { + bail!( + "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted" + ); + } + let canonical_base = base_dir + .canonicalize() + .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?; + let joined = base_dir.join(candidate_path); + // Walk the joined path's ancestors from the deepest existing component up: + // every existing component must canonicalize inside the base, so a symlink + // pointing outside the config directory is refused even when the final + // target does not exist yet. + let deepest_existing = joined + .ancestors() + .find(|ancestor| ancestor.symlink_metadata().is_ok()) + .context("bundle path has no existing ancestor inside the config directory")?; + let resolved = deepest_existing.canonicalize().with_context(|| { + format!( + "could not resolve bundle path component {}", + deepest_existing.display() + ) + })?; + if !resolved.starts_with(&canonical_base) { + bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused"); + } + Ok(joined) +} + // --------------------------------------------------------------------------- // Remote fetch // --------------------------------------------------------------------------- @@ -3381,6 +3430,23 @@ label = "safe-nested-workspace-label" require_import_consent(true, &plan).expect("--yes short-circuits consent"); } + #[test] + fn bounded_paths_refuse_traversal_and_absolute_escapes() { + let base = tempfile::tempdir().expect("base"); + let err = + resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused"); + assert!( + err.to_string().contains("escapes") || err.to_string().contains("absolute"), + "{err:#}" + ); + let absolute = base.path().join("absolute.toml"); + let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref()) + .expect_err("absolute refused"); + assert!(err.to_string().contains("absolute"), "{err:#}"); + let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok"); + assert!(ok.starts_with(base.path())); + } + // -- helpers ------------------------------------------------------------ fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec { diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 2c676fe3f1..0f6ea076a5 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -628,7 +628,8 @@ impl CatalogSnapshot { /// Builds a [`CatalogSnapshot`] by merging layers in precedence order. /// -/// Last writer wins per `(provider, wire id)` field: +/// Last writer wins per `(provider, wire id)` field. Policy DENY is applied +/// after every layer and is never overridden: /// /// ```text /// 0 bundled committed models.dev-shaped snapshot @@ -638,6 +639,7 @@ impl CatalogSnapshot { /// 25 codewhale live signed CWC catalog (authority) /// 30 config config.toml [providers.*] overrides /// 40 user user approved set +/// policy DENY last, never overridden /// ``` /// /// [`Self::with_live`] remains the combined live bucket so existing callers @@ -653,6 +655,7 @@ pub struct CatalogCompiler { codewhale_live: Vec, config: Vec, overrides: Vec, + policy: crate::route::CatalogPolicy, } impl CatalogCompiler { @@ -730,7 +733,14 @@ impl CatalogCompiler { self } - /// Merge all layers into a deterministic snapshot. + /// Attach policy evaluated after every layer. DENY is never overridden. + #[must_use] + pub fn with_policy(mut self, policy: crate::route::CatalogPolicy) -> Self { + self.policy = policy; + self + } + + /// Merge all layers into a deterministic snapshot, then apply policy DENY. #[must_use] pub fn compile(self) -> CatalogSnapshot { let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new(); @@ -747,7 +757,10 @@ impl CatalogCompiler { { merged.insert(row.merge_key(), row); } - let offerings = merged.into_values().collect(); + let offerings = merged + .into_values() + .filter(|row| self.policy.allows(&row.provider, &row.wire_model_id)) + .collect(); CatalogSnapshot { offerings } } } diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 5d1636fff0..ff556e2164 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -204,6 +204,57 @@ fn compiler_merges_layers_with_override_precedence() { assert!(matches!(two.source, CatalogSource::Live { .. })); } +#[test] +fn compiler_layer_order_and_policy_deny_never_overridden() { + let row = |source: CatalogSource, model: &str, family: &str| CatalogOffering { + provider: "zai-coding-cn".into(), + wire_model_id: model.into(), + endpoint_key: "chat".into(), + family: Some(family.into()), + source, + ..Default::default() + }; + let policy = crate::route::CatalogPolicy { + rules: vec![crate::route::PolicyRule { + effect: crate::route::PolicyEffect::Deny, + action: crate::route::PolicyAction::ModelUse, + resource: "*-cn/*".to_string(), + }], + }; + let snapshot = CatalogCompiler::new() + .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")]) + .with_models_dev_live(vec![row( + CatalogSource::ModelsDevLive { fetched_at: 1 }, + "glm-5", + "models-dev", + )]) + .with_provider_live(vec![row( + CatalogSource::Live { + base_url_fingerprint: "fp".into(), + fetched_at: 2, + }, + "glm-5", + "provider", + )]) + .with_config(vec![row(CatalogSource::ConfigOverride, "glm-5", "config")]) + .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")]) + .with_policy(policy) + .compile(); + + assert!( + snapshot.offerings.is_empty(), + "policy DENY after every layer must drop the row; layers cannot override it" + ); + + let allowed = CatalogCompiler::new() + .with_bundled(vec![row(CatalogSource::Bundled, "glm-5", "bundled")]) + .with_overrides(vec![row(CatalogSource::UserOverride, "glm-5", "user")]) + .compile(); + let kept = find(&allowed.offerings, "zai-coding-cn", "glm-5"); + assert_eq!(kept.source, CatalogSource::UserOverride); + assert_eq!(kept.family.as_deref(), Some("user")); +} + #[test] fn cache_scopes_by_provider_and_base_url_fingerprint() { let fp_a = base_url_fingerprint("https://api.example.com/v1"); diff --git a/crates/config/src/route/auth.rs b/crates/config/src/route/auth.rs index f0a9ce77f0..41d65842ce 100644 --- a/crates/config/src/route/auth.rs +++ b/crates/config/src/route/auth.rs @@ -124,6 +124,27 @@ impl AuthMethod { label: "External consent", prompts: &[], }; + + /// Whether `answers` satisfy this prompt's `when` clause. + #[must_use] + pub fn prompt_visible( + prompt: &Prompt, + answers: &std::collections::BTreeMap, + ) -> bool { + let when = match prompt { + Prompt::Text { when, .. } | Prompt::Select { when, .. } => *when, + }; + match when { + None => true, + Some(When { key, op, value }) => { + let actual = answers.get(key).map(String::as_str).unwrap_or(""); + match op { + Op::Eq => actual == value, + Op::Neq => actual != value, + } + } + } + } } /// Owned, serializable projection of [`AuthMethod`] for `providers export`. @@ -262,3 +283,28 @@ impl From for ChoiceExport { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn when_eq_hides_unmatched_prompt() { + let prompt = Prompt::Text { + key: "key", + message: "Key", + placeholder: None, + secret: true, + when: Some(When { + key: "plan", + op: Op::Eq, + value: "coding", + }), + }; + let mut answers = std::collections::BTreeMap::new(); + answers.insert("plan".into(), "token".into()); + assert!(!AuthMethod::prompt_visible(&prompt, &answers)); + answers.insert("plan".into(), "coding".into()); + assert!(AuthMethod::prompt_visible(&prompt, &answers)); + } +} diff --git a/crates/config/src/route/authority.rs b/crates/config/src/route/authority.rs new file mode 100644 index 0000000000..f23171188e --- /dev/null +++ b/crates/config/src/route/authority.rs @@ -0,0 +1,309 @@ +//! One immutable authority for a compiled provider catalog and its route resolver. +//! +//! Before this seam, callers could independently project a +//! [`crate::catalog::CatalogSnapshot`] through +//! [`crate::catalog::CatalogSnapshot::to_offerings`] and create a +//! [`super::RouteResolver`]. That made it possible for a picker, readiness +//! view, and execution path to use different catalog snapshots without any +//! type-level signal. `RouteAuthoritySnapshot` replaces that ad-hoc pairing for +//! new consumers: it owns the exact compiled catalog and the resolver projected +//! from that catalog together. +//! +//! It deliberately does not claim that a catalog row is runnable. Calling +//! [`RouteAuthoritySnapshot::resolve`] still mints a +//! [`super::ReadyRouteCandidate`] through the sole resolver. The returned +//! receipt distinguishes an exact catalog row, a custom-endpoint route whose +//! provider facts are intentionally not reused, and an allowed pass-through +//! route with no catalog row. All state is secret-free. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogStatus}; + +use super::{ + ProviderId, ReadyRouteCandidate, RouteError, RouteRequest, RouteResolver, WireModelId, +}; + +/// A secret-free provider catalog cache scope. +/// +/// The base URL is represented only by its already-redacted fingerprint. The +/// provider remains an open catalog string because a catalog can include a +/// discoverable provider that is not yet a built-in [`crate::ProviderKind`]. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RouteCatalogScope { + provider: String, + base_url_fingerprint: String, +} + +impl RouteCatalogScope { + /// Construct a normalized, secret-free cache scope. + #[must_use] + pub fn new(provider: impl Into, base_url_fingerprint: impl Into) -> Self { + Self { + provider: provider.into().trim().to_string(), + base_url_fingerprint: base_url_fingerprint.into().trim().to_string(), + } + } + + /// Provider id associated with this cache scope. + #[must_use] + pub fn provider(&self) -> &str { + &self.provider + } + + /// Secret-free base URL fingerprint associated with this cache scope. + #[must_use] + pub fn base_url_fingerprint(&self) -> &str { + &self.base_url_fingerprint + } +} + +/// Provenance receipt for a route resolved from a [`RouteAuthoritySnapshot`]. +/// +/// A catalog source describes metadata provenance, not account authorization or +/// endpoint health. Consumers must keep readiness/auth checks separate. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CatalogOfferingReceipt { + /// The resolved provider/wire pair exactly matches an offering in the + /// compiled catalog owned by this authority snapshot. + Catalog { offering: Box }, + /// The caller selected an explicit base URL. The resolver deliberately + /// clears catalog-owned capabilities and pricing for that custom endpoint, + /// so it must not be presented as an exact catalog offering. + CustomEndpoint { + provider: ProviderId, + wire_model_id: WireModelId, + }, + /// The resolver permitted an unknown/pass-through wire model, but the + /// compiled catalog did not assert facts for that provider/wire pair. + NotCataloged { + provider: ProviderId, + wire_model_id: WireModelId, + }, +} + +impl CatalogOfferingReceipt { + /// Return the exact catalog offering, if this route was catalog-backed. + #[must_use] + pub fn offering(&self) -> Option<&CatalogOffering> { + match self { + Self::Catalog { offering } => Some(offering), + Self::CustomEndpoint { .. } | Self::NotCataloged { .. } => None, + } + } +} + +/// One route-resolution result plus honest catalog provenance. +#[derive(Debug, Clone)] +pub struct AuthorityResolution { + candidate: ReadyRouteCandidate, + receipt: CatalogOfferingReceipt, +} + +impl AuthorityResolution { + /// The executable candidate minted by the snapshot's resolver. + #[must_use] + pub fn candidate(&self) -> &ReadyRouteCandidate { + &self.candidate + } + + /// Honest catalog provenance for the candidate's provider/wire pair. + #[must_use] + pub fn receipt(&self) -> &CatalogOfferingReceipt { + &self.receipt + } +} + +/// A compiled catalog and the resolver derived from that exact catalog. +/// +/// The private fields prevent a consumer from retaining the snapshot while +/// silently replacing its resolver. Refreshers build a new immutable snapshot +/// when their catalog changes; existing consumers keep their coherent view. +#[derive(Debug, Clone)] +pub struct RouteAuthoritySnapshot { + catalog: CatalogSnapshot, + resolver: RouteResolver, + scope_statuses: BTreeMap, +} + +impl RouteAuthoritySnapshot { + /// Bind a compiled catalog to the resolver that consumes its offerings. + #[must_use] + pub fn new(catalog: CatalogSnapshot) -> Self { + let resolver = RouteResolver::from_offerings(catalog.to_offerings()); + Self { + catalog, + resolver, + scope_statuses: BTreeMap::new(), + } + } + + /// Record the cache health known for one provider/base-URL scope. + /// + /// An omitted status stays [`CatalogStatus::Unknown`]; this API never + /// derives freshness from a model row or invents a successful refresh. + #[must_use] + pub fn with_scope_status(mut self, scope: RouteCatalogScope, status: CatalogStatus) -> Self { + self.scope_statuses.insert(scope, status); + self + } + + /// The exact compiled catalog bound to this resolver. + #[must_use] + pub fn catalog(&self) -> &CatalogSnapshot { + &self.catalog + } + + /// Cache health for one provider/base-URL scope, or honestly unknown. + #[must_use] + pub fn scope_status(&self, scope: &RouteCatalogScope) -> CatalogStatus { + self.scope_statuses + .get(scope) + .cloned() + .unwrap_or(CatalogStatus::Unknown) + } + + /// Resolve through the authority-bound resolver and retain catalog receipt. + /// + /// # Errors + /// Returns the route resolver's validation error when a request cannot + /// produce an executable candidate. + pub fn resolve(&self, request: &RouteRequest) -> Result { + let candidate = self.resolver.resolve(request)?; + let receipt = if request.base_url_override.is_some() { + CatalogOfferingReceipt::CustomEndpoint { + provider: candidate.provider_id().clone(), + wire_model_id: candidate.wire_model_id().clone(), + } + } else if let Some(offering) = self.catalog.offerings.iter().find(|offering| { + offering.provider == candidate.provider_id().as_str() + && offering.wire_model_id == candidate.wire_model_id().as_str() + }) { + CatalogOfferingReceipt::Catalog { + offering: Box::new(offering.clone()), + } + } else { + CatalogOfferingReceipt::NotCataloged { + provider: candidate.provider_id().clone(), + wire_model_id: candidate.wire_model_id().clone(), + } + }; + + Ok(AuthorityResolution { candidate, receipt }) + } +} + +#[cfg(test)] +mod tests { + use crate::ProviderKind; + use crate::catalog::{CatalogCompiler, CatalogSource}; + use crate::models_dev::ModelsDevLimit; + use crate::route::{LogicalModelRef, RouteRequest}; + + use super::{CatalogOfferingReceipt, RouteAuthoritySnapshot, RouteCatalogScope}; + + fn request(model: &str) -> RouteRequest { + RouteRequest { + explicit_provider: Some(ProviderKind::Deepseek), + model_selector: Some(LogicalModelRef::from(model)), + saved_provider_model: None, + base_url_override: None, + limit_overrides: Vec::new(), + wire_override: None, + } + } + + fn offering(source: CatalogSource, context: u64) -> crate::catalog::CatalogOffering { + crate::catalog::CatalogOffering { + provider: "deepseek".to_string(), + wire_model_id: "deepseek-v4-flash-vision-exp".to_string(), + endpoint_key: "responses".to_string(), + default_for_provider: true, + limit: Some(ModelsDevLimit { + context: Some(context), + ..Default::default() + }), + source, + ..Default::default() + } + } + + #[test] + fn one_compiled_snapshot_drives_candidate_and_catalog_receipt() { + let snapshot = CatalogCompiler::new() + .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) + .with_config(vec![offering(CatalogSource::ConfigOverride, 64_000)]) + .compile(); + let authority = RouteAuthoritySnapshot::new(snapshot); + + let resolved = authority + .resolve(&request("deepseek-v4-flash-vision-exp")) + .expect("catalog-backed direct route resolves"); + + assert_eq!(resolved.candidate().limits().context_tokens, Some(64_000)); + let CatalogOfferingReceipt::Catalog { offering } = resolved.receipt() else { + panic!("compiled catalog row must be retained as the receipt"); + }; + assert_eq!(offering.source, CatalogSource::ConfigOverride); + assert_eq!( + offering.limit.as_ref().and_then(|limit| limit.context), + Some(64_000) + ); + } + + #[test] + fn custom_endpoint_never_claims_catalog_offering_facts() { + let snapshot = CatalogCompiler::new() + .with_bundled(vec![offering(CatalogSource::Bundled, 32_000)]) + .compile(); + let authority = RouteAuthoritySnapshot::new(snapshot); + let mut request = request("deepseek-v4-flash-vision-exp"); + request.base_url_override = Some("https://compatible.example/v1".to_string()); + + let resolved = authority + .resolve(&request) + .expect("custom compatible endpoint still resolves"); + + assert!(matches!( + resolved.receipt(), + CatalogOfferingReceipt::CustomEndpoint { .. } + )); + assert!(resolved.receipt().offering().is_none()); + } + + #[test] + fn direct_provider_pass_through_is_explicitly_not_cataloged() { + let authority = RouteAuthoritySnapshot::new(CatalogCompiler::new().compile()); + + let resolved = authority + .resolve(&request("future-deepseek-model")) + .expect("direct-provider pass-through remains executable"); + + assert!(matches!( + resolved.receipt(), + CatalogOfferingReceipt::NotCataloged { .. } + )); + assert!(resolved.receipt().offering().is_none()); + } + + #[test] + fn scope_status_is_explicit_and_defaults_to_unknown() { + let snapshot = CatalogCompiler::new().compile(); + let fresh_scope = RouteCatalogScope::new("deepseek", "fingerprint-a"); + let absent_scope = RouteCatalogScope::new("deepseek", "fingerprint-b"); + let authority = RouteAuthoritySnapshot::new(snapshot) + .with_scope_status(fresh_scope.clone(), crate::catalog::CatalogStatus::Fresh); + + assert_eq!( + authority.scope_status(&fresh_scope), + crate::catalog::CatalogStatus::Fresh + ); + assert_eq!( + authority.scope_status(&absent_scope), + crate::catalog::CatalogStatus::Unknown + ); + } +} diff --git a/crates/config/src/route/mod.rs b/crates/config/src/route/mod.rs index 9a66471381..b4a5708269 100644 --- a/crates/config/src/route/mod.rs +++ b/crates/config/src/route/mod.rs @@ -25,6 +25,7 @@ pub use crate::provider::WireFormat as RequestProtocol; pub mod auth; +pub mod authority; pub mod candidate; pub mod capabilities; pub mod descriptor; @@ -32,12 +33,16 @@ pub mod errors; pub mod export; pub mod ids; pub mod offering; +pub mod policy; pub mod resolver; pub use auth::{ AuthKind, AuthMethod, AuthMethodExport, Choice, ChoiceExport, Op, Prompt, PromptExport, When, WhenExport, }; +pub use authority::{ + AuthorityResolution, CatalogOfferingReceipt, RouteAuthoritySnapshot, RouteCatalogScope, +}; pub use candidate::{ LimitField, OverrideSource, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, SourcedLimitOverride, ValidationReport, @@ -56,6 +61,7 @@ pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, RouteId, Wire pub use offering::{ ProviderModelOffering, RouteLimits, bundled_offerings, opencode_zen_picker_models, }; +pub use policy::{CatalogPolicy, PolicyAction, PolicyEffect, PolicyRule}; pub use resolver::{RouteRequest, RouteResolver}; #[cfg(test)] diff --git a/crates/config/src/route/policy.rs b/crates/config/src/route/policy.rs new file mode 100644 index 0000000000..da9285353d --- /dev/null +++ b/crates/config/src/route/policy.rs @@ -0,0 +1,160 @@ +//! Catalog policy evaluated after every catalog layer (Phase 1 hook). +//! +//! Policy decides whether an allowed offering may be used. It never configures +//! endpoints or credentials, and it never makes an unusable route usable. +//! `DENY` is applied last and is never overridden by a catalog layer. + +use serde::{Deserialize, Serialize}; + +/// Effect of one policy rule. Last match wins. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PolicyEffect { + /// Permit the matched resource. + Allow, + /// Forbid the matched resource. Never overridden by a later catalog layer. + Deny, +} + +/// Action a policy rule addresses. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PolicyAction { + /// Using a model on a route (`/`). + ModelUse, + /// Using a route at all (``). + ProviderUse, +} + +/// One wildcard-matched policy rule. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PolicyRule { + /// Allow or deny. + pub effect: PolicyEffect, + /// Action being gated. + pub action: PolicyAction, + /// Resource glob: `"/"` or `""`. + pub resource: String, +} + +/// Ordered policy document. Empty means allow-all. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CatalogPolicy { + /// Rules in file order; last match wins. + #[serde(default)] + pub rules: Vec, +} + +impl CatalogPolicy { + /// Empty policy (allow everything). + #[must_use] + pub fn allow_all() -> Self { + Self::default() + } + + /// Whether `route_id` / `model_id` survives policy. + /// + /// Default is allow. Each matching rule overwrites the decision; the last + /// match wins. A final DENY cannot be undone by a catalog layer because + /// callers apply this *after* merge. + #[must_use] + pub fn allows(&self, route_id: &str, model_id: &str) -> bool { + let mut allowed = true; + for rule in &self.rules { + if rule.matches(route_id, model_id) { + allowed = rule.effect == PolicyEffect::Allow; + } + } + allowed + } +} + +impl PolicyRule { + fn matches(&self, route_id: &str, model_id: &str) -> bool { + let resource = match self.action { + PolicyAction::ProviderUse => route_id.to_string(), + PolicyAction::ModelUse => format!("{route_id}/{model_id}"), + }; + wildcard_match(&self.resource, &resource) + } +} + +/// Single-`*` wildcard match. `*` does not cross `/`. +fn wildcard_match(pattern: &str, value: &str) -> bool { + wildcard_match_parts(pattern.as_bytes(), value.as_bytes()) +} + +fn wildcard_match_parts(pattern: &[u8], value: &[u8]) -> bool { + let mut p = 0; + let mut v = 0; + let mut star = None; + while v < value.len() { + if p < pattern.len() && pattern[p] == b'*' { + star = Some((p, v)); + p += 1; + continue; + } + if p < pattern.len() && pattern[p] == value[v] { + p += 1; + v += 1; + continue; + } + if let Some((star_p, star_v)) = star { + if value[star_v] == b'/' { + return false; + } + v = star_v + 1; + p = star_p + 1; + star = Some((star_p, v)); + continue; + } + return false; + } + while p < pattern.len() && pattern[p] == b'*' { + p += 1; + } + p == pattern.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deny_survives_every_layer_order() { + let policy = CatalogPolicy { + rules: vec![PolicyRule { + effect: PolicyEffect::Deny, + action: PolicyAction::ModelUse, + resource: "*-cn/*".to_string(), + }], + }; + assert!(!policy.allows("zai-coding-cn", "glm-5")); + assert!(policy.allows("zai", "glm-5")); + assert!(policy.allows("deepseek", "deepseek-v4-pro")); + } + + #[test] + fn last_match_wins() { + let policy = CatalogPolicy { + rules: vec![ + PolicyRule { + effect: PolicyEffect::Deny, + action: PolicyAction::ProviderUse, + resource: "deepseek".to_string(), + }, + PolicyRule { + effect: PolicyEffect::Allow, + action: PolicyAction::ProviderUse, + resource: "deepseek".to_string(), + }, + ], + }; + assert!(policy.allows("deepseek", "any")); + } + + #[test] + fn empty_policy_allows() { + assert!(CatalogPolicy::allow_all().allows("anything", "model")); + } +} diff --git a/crates/core/src/journal.rs b/crates/core/src/journal.rs new file mode 100644 index 0000000000..5302e83824 --- /dev/null +++ b/crates/core/src/journal.rs @@ -0,0 +1,78 @@ +//! Session tree journal placeholder (issue #5262). +//! +//! The journal is append-only with an in-memory tree projection: +//! every non-header entry carries `id` + `parentId`, the active position is +//! a `leafId`, appending creates a child of the leaf, and branching only +//! moves the leaf — it never rewrites history. This file lands the entry +//! shape that #5262's tree operations hang off of; compaction and +//! branch-summary entry kinds are included as first-class kinds but their +//! *strategies* are deferred. +//! +//! Re-exports the protocol journal as the canonical shape so `protocol` and +//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that +//! owns the `current_leaf_id` column in `state.threads`. + +pub use codewhale_protocol::journal::{Journal, JournalEntry}; + +use serde::{Deserialize, Serialize}; + +/// Persisted thread metadata extension for the tree. This is the +/// `current_leaf_id` column added to `state.threads`; `None` before the +/// first turn, `Some(id)` after. The existing `threads` JSON shape is +/// otherwise unchanged (back-compat: old rows read as `None` and the next +/// append mints the header leaf). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ThreadLeafState { + pub thread_id: String, + pub leaf_id: Option, +} + +/// First-class journal entry kinds (data shape lands now; strategies later). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JournalKind { + Header, + User, + Assistant, + ToolResult, + Compaction, + BranchSummary, +} + +impl JournalKind { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Header => "header", + Self::User => "user", + Self::Assistant => "assistant", + Self::ToolResult => "tool_result", + Self::Compaction => "compaction", + Self::BranchSummary => "branch_summary", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn leaf_state_roundtrip() { + let s = ThreadLeafState { + thread_id: "thread-1".into(), + leaf_id: Some("entry-abc".into()), + }; + let j = serde_json::to_string(&s).unwrap(); + let back: ThreadLeafState = serde_json::from_str(&j).unwrap(); + assert_eq!(back, s); + } + + #[test] + fn journal_append_is_child_of_leaf() { + let mut j = Journal::new(); + let a = j.append("header", json!({})); + let b = j.append("user", json!("hi")); + assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str())); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index c7b3e75440..977d575432 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,7 +1,9 @@ pub mod fragments; pub mod ids; +pub mod journal; pub mod request; pub mod role; +pub mod session; pub mod tool_parser; use std::collections::{HashMap, HashSet}; diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs new file mode 100644 index 0000000000..e22e2c6007 --- /dev/null +++ b/crates/core/src/session.rs @@ -0,0 +1,137 @@ +//! `Thread` / `Session` split (issue #5261). +//! +//! `codewhale`'s `Session` was really a thread. The new split is: +//! - `Thread` — durable, persisted, owns the append-only `Journal` and the +//! `leafId` cursor. One row in `state.threads`, one directory on disk. +//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the +//! in-memory `TurnContext` plus the live approval/sandbox posture for this +//! `SessionId`. Many sessions can attach to one thread over time, but only +//! one `Session` drives a turn for a given `ThreadId` at a time. +//! +//! The thread manager (`ThreadManager` in `crate::lib`) already can start a +//! session with no TUI attached (`spawn_thread_with_history`); this file +//! formalizes the types that make that first-class and moves the former +//! `crates/tui/src/core/session.rs` state (model, reasoning_effort, +//! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`, +//! `messages_revision`) into `crates/core` so both TUI and headless share it. + +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::ids::{SessionId, ThreadId}; +use crate::journal::Journal; + +/// Durable thread (the former `Session`). One per conversation, persisted in +/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the +/// journal cursor — plus the typed `ThreadId`. All other fields keep their +/// persisted JSON shape unchanged. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Thread { + pub thread_id: ThreadId, + /// Active branch tip. `None` before the first journal header. + #[serde(skip_serializing_if = "Option::is_none")] + pub leaf_id: Option, + /// Journal (append-only). In-memory projection of the persisted + /// `threads/turns/items/events` layout is derived root→leaf. + #[serde(default)] + pub journal: Journal, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + pub workspace: PathBuf, + #[serde(default)] + pub ephemeral: bool, +} + +impl Thread { + #[must_use] + pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into) -> Self { + Self { + thread_id, + leaf_id: None, + journal: Journal::new(), + model: model.into(), + reasoning_effort: None, + workspace, + ephemeral: false, + } + } + + #[must_use] + pub fn leaf_id(&self) -> Option<&str> { + self.leaf_id.as_deref() + } + + pub fn set_leaf(&mut self, leaf: Option) { + self.leaf_id = leaf; + } +} + +/// Ephemeral session within a thread (one engine lifetime / one turn's +/// live posture). The TUI's `EngineHandle` and the headless `exec` both +/// hold a `Session` that points at the same `ThreadId` but with different +/// `SessionId`s. +#[derive(Debug, Clone)] +pub struct Session { + pub session_id: SessionId, + pub thread_id: ThreadId, + /// Model for this session's next turn (may differ from thread default). + pub model: String, + pub workspace: PathBuf, + /// Monotonic `messages_revision` for prefix-cache memoization (carried + /// from the former `Session::messages_revision`). + pub messages_revision: u64, +} + +impl Session { + #[must_use] + pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into) -> Self { + Self { + session_id: SessionId::new(), + thread_id, + model: model.into(), + workspace, + messages_revision: 0, + } + } + + pub fn bump_revision(&mut self) { + self.messages_revision = self.messages_revision.wrapping_add(1); + } +} + +/// Split helper: derive a `Session` from an existing `Thread` without +/// cloning the journal. Headless and TUI call the same constructor so +/// the request shape stays identical. +#[must_use] +pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session { + Session::new(thread.thread_id.clone(), workspace, thread.model.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn thread_and_session_ids_are_distinct_scopes() { + let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash"); + let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); + let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model); + assert_eq!(s1.thread_id, s2.thread_id); + assert_ne!(s1.session_id, s2.session_id); + } + + #[test] + fn leaf_is_moved_not_rewritten() { + let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m"); + let a = t.journal.append("header", serde_json::json!({})); + let b = t.journal.append("user", serde_json::json!("b")); + t.leaf_id = t.journal.leaf_id.clone(); + assert_eq!(t.leaf_id.as_deref(), Some(b.as_str())); + assert!(t.journal.branch_to(&a)); + t.leaf_id = t.journal.leaf_id.clone(); + assert_eq!(t.leaf_id.as_deref(), Some(a.as_str())); + assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf + } +} diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index e40477cad1..ec323c82b7 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -4,9 +4,9 @@ //! The TUI engine already had an internal channel (`Op` in //! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`). //! This protocol file formalizes that channel so TUI, CLI, app-server, and -//! tests share one serializable API. The wire is the `Op` enum; -//! transports that already speak JSON (app-server, tests) can send it -//! directly, while in-process callers continue to use the typed +//! tests share one serializable API. The wire is `OpEnvelope` + `Op`; +//! transports that already speak JSON (app-server, tests) can send the +//! envelope directly, while in-process callers continue to use the typed //! enum. //! //! Parity is compile-enforced from the engine side: @@ -51,8 +51,20 @@ where Ok(None) } +use crate::ids::{SessionId, ThreadId}; use crate::runtime::DynamicToolSpec; +/// Every `Op` is paired with the ids that route it. This is the +/// `Op`-in half of the `Op`-in / `EventMsg`-out contract. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpEnvelope { + /// Monotonic `op:` for dedup / tracing within a session. + pub op_id: String, + pub thread_id: ThreadId, + pub session_id: SessionId, + pub op: Op, +} + /// Token-limit facts for a route. `None` is unknown, never zero. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RouteLimits { @@ -483,6 +495,40 @@ impl Op { } } +/// Build a headless `SendMessage` envelope with fresh ids. This is the +/// one-line helper every headless caller (CLI `exec`, app-server, tests) +/// uses so TUI and headless start a session identically. +#[must_use] +pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into) -> OpEnvelope { + OpEnvelope { + op_id: format!("op-{}", uuid::Uuid::new_v4()), + thread_id: thread_id.clone(), + session_id: SessionId::new(), + op: Op::SendMessage { + content: content.into(), + mode: default_mode(), + model: None, + model_provider: None, + allowed_tools: None, + dynamic_tools: Vec::new(), + provenance: default_provenance(), + compaction: None, + goal_objective: None, + goal_token_budget: None, + goal_status: default_goal_status(), + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: default_approval_mode(), + translation_enabled: false, + verbosity: None, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -505,28 +551,7 @@ mod tests { /// One instance of every variant, in declaration order. fn every_variant() -> Vec { vec![ - Op::SendMessage { - content: "hello".into(), - mode: default_mode(), - model: None, - model_provider: None, - allowed_tools: None, - dynamic_tools: Vec::new(), - provenance: default_provenance(), - compaction: None, - goal_objective: None, - goal_token_budget: None, - goal_status: default_goal_status(), - reasoning_effort: None, - reasoning_effort_auto: false, - auto_model: false, - allow_shell: false, - trust_mode: false, - auto_approve: false, - approval_mode: default_approval_mode(), - translation_enabled: false, - verbosity: None, - }, + headless_send_message_op(ThreadId::new(), "hello").op, Op::Steer { content: "more".into(), }, @@ -692,6 +717,15 @@ mod tests { } } + #[test] + fn op_envelope_roundtrip() { + let env = headless_send_message_op(ThreadId::new(), "hello"); + let json = serde_json::to_string(&env).unwrap(); + let back: OpEnvelope = serde_json::from_str(&json).unwrap(); + assert_eq!(back.thread_id, env.thread_id); + assert!(back.op.is_send_message()); + } + #[test] fn minimal_send_message_json_still_parses_with_defaults() { // The pre-A1 wire shape: only `content` plus the tag. diff --git a/crates/tui/src/core/engine/token_estimate_cache.rs b/crates/tui/src/core/engine/token_estimate_cache.rs index addef4f56b..50402376c3 100644 --- a/crates/tui/src/core/engine/token_estimate_cache.rs +++ b/crates/tui/src/core/engine/token_estimate_cache.rs @@ -23,12 +23,18 @@ use std::hash::{Hash, Hasher}; use crate::compaction::estimate_input_tokens_conservative; use crate::models::{Message, SystemPrompt}; +/// Default capacity for the rolling audit ring. Sized so a 64-entry window +/// covers a full capacity controller observation cycle without unbounded +/// growth on long-running sessions. +const AUDIT_RING_CAPACITY: usize = 64; + /// Process-local memoization for `estimate_input_tokens_conservative`. /// /// The cache is keyed on the `(messages_revision, system_fingerprint)` /// pair, both of which the engine bumps on every content change. On a hit /// the previously stored token estimate is returned without re-walking the -/// message list. On a miss, the estimator runs and the result is stored. +/// message list. On a miss, the estimator runs and the result is stored +/// alongside the audit ring entry. #[derive(Debug, Default, Clone)] pub struct TokenEstimateCache { /// Monotonic counter bumped by the engine on every message mutation. @@ -38,13 +44,21 @@ pub struct TokenEstimateCache { system_fingerprint: u64, /// Cached token count, valid iff both keys match the current inputs. cached_tokens: Option, + /// Audit ring of recent (revision, tokens) pairs. The most recent entry + /// is the tail; the oldest is dropped when capacity is exceeded. Used by + /// observability to surface cache effectiveness to `/status`. + audit_ring: Vec<(u64, usize)>, + /// Number of cache hits since the cache was last cleared. Saturates at + /// `u64::MAX` (effectively never in practice). + hits: u64, + /// Number of cache misses since the cache was last cleared. + misses: u64, } impl TokenEstimateCache { - /// Construct a fresh, empty cache. `messages_revision` defaults to 0; - /// the engine passes the current revision on every - /// [`lookup_or_compute`](Self::lookup_or_compute) call so the next - /// lookup correctly invalidates. + /// Construct a fresh, empty cache. `messages_revision` defaults to 0; the + /// engine must call [`bump_messages_revision`](Self::bump_messages_revision) + /// whenever a mutation occurs so the next lookup correctly invalidates. #[must_use] pub fn new() -> Self { Self::default() @@ -67,6 +81,7 @@ impl TokenEstimateCache { && self.system_fingerprint == system_fingerprint && let Some(tokens) = self.cached_tokens { + self.hits = self.hits.saturating_add(1); return tokens; } @@ -74,8 +89,53 @@ impl TokenEstimateCache { self.messages_revision = messages_revision; self.system_fingerprint = system_fingerprint; self.cached_tokens = Some(tokens); + self.misses = self.misses.saturating_add(1); + self.push_audit(messages_revision, tokens); tokens } + + /// Record a messages-revision bump. The engine calls this whenever + /// `session.messages` is mutated. Calling it with a value smaller than + /// the current value is a no-op (the cache is monotonic). + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn bump_messages_revision(&mut self, revision: u64) { + if revision > self.messages_revision { + self.messages_revision = revision; + self.cached_tokens = None; + } + } + + /// Forget all cached state. Used by `/clear` and session reset paths. + #[allow(dead_code)] // exposed for future wiring of /clear and reset paths; tests exercise it + pub fn invalidate(&mut self) { + self.cached_tokens = None; + self.system_fingerprint = 0; + self.audit_ring.clear(); + self.hits = 0; + self.misses = 0; + } + + /// Returns `(hits, misses)` counters since the last `invalidate` call. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn stats(&self) -> (u64, u64) { + (self.hits, self.misses) + } + + /// Returns the most recent `(revision, tokens)` audit entries, newest + /// first. Bounded by [`AUDIT_RING_CAPACITY`]. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn recent_audit(&self) -> &[(u64, usize)] { + &self.audit_ring + } + + fn push_audit(&mut self, revision: u64, tokens: usize) { + if self.audit_ring.len() >= AUDIT_RING_CAPACITY { + self.audit_ring.remove(0); + } + self.audit_ring.push((revision, tokens)); + } } /// Stable 64-bit hash of the system prompt text. Walks the same shape the @@ -124,45 +184,89 @@ mod tests { } #[test] - fn first_call_computes_a_positive_estimate() { + fn first_call_is_a_miss() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hello world")]; let tokens = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); assert!(tokens > 0); + assert_eq!(hits, 0); + assert_eq!(misses, 1); } #[test] - fn repeated_call_with_same_revision_returns_the_cached_estimate() { + fn repeated_call_with_same_revision_is_a_hit() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hello world")]; - let first = cache.lookup_or_compute(1, None, &messages); - let second = cache.lookup_or_compute(1, None, &messages); - assert!(first > 0); - assert_eq!(first, second); + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 1); + assert_eq!(misses, 1); } #[test] - fn revision_bump_recomputes_the_same_estimate() { + fn revision_bump_invalidates() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; let a = cache.lookup_or_compute(1, None, &messages); let b = cache.lookup_or_compute(2, None, &messages); - assert!(a > 0); + let (hits, misses) = cache.stats(); + // Both calls were misses (different revisions), neither hit the cache. assert_eq!(a, b); + assert_eq!(hits, 0); + assert_eq!(misses, 2); } #[test] - fn system_prompt_change_recomputes_the_same_estimate() { + fn system_prompt_change_invalidates() { let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; - let a = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); - let b = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); - assert!(a > 0); - assert_eq!(a, b); + let _ = cache.lookup_or_compute(1, Some(&sys_text("alpha")), &messages); + let _ = cache.lookup_or_compute(1, Some(&sys_text("beta")), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); } #[test] - fn blocks_system_prompt_lookups_stay_consistent() { + fn bump_messages_revision_clears_cache() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + cache.bump_messages_revision(2); + let _ = cache.lookup_or_compute(2, None, &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn bump_to_smaller_revision_is_noop() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(5, None, &messages); + cache.bump_messages_revision(2); + // revision went down, cache should still be valid for revision 5 + let _ = cache.lookup_or_compute(5, None, &messages); + let (hits, _) = cache.stats(); + assert_eq!(hits, 1, "downward revision bumps must not invalidate"); + } + + #[test] + fn invalidate_resets_state() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("x")]; + let _ = cache.lookup_or_compute(1, None, &messages); + let _ = cache.lookup_or_compute(1, None, &messages); + cache.invalidate(); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 0); + } + + #[test] + fn blocks_system_prompt_yields_distinct_fingerprint() { let blocks_a = SystemPrompt::Blocks(vec![SystemBlock { block_type: "text".to_string(), text: "alpha".to_string(), @@ -175,9 +279,35 @@ mod tests { }]); let mut cache = TokenEstimateCache::new(); let messages = vec![user_text("hi")]; - let a = cache.lookup_or_compute(1, Some(&blocks_a), &messages); - let b = cache.lookup_or_compute(1, Some(&blocks_b), &messages); - assert!(a > 0); - assert_eq!(a, b); + let _ = cache.lookup_or_compute(1, Some(&blocks_a), &messages); + let _ = cache.lookup_or_compute(1, Some(&blocks_b), &messages); + let (hits, misses) = cache.stats(); + assert_eq!(hits, 0); + assert_eq!(misses, 2); + } + + #[test] + fn audit_ring_records_recent_pairs() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=5 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), 5); + assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1))); + } + + #[test] + fn audit_ring_bounded_by_capacity() { + let mut cache = TokenEstimateCache::new(); + let messages = vec![user_text("hi")]; + for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 { + let _ = cache.lookup_or_compute(rev, None, &messages); + } + let ring = cache.recent_audit(); + assert_eq!(ring.len(), AUDIT_RING_CAPACITY); + // newest entry should be the most recent revision we asked for + assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64); } } diff --git a/crates/tui/src/mcp/external_import.rs b/crates/tui/src/mcp/external_import.rs index 5e2182f9f1..57a281deeb 100644 --- a/crates/tui/src/mcp/external_import.rs +++ b/crates/tui/src/mcp/external_import.rs @@ -211,6 +211,30 @@ pub fn save_consent_store(path: &Path, store: &ImportConsentStore) -> std::io::R Ok(()) } +/// Filter candidates that still need a user decision for this content hash. +#[allow(dead_code)] // used by future selector UI + unit tests +pub fn candidates_needing_consent( + candidates: &[ImportCandidate], + store: &ImportConsentStore, +) -> Vec { + candidates + .iter() + .filter(|c| { + let key = consent_key(&c.source_path, &c.content_hash); + match store.entries.get(&key) { + Some(entry) if entry.decision == ImportDecision::Decline => false, + Some(entry) if entry.decision == ImportDecision::Approve => { + // Re-prompt only when the specific server was not part of + // the prior approval list (partial approval). + !entry.servers.iter().any(|s| s == &c.name) + } + _ => true, + } + }) + .cloned() + .collect() +} + /// Apply approvals: returns servers to merge into user mcp.json. /// Hard-blocked candidates are never returned even if decision is Approve. pub fn apply_approved( @@ -390,6 +414,32 @@ mod tests { assert!(config.servers["ok"].is_enabled()); } + #[test] + fn declined_consent_skips_reprompt_until_hash_changes() { + let dir = tempdir().unwrap(); + let path = write_claude_json( + dir.path(), + r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#, + ); + let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); + let mut store = ImportConsentStore::default(); + let mut decisions = HashMap::new(); + decisions.insert("x".into(), ImportDecision::Decline); + record_decisions(&mut store, &candidates, &decisions, 1); + let needing = candidates_needing_consent(&candidates, &store); + assert!(needing.is_empty(), "declined should not re-prompt"); + + // Content change → new hash → re-prompt. + fs::write( + &path, + r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#, + ) + .unwrap(); + let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson); + let needing = candidates_needing_consent(&refreshed, &store); + assert_eq!(needing.len(), 1); + } + #[test] fn provenance_display_includes_source_and_hash() { let dir = tempdir().unwrap(); diff --git a/crates/tui/src/prefix_cache.rs b/crates/tui/src/prefix_cache.rs index b49f186ce5..7d23f99982 100644 --- a/crates/tui/src/prefix_cache.rs +++ b/crates/tui/src/prefix_cache.rs @@ -312,11 +312,35 @@ impl ToolCatalogCache { entry } + /// Drop every cached entry. Used by tool-registry mutation paths + /// (e.g. plugin hot-reload, MCP attach) when the caller cannot + /// easily prove the tool set is unchanged. + #[allow(dead_code)] // observability; called by /cache flush and tests + pub fn invalidate(&mut self) { + self.by_identity.clear(); + self.insertion_order.clear(); + } + /// Returns the number of cached entries. #[must_use] pub fn len(&self) -> usize { self.by_identity.len() } + + /// Returns `true` if the cache has no entries. + #[allow(dead_code)] // observability; surfaced via /status + #[must_use] + pub fn is_empty(&self) -> bool { + self.by_identity.is_empty() + } + + /// Returns `(current_entries, capacity)` for observability. Surfaced via + /// the `/status` chip in a follow-up; tests exercise the path. + #[allow(dead_code)] // surfaced via /status in a follow-up; tests exercise it + #[must_use] + pub fn stats(&self) -> (usize, usize) { + (self.len(), self.capacity) + } } /// Content-derived identity for a tool slice. Order-sensitive: two slices @@ -401,7 +425,6 @@ fn hash_json_value(value: &serde_json::Value, state: &mut H) { #[allow(dead_code)] impl PrefixStabilityManager { /// Create a new manager and immediately pin the first fingerprint. - #[cfg(test)] pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self { let mut cache = ToolCatalogCache::new(); let fp = PrefixFingerprint::compute_with_tool_cache(system_text, tools, &mut cache); @@ -420,7 +443,7 @@ impl PrefixStabilityManager { } /// Create a manager in "unpinned" state — no initial fingerprint. - /// Call `check(.., Some(reason))` to establish the baseline. + /// Call `pin()` or `check_and_update()` to establish the baseline. pub fn new_unpinned() -> Self { Self { pinned: None, @@ -436,6 +459,33 @@ impl PrefixStabilityManager { } } + /// Explicitly pin a fingerprint, replacing any prior pinned state. + /// Returns `true` if this is the first pin, or `false` if replacing. + /// Note: does NOT increment `check_count` — that counter is reserved + /// for `check_and_update` calls so `stability_ratio()` stays accurate. + pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool { + self.pin_with_reason(system_text, tools, "initial") + } + + /// Pin under an explicit reason (`initial`, `resume`, `change:`). + pub fn pin_with_reason( + &mut self, + system_text: &str, + tools: Option<&[Tool]>, + reason: &str, + ) -> bool { + let fp = PrefixFingerprint::compute_with_tool_cache( + system_text, + tools, + &mut self.tool_catalog_cache, + ); + let was_unpinned = self.pinned.is_none(); + self.pinned = Some(fp.clone()); + self.current = Some(fp); + self.pin_reason = Some(reason.to_string()); + was_unpinned + } + /// Record an expected miss that is not a header change (compaction, /// `/clear`, an edited turn). The pin is untouched; the reason is kept so /// `/cache stats` can explain the next low hit-rate turn. @@ -566,7 +616,6 @@ impl PrefixStabilityManager { } /// Attributed change/drift/reset history, oldest first. - #[cfg(test)] pub fn history(&self) -> impl Iterator { self.history.iter() } @@ -583,7 +632,6 @@ impl PrefixStabilityManager { /// - `Err(change)` when the prefix drifted. The pin is **kept**: an /// undeclared change never becomes the new baseline. Use [`Self::check`] /// with a declared reason to move the pin on purpose. - #[cfg(test)] pub fn check_and_update( &mut self, system_text: &str, @@ -598,7 +646,6 @@ impl PrefixStabilityManager { } /// Returns the most recent prefix change, if any. - #[cfg(test)] pub fn last_change(&self) -> Option<&PrefixChange> { self.last_change.as_ref() } @@ -609,13 +656,11 @@ impl PrefixStabilityManager { } /// Returns the current (most recently computed) fingerprint. - #[cfg(test)] pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> { self.current.as_ref() } /// Returns the total number of prefix changes detected. - #[cfg(test)] pub fn change_count(&self) -> u64 { self.change_count } @@ -636,6 +681,31 @@ impl PrefixStabilityManager { stable_checks as f64 / self.check_count as f64 } } + + /// Returns a human-readable stability summary. + pub fn summary(&self) -> String { + let pct = self.stability_ratio() * 100.0; + let pinned_short = self + .pinned + .as_ref() + .map(|fp| { + if fp.combined_sha256.len() >= 12 { + &fp.combined_sha256[..12] + } else { + &fp.combined_sha256 + } + }) + .unwrap_or("none"); + + format!( + "Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}", + pct = pct, + stable = self.check_count.saturating_sub(self.change_count), + total = self.check_count, + pinned_short = pinned_short, + changes = self.change_count, + ) + } } /// Serialize a tool to the same JSON shape the chat API receives, @@ -1096,6 +1166,16 @@ mod tests { assert_eq!(re_entry.sha256_hex, fresh.sha256_hex); } + #[test] + fn tool_catalog_cache_invalidate_clears_all() { + let mut cache = ToolCatalogCache::new(); + cache.fingerprint_for(&[make_tool("a")]); + cache.fingerprint_for(&[make_tool("b")]); + cache.invalidate(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + #[test] fn tool_catalog_cache_empty_slice_uses_zero_capacity_path() { // Empty input is fine — should produce a stable, non-empty digest. diff --git a/crates/tui/src/project_context/types.rs b/crates/tui/src/project_context/types.rs index 46d0811d56..7c500fcad2 100644 --- a/crates/tui/src/project_context/types.rs +++ b/crates/tui/src/project_context/types.rs @@ -160,3 +160,39 @@ pub(crate) fn repo_relative_source_label(path: &Path, root: Option<&Path>) -> St } path.display().to_string() } + +/// Merge multiple project contexts (e.g., from nested directories) +#[allow(dead_code)] // Public API for monorepo context merging +pub fn merge_contexts(contexts: &[ProjectContext]) -> Option { + let non_empty: Vec<_> = contexts + .iter() + .filter_map(ProjectContext::as_system_block) + .collect(); + + if non_empty.is_empty() { + None + } else { + Some(non_empty.join("\n\n")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_merge_contexts() { + let mut ctx1 = ProjectContext::empty(PathBuf::from("/a")); + ctx1.instructions = Some("Instructions A".to_string()); + ctx1.source_path = Some(PathBuf::from("/a/AGENTS.md")); + + let mut ctx2 = ProjectContext::empty(PathBuf::from("/b")); + ctx2.instructions = Some("Instructions B".to_string()); + ctx2.source_path = Some(PathBuf::from("/b/AGENTS.md")); + + let merged = merge_contexts(&[ctx1, ctx2]).expect("merge"); + + assert!(merged.contains("Instructions A")); + assert!(merged.contains("Instructions B")); + } +} diff --git a/crates/tui/src/prompt_zones.rs b/crates/tui/src/prompt_zones.rs index 87c6ed35a2..54ebb22dea 100644 --- a/crates/tui/src/prompt_zones.rs +++ b/crates/tui/src/prompt_zones.rs @@ -9,14 +9,19 @@ //! ├─────────────────────────────────────────┤ //! │ AppendLog (append-only) │ ← conversation history //! │ push() only, no insert / remove / edit │ preserves prefix of prior turns +//! ├─────────────────────────────────────────┤ +//! │ TurnScratch (ephemeral) │ ← per-turn metadata +//! │ cleared at every turn boundary │ the only new content per request //! └─────────────────────────────────────────┘ //! ``` //! -//! ## Status +//! ## Status (Phase 1 foundation) //! -//! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` / `AppendLog` are ready -//! for use; `AppendLog` itself is wired through `Session`'s message log. +//! `PinnedPrefix` / `FrozenPrefix` / `PrefixDrift` are ready for use. +//! `AppendLog` / `TurnScratch` / `ThreeZoneRequest` are type scaffolding +//! for future phases — not yet wired into the request path. +use crate::models::Role; use crate::models::{Message, SystemPrompt, Tool}; // ── helpers ──────────────────────────────────────────────────────────── @@ -272,13 +277,124 @@ impl std::ops::Deref for AppendLog { } } +// ── TurnScratch ──────────────────────────────────────────────────────── + +/// Per-turn ephemeral data. Cleared at every turn boundary. +/// +/// **Phase 1 scaffolding** — not yet wired into the engine request path. +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug, Clone, Default)] +pub struct TurnScratch { + pub working_set: Vec, + pub user_message: Option, +} + +#[cfg_attr(not(test), allow(dead_code))] +impl TurnScratch { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.working_set.clear(); + self.user_message = None; + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.working_set.is_empty() && self.user_message.is_none() + } +} + +// ── ThreeZoneRequest ─────────────────────────────────────────────────── + +/// A composed three-zone request ready for DeepSeek API serialization. +/// +/// **Phase 1 scaffolding** — not yet wired into the engine request path. +/// Currently the engine continues to use `MessageRequest` directly. +#[expect(dead_code)] +#[derive(Debug, Clone)] +pub struct ThreeZoneRequest<'a> { + pub prefix: &'a FrozenPrefix, + pub log: &'a AppendLog, + pub scratch: TurnScratch, + pub model: String, + pub max_tokens: u32, + pub system: Option, + pub tools: Option>, + pub tool_choice: Option, + pub reasoning_effort: Option, + pub thinking: Option, + pub stream: Option, + pub temperature: Option, + pub top_p: Option, + pub metadata: Option, +} + +#[cfg_attr(not(test), expect(dead_code))] +impl<'a> ThreeZoneRequest<'a> { + /// Build the full message list from system prompt, append-log messages, + /// and scratch user message. The returned vector is serialized as the + /// `messages` field in the DeepSeek chat-completion request. + #[must_use] + pub fn build_messages(&self) -> Vec { + let mut messages = Vec::with_capacity(self.message_count()); + + match self.system.as_ref() { + Some(SystemPrompt::Text(text)) => { + messages.push(Message { + role: Role::System, + content: vec![crate::models::ContentBlock::Text { + text: text.clone(), + cache_control: None, + }], + }); + } + Some(SystemPrompt::Blocks(blocks)) => { + let content: Vec = blocks + .iter() + .map(|block| crate::models::ContentBlock::Text { + text: block.text.clone(), + cache_control: block.cache_control.clone(), + }) + .collect(); + messages.push(Message { + role: Role::System, + content, + }); + } + None => {} + } + + for msg in self.log.iter() { + messages.push(msg.clone()); + } + + if let Some(ref user_msg) = self.scratch.user_message { + messages.push(user_msg.clone()); + } + + messages + } + + #[must_use] + pub fn message_count(&self) -> usize { + let system_count = if self.system.is_some() { 1 } else { 0 }; + let scratch_count = if self.scratch.user_message.is_some() { + 1 + } else { + 0 + }; + system_count + self.log.len() + scratch_count + } +} + // ── tests ────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use crate::models::ContentBlock; - use crate::models::Role; fn make_tool(name: &str) -> Tool { Tool { @@ -434,4 +550,141 @@ mod tests { let messages: Vec<_> = log.iter().collect(); assert_eq!(messages.len(), 2); } + + // ── TurnScratch ─────────────────────────────────────────────── + + #[test] + fn scratch_clear_empties_all_fields() { + let mut scratch = TurnScratch::new(); + scratch.working_set.push("file.rs".to_string()); + scratch.user_message = Some(make_message("user", "task")); + + assert!(!scratch.is_empty()); + scratch.clear(); + assert!(scratch.is_empty()); + assert!(scratch.working_set.is_empty()); + assert!(scratch.user_message.is_none()); + } + + // ── ThreeZoneRequest ────────────────────────────────────────── + + #[test] + fn build_messages_concatenates_zones() { + let sys = SystemPrompt::Text("you are helpful".to_string()); + let tools = vec![make_tool("read")]; + let prefix = PinnedPrefix::new(Some(&sys), tools).freeze(); + + let mut log = AppendLog::new(); + log.push(make_message("user", "prev question")); + log.push(make_message("assistant", "prev answer")); + + let scratch = TurnScratch { + working_set: vec!["main.rs".to_string()], + user_message: Some(make_message("user", "current task")), + }; + + let request = ThreeZoneRequest { + prefix: &prefix, + log: &log, + scratch, + model: "deepseek-v4-pro".to_string(), + max_tokens: 4096, + system: Some(sys), + tools: None, + tool_choice: None, + reasoning_effort: None, + thinking: None, + stream: None, + temperature: None, + top_p: None, + metadata: None, + }; + + let messages = request.build_messages(); + assert_eq!(messages.len(), 4); + assert_eq!(messages[0].role, "system"); + assert_eq!(messages[1].role, "user"); + assert_eq!(messages[2].role, "assistant"); + assert_eq!(messages[3].role, "user"); + assert_eq!(request.message_count(), 4); + } + + #[test] + fn build_messages_no_system_no_scratch() { + let prefix = PinnedPrefix::new(None, vec![]).freeze(); + + let mut log = AppendLog::new(); + log.push(make_message("user", "hi")); + + let request = ThreeZoneRequest { + prefix: &prefix, + log: &log, + scratch: TurnScratch::new(), + model: "x".to_string(), + max_tokens: 1, + system: None, + tools: None, + tool_choice: None, + reasoning_effort: None, + thinking: None, + stream: None, + temperature: None, + top_p: None, + metadata: None, + }; + + let messages = request.build_messages(); + assert_eq!(messages.len(), 1); + assert_eq!(request.message_count(), 1); + } + + #[test] + fn blocks_system_prompt_preserves_cache_control() { + use crate::models::{CacheControl, SystemBlock}; + let cc = Some(CacheControl { + cache_type: "ephemeral".to_string(), + }); + let blocks = SystemPrompt::Blocks(vec![SystemBlock { + block_type: "text".to_string(), + text: "hello".to_string(), + cache_control: cc.clone(), + }]); + + let prefix = PinnedPrefix::new(Some(&blocks), vec![]).freeze(); + let log = AppendLog::new(); + let scratch = TurnScratch::new(); + let request = ThreeZoneRequest { + prefix: &prefix, + log: &log, + scratch, + model: "x".to_string(), + max_tokens: 1, + system: Some(blocks), + tools: None, + tool_choice: None, + reasoning_effort: None, + thinking: None, + stream: None, + temperature: None, + top_p: None, + metadata: None, + }; + + let messages = request.build_messages(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].role, "system"); + // cache_control should be preserved on the block. + if let ContentBlock::Text { + cache_control: actual_cc, + .. + } = &messages[0].content[0] + { + assert_eq!( + actual_cc.as_ref().map(|c| c.cache_type.as_str()), + Some("ephemeral") + ); + } else { + panic!("expected Text content block"); + } + } } diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index ecabd8f9ed..593b423e7e 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -1060,6 +1060,14 @@ fn apply_static_prompt_composer( // ── Public API ──────────────────────────────────────────────────────── +/// Get the system prompt for a specific mode with project context. +pub fn system_prompt_for_mode_with_context( + workspace: &Path, + working_set_summary: Option<&str>, +) -> SystemPrompt { + system_prompt_for_mode_with_context_and_skills(workspace, working_set_summary, None, None, None) +} + /// Get the system prompt for a specific mode with project and skills context. /// /// **Volatile-content-last invariant.** Blocks are appended in order from @@ -3025,9 +3033,7 @@ mod tests { ) .unwrap(); - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - workspace, None, None, None, None, - )); + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); assert!(prompt.contains(HANDOFF_BLOCK_MARKER)); assert!(prompt.contains("Finish #32.")); @@ -3090,13 +3096,8 @@ mod tests { #[test] fn missing_handoff_does_not_inject_block() { let tmp = tempdir().expect("tempdir"); - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - tmp.path(), - None, - None, - None, - None, - )); + let prompt = + system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); } @@ -3106,13 +3107,8 @@ mod tests { let dir = tmp.path().join(".deepseek"); std::fs::create_dir_all(&dir).unwrap(); std::fs::write(dir.join("handoff.md"), " \n\n ").unwrap(); - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - tmp.path(), - None, - None, - None, - None, - )); + let prompt = + system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); assert!(!prompt.contains(HANDOFF_BLOCK_MARKER)); } @@ -3287,13 +3283,8 @@ mod tests { #[test] fn compact_template_is_lazy_in_fresh_prompt() { let tmp = tempdir().expect("tempdir"); - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - tmp.path(), - None, - None, - None, - None, - )); + let prompt = + system_prompt_flat_text(&system_prompt_for_mode_with_context(tmp.path(), None)); assert!(!prompt.contains("# Session relay")); assert!(!prompt.contains("## Verification")); } @@ -3659,12 +3650,8 @@ mod tests { let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); let workspace = workspace_tmp.path(); - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - workspace, None, None, None, None, - )); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - workspace, None, None, None, None, - )); + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); assert_byte_identical( "system_prompt_for_mode_with_context() on empty workspace", &a, @@ -3686,19 +3673,13 @@ mod tests { let workspace = tmp.path(); let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context( workspace, Some(summary), - None, - None, - None, )); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context( workspace, Some(summary), - None, - None, - None, )); assert_byte_identical( "system_prompt_for_mode_with_context with constant working_set summary", @@ -3732,12 +3713,8 @@ mod tests { ) .unwrap(); - let a = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - workspace, None, None, None, None, - )); - let b = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( - workspace, None, None, None, None, - )); + let a = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); + let b = system_prompt_flat_text(&system_prompt_for_mode_with_context(workspace, None)); assert_byte_identical( "system_prompt_for_mode_with_context with constant handoff file", &a, @@ -3760,12 +3737,9 @@ mod tests { std::fs::write(handoff_dir.join("handoff.md"), "# handoff body\n").unwrap(); let summary = "## Repo Working Set\nWorkspace: /tmp/x\n"; - let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context_and_skills( + let prompt = system_prompt_flat_text(&system_prompt_for_mode_with_context( workspace, Some(summary), - None, - None, - None, )); let execution_pos = prompt @@ -3813,16 +3787,14 @@ mod tests { let _userprofile = EnvVarGuard::set("USERPROFILE", home_tmp.path().as_os_str()); let _skills_dir = EnvVarGuard::remove("DEEPSEEK_SKILLS_DIR"); let workspace = tmp.path(); - let prompt = - match system_prompt_for_mode_with_context_and_skills(workspace, None, None, None, None) - { - SystemPrompt::Text(text) => text, - SystemPrompt::Blocks(blocks) => blocks - .iter() - .map(|block| block.text.as_str()) - .collect::>() - .join("\n"), - }; + let prompt = match system_prompt_for_mode_with_context(workspace, None) { + SystemPrompt::Text(text) => text, + SystemPrompt::Blocks(blocks) => blocks + .iter() + .map(|block| block.text.as_str()) + .collect::>() + .join("\n"), + }; // No absolute paths (Unix or Windows). let offending: Vec<&str> = prompt diff --git a/crates/tui/src/resource_telemetry.rs b/crates/tui/src/resource_telemetry.rs index dbb80504ce..5cae28e549 100644 --- a/crates/tui/src/resource_telemetry.rs +++ b/crates/tui/src/resource_telemetry.rs @@ -1,11 +1,179 @@ -//! Output-token throughput estimation for long-running CodeWhale tasks. +#![allow(dead_code)] + +//! Resource-usage telemetry for long-running CodeWhale tasks. +//! +//! This module is a pure, side-effect-free foundation for surfacing how many +//! tokens and how much wall-clock time a task has consumed, optionally relative +//! to a budget. It performs no I/O and no rendering; consumers (status lines, +//! the cost panel, the goal/budget tooling) are wired up separately so the +//! formatting and pressure logic can be unit-tested in isolation. //! -//! This module is a pure, side-effect-free foundation for the footer's live -//! throughput readout. It performs no I/O and no rendering; consumers (the -//! status line and event loop) wire it up directly so the estimation logic -//! can be unit-tested in isolation. +//! The shape intentionally mirrors the budget vocabulary already used by the +//! goal tooling (`token_budget: Option<_>`) so a consumer can adapt between the +//! two without inventing new concepts. We keep a local type rather than reusing +//! `tools::goal` here to avoid coupling a presentation-layer helper to the tool +//! domain model (whose budgets are `u32` and carry unrelated bookkeeping). + +use std::{ + fmt::{self, Write as _}, + time::Duration, +}; + +/// A coarse, three-level read on how close a task is to exhausting its budget. +/// +/// The level is derived from the *highest* pressure across all bounded +/// dimensions (tokens and time), so a task that is comfortable on tokens but +/// nearly out of time still reports [`PressureLevel::High`]. When nothing is +/// bounded, pressure is [`PressureLevel::Low`] by definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum PressureLevel { + /// Plenty of headroom (under ~75% of every bounded budget). + Low, + /// Getting close (at/over ~75% but under 100% of some budget). + Medium, + /// At or over budget on some bounded dimension. + High, +} + +impl PressureLevel { + /// Fraction at/above which a dimension is considered medium pressure. + const MEDIUM_THRESHOLD: f64 = 0.75; + /// Fraction at/above which a dimension is considered high pressure. + const HIGH_THRESHOLD: f64 = 1.0; + + /// Classify a single budget fraction (e.g. `0.41` for 41% used). + /// + /// Negative or non-finite input is treated as [`PressureLevel::Low`]; the + /// telemetry helpers never produce such values, but classifying defensively + /// keeps this usable for arbitrary callers. + fn from_fraction(fraction: f64) -> Self { + if !fraction.is_finite() || fraction < Self::MEDIUM_THRESHOLD { + PressureLevel::Low + } else if fraction < Self::HIGH_THRESHOLD { + PressureLevel::Medium + } else { + PressureLevel::High + } + } + + /// A short lowercase label suitable for compact status output. + pub fn label(self) -> &'static str { + match self { + PressureLevel::Low => "low", + PressureLevel::Medium => "medium", + PressureLevel::High => "high", + } + } +} + +/// A snapshot of token and time usage for a single task, with optional budgets. +/// +/// All fields are plain counters; this type owns no clock and reads no +/// environment. Construct it from whatever the caller is already tracking and +/// use the helpers below to render or classify it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct ResourceTelemetry { + /// Total tokens consumed so far. + pub tokens_used: u64, + /// Total wall-clock seconds elapsed so far. + pub time_used_seconds: u64, + /// Optional token ceiling for the task; `None` means unbounded. + pub token_budget: Option, + /// Optional time ceiling in seconds; `None` means unbounded. + pub time_budget_seconds: Option, +} + +impl ResourceTelemetry { + /// Create a telemetry snapshot with no budgets (fully unbounded). + pub fn new(tokens_used: u64, time_used_seconds: u64) -> Self { + Self { + tokens_used, + time_used_seconds, + token_budget: None, + time_budget_seconds: None, + } + } + + /// Set the token budget, returning the updated snapshot (builder style). + pub fn with_token_budget(mut self, budget: u64) -> Self { + self.token_budget = Some(budget); + self + } + + /// Set the time budget in seconds, returning the updated snapshot. + pub fn with_time_budget_seconds(mut self, seconds: u64) -> Self { + self.time_budget_seconds = Some(seconds); + self + } + + /// Fraction of the token budget consumed, or `None` when unbounded. + /// + /// A zero budget yields `None` (a percentage of nothing is meaningless) + /// rather than infinity, keeping every downstream consumer safe. + pub fn token_fraction(&self) -> Option { + fraction(self.tokens_used, self.token_budget) + } + + /// Fraction of the time budget consumed, or `None` when unbounded. + pub fn time_fraction(&self) -> Option { + fraction(self.time_used_seconds, self.time_budget_seconds) + } + + /// The largest bounded budget fraction across tokens and time. + /// + /// Returns `None` only when *neither* dimension is bounded. When at least + /// one budget is present, the most-pressured bounded dimension wins. + pub fn budget_fraction(&self) -> Option { + match (self.token_fraction(), self.time_fraction()) { + (Some(t), Some(s)) => Some(t.max(s)), + (Some(t), None) => Some(t), + (None, Some(s)) => Some(s), + (None, None) => None, + } + } -use std::time::Duration; + /// Budget fraction expressed as a whole-number percent (rounded), or `None` + /// when unbounded. This is the value surfaced in the human summary. + pub fn budget_percent(&self) -> Option { + self.budget_fraction().map(|f| (f * 100.0).round() as u64) + } + + /// Coarse pressure level derived from [`Self::budget_fraction`]. + /// + /// Unbounded tasks are always [`PressureLevel::Low`]. + pub fn pressure(&self) -> PressureLevel { + match self.budget_fraction() { + Some(fraction) => PressureLevel::from_fraction(fraction), + None => PressureLevel::Low, + } + } + + /// A compact, human-readable one-liner, e.g. `12.3k tok · 4m12s · 41% budget`. + /// + /// Tokens are abbreviated with `k`/`M` suffixes, time is rendered as + /// `Hh Mm Ss` (dropping leading zero units), and the budget segment is + /// omitted entirely when the task is unbounded. + pub fn human_summary(&self) -> String { + let mut out = String::new(); + // `write!` into a String is infallible; ignore the Result. + let _ = write!( + out, + "{} tok · {}", + format_tokens(self.tokens_used), + crate::elapsed::format_elapsed_secs(self.time_used_seconds), + ); + if let Some(percent) = self.budget_percent() { + let _ = write!(out, " · {percent}% budget"); + } + out + } +} + +impl fmt::Display for ResourceTelemetry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.human_summary()) + } +} /// Output-token throughput for a live or completed turn. #[derive(Debug, Clone, Copy, PartialEq)] @@ -45,10 +213,90 @@ pub fn estimate_output_tokens_from_text(text: &str) -> u64 { } } +/// Divide `used` by an optional budget, guarding against an absent or zero +/// budget. Returns `None` when the budget is `None` or `0`. +fn fraction(used: u64, budget: Option) -> Option { + match budget { + Some(budget) if budget > 0 => Some(used as f64 / budget as f64), + _ => None, + } +} + +/// Format a token count with a `k`/`M` suffix once it crosses each threshold. +/// +/// Values under 1_000 are printed verbatim. Thousands use one decimal place +/// (`12.3k`), trimming a trailing `.0` so round values read cleanly (`5k`). +/// Millions follow the same rule (`1.5M`, `2M`). +fn format_tokens(tokens: u64) -> String { + const K: u64 = 1_000; + const M: u64 = 1_000_000; + if tokens >= M { + format_scaled(tokens, M, 'M') + } else if tokens >= K { + format_scaled(tokens, K, 'k') + } else { + tokens.to_string() + } +} + +/// Render `value / divisor` to one decimal place with `suffix`, dropping a +/// trailing `.0`. The divisor is always one of the constants above (non-zero). +fn format_scaled(value: u64, divisor: u64, suffix: char) -> String { + let scaled = value as f64 / divisor as f64; + // Round to one decimal before deciding whether the fraction is ".0", so a + // value like 1_999_999 reads as "2M" rather than "1.9...M". + let rounded = (scaled * 10.0).round() / 10.0; + if (rounded.fract()).abs() < f64::EPSILON { + format!("{}{}", rounded as u64, suffix) + } else { + format!("{rounded:.1}{suffix}") + } +} + #[cfg(test)] mod tests { use super::*; + // ---- token formatting ------------------------------------------------- + + #[test] + fn format_tokens_under_a_thousand_is_verbatim() { + assert_eq!(format_tokens(0), "0"); + assert_eq!(format_tokens(1), "1"); + assert_eq!(format_tokens(999), "999"); + } + + #[test] + fn format_tokens_uses_k_suffix_with_trimmed_decimal() { + assert_eq!(format_tokens(1_000), "1k"); + assert_eq!(format_tokens(1_500), "1.5k"); + assert_eq!(format_tokens(12_345), "12.3k"); + // Exactly on a round thousand trims the ".0". + assert_eq!(format_tokens(5_000), "5k"); + // Just under the millions boundary stays in k. + assert_eq!(format_tokens(999_400), "999.4k"); + } + + #[test] + fn format_tokens_uses_m_suffix_for_millions() { + assert_eq!(format_tokens(1_000_000), "1M"); + assert_eq!(format_tokens(1_500_000), "1.5M"); + assert_eq!(format_tokens(2_340_000), "2.3M"); + } + + #[test] + fn format_tokens_rounds_up_across_a_unit_boundary() { + // 1_999_999 rounds to 2.0M -> "2M", not "1.9M" or "2.0M". + assert_eq!(format_tokens(1_999_999), "2M"); + // 999_950 rounds to 1000.0k; still within the k branch and trims ".0". + assert_eq!(format_tokens(999_950), "1000k"); + } + + #[test] + fn format_tokens_handles_very_large_values() { + assert_eq!(format_tokens(u64::MAX), "18446744073709.6M"); + } + // ---- throughput ------------------------------------------------------- #[test] @@ -70,4 +318,160 @@ mod tests { assert_eq!(estimate_output_tokens_from_text("abcd"), 1); assert_eq!(estimate_output_tokens_from_text("abcde"), 2); } + + // ---- fraction / percent ---------------------------------------------- + + #[test] + fn fractions_are_none_when_unbounded() { + let t = ResourceTelemetry::new(5_000, 120); + assert_eq!(t.token_fraction(), None); + assert_eq!(t.time_fraction(), None); + assert_eq!(t.budget_fraction(), None); + assert_eq!(t.budget_percent(), None); + } + + #[test] + fn zero_budget_yields_none_not_infinity() { + let t = ResourceTelemetry { + tokens_used: 100, + time_used_seconds: 0, + token_budget: Some(0), + time_budget_seconds: Some(0), + }; + assert_eq!(t.token_fraction(), None); + assert_eq!(t.time_fraction(), None); + assert_eq!(t.budget_fraction(), None); + assert_eq!(t.pressure(), PressureLevel::Low); + } + + #[test] + fn token_fraction_is_computed_when_bounded() { + let t = ResourceTelemetry::new(4_100, 0).with_token_budget(10_000); + let frac = t.token_fraction().expect("bounded"); + assert!((frac - 0.41).abs() < 1e-9, "got {frac}"); + assert_eq!(t.budget_percent(), Some(41)); + } + + #[test] + fn budget_fraction_takes_the_max_across_dimensions() { + // Tokens at 10%, time at 80% -> the time pressure dominates. + let t = ResourceTelemetry { + tokens_used: 1_000, + time_used_seconds: 80, + token_budget: Some(10_000), + time_budget_seconds: Some(100), + }; + let frac = t.budget_fraction().expect("bounded"); + assert!((frac - 0.80).abs() < 1e-9, "got {frac}"); + assert_eq!(t.budget_percent(), Some(80)); + } + + #[test] + fn budget_fraction_present_when_only_one_dimension_bounded() { + let only_time = ResourceTelemetry::new(9_999, 50).with_time_budget_seconds(200); + assert_eq!(only_time.budget_percent(), Some(25)); + + let only_tokens = ResourceTelemetry::new(2_500, 9_999).with_token_budget(10_000); + assert_eq!(only_tokens.budget_percent(), Some(25)); + } + + #[test] + fn budget_percent_rounds_to_nearest_whole() { + // 333 / 1000 = 33.3% -> 33 + let down = ResourceTelemetry::new(333, 0).with_token_budget(1_000); + assert_eq!(down.budget_percent(), Some(33)); + // 336 / 1000 = 33.6% -> 34 + let up = ResourceTelemetry::new(336, 0).with_token_budget(1_000); + assert_eq!(up.budget_percent(), Some(34)); + } + + // ---- pressure levels -------------------------------------------------- + + #[test] + fn pressure_low_when_unbounded_regardless_of_usage() { + let t = ResourceTelemetry::new(u64::MAX, u64::MAX); + assert_eq!(t.pressure(), PressureLevel::Low); + } + + #[test] + fn pressure_thresholds_just_under_and_over() { + // 74% -> Low (just under the medium threshold). + let low = ResourceTelemetry::new(7_400, 0).with_token_budget(10_000); + assert_eq!(low.pressure(), PressureLevel::Low); + + // Exactly 75% -> Medium (inclusive lower bound). + let medium_edge = ResourceTelemetry::new(7_500, 0).with_token_budget(10_000); + assert_eq!(medium_edge.pressure(), PressureLevel::Medium); + + // 99% -> Medium (just under the high threshold). + let medium = ResourceTelemetry::new(9_900, 0).with_token_budget(10_000); + assert_eq!(medium.pressure(), PressureLevel::Medium); + + // Exactly 100% -> High (at budget). + let high_edge = ResourceTelemetry::new(10_000, 0).with_token_budget(10_000); + assert_eq!(high_edge.pressure(), PressureLevel::High); + + // Over budget -> High. + let over = ResourceTelemetry::new(12_500, 0).with_token_budget(10_000); + assert_eq!(over.pressure(), PressureLevel::High); + } + + #[test] + fn pressure_level_labels_and_ordering() { + assert_eq!(PressureLevel::Low.label(), "low"); + assert_eq!(PressureLevel::Medium.label(), "medium"); + assert_eq!(PressureLevel::High.label(), "high"); + // Ord derive: Low < Medium < High. + assert!(PressureLevel::Low < PressureLevel::Medium); + assert!(PressureLevel::Medium < PressureLevel::High); + } + + #[test] + fn pressure_from_fraction_ignores_non_finite() { + assert_eq!(PressureLevel::from_fraction(f64::NAN), PressureLevel::Low); + assert_eq!( + PressureLevel::from_fraction(f64::INFINITY), + PressureLevel::Low + ); + assert_eq!(PressureLevel::from_fraction(-0.5), PressureLevel::Low); + } + + // ---- human summary ---------------------------------------------------- + + #[test] + fn human_summary_bounded_matches_example_shape() { + let t = ResourceTelemetry::new(12_345, 252).with_token_budget(30_000); + // 12_345 -> "12.3k", 252s -> "4m 12s", 12345/30000 = 41.15% -> 41%. + assert_eq!(t.human_summary(), "12.3k tok · 4m 12s · 41% budget"); + } + + #[test] + fn human_summary_unbounded_omits_budget_segment() { + let t = ResourceTelemetry::new(500, 5); + assert_eq!(t.human_summary(), "500 tok · 5s"); + // Display delegates to human_summary. + assert_eq!(t.to_string(), "500 tok · 5s"); + } + + #[test] + fn human_summary_zero_everything() { + let t = ResourceTelemetry::default(); + assert_eq!(t.human_summary(), "0 tok · 0s"); + } + + #[test] + fn human_summary_over_budget_can_exceed_one_hundred_percent() { + let t = ResourceTelemetry::new(15_000, 7_320).with_token_budget(10_000); + // 15000/10000 = 150%, 7320s -> 122m 00s. + assert_eq!(t.human_summary(), "15k tok · 122m 00s · 150% budget"); + assert_eq!(t.pressure(), PressureLevel::High); + } + + #[test] + fn human_summary_with_only_time_budget() { + let t = ResourceTelemetry::new(2_000_000, 300).with_time_budget_seconds(600); + // 2M tokens, 5m 00s, 300/600 = 50% budget. + assert_eq!(t.human_summary(), "2M tok · 5m 00s · 50% budget"); + assert_eq!(t.pressure(), PressureLevel::Low); + } } diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 0ebd5350f3..61ece593cb 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -10522,6 +10522,65 @@ fn tool_kind_for_name(name: &str) -> TurnItemKind { TurnItemKind::ToolCall } +/// One sub-agent rebind hint extracted from a thread's persisted event +/// timeline (issue #128). When the TUI resumes a session that was +/// mid-fanout, the in-transcript card stack is empty — these hints let the +/// UI know which agent_ids were live (or recently terminal) so it can +/// reconstruct the matching `DelegateCard` / `FanoutCard` placeholders +/// before fresh mailbox envelopes arrive on a re-attached engine. +/// +/// The helper is the testable contract here — actual TUI wire-up to the +/// resume flow is a follow-up. +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] // consumed by #128 follow-up TUI resume wiring; tested here. +pub struct AgentRebindHint { + pub agent_id: String, + pub status: AgentRebindStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum AgentRebindStatus { + Spawned, + InProgress, + Completed, +} + +/// Collapse a chronologically ordered slice of `RuntimeEventRecord` into +/// the latest known status per `agent_id`. Drops entries that aren't in +/// the `agent.*` family. Cards built from these hints are immediately +/// open to mutation by subsequent live mailbox envelopes (each envelope's +/// `agent_id` matches one already in the rebind map). +#[must_use] +#[allow(dead_code)] +pub fn collect_agent_rebind_hints(events: &[RuntimeEventRecord]) -> Vec { + use std::collections::BTreeMap; + let mut latest: BTreeMap = BTreeMap::new(); + for event in events { + let id = match event.payload.get("agent_id").and_then(|v| v.as_str()) { + Some(id) => id.to_string(), + None => continue, + }; + let next_status = match event.event.as_str() { + "agent.spawned" => Some(AgentRebindStatus::Spawned), + "agent.progress" => Some(AgentRebindStatus::InProgress), + "agent.completed" => Some(AgentRebindStatus::Completed), + _ => None, + }; + if let Some(status) = next_status { + // Don't downgrade Completed → InProgress on out-of-order events. + let entry = latest.entry(id).or_insert(status); + if !matches!(*entry, AgentRebindStatus::Completed) { + *entry = status; + } + } + } + latest + .into_iter() + .map(|(agent_id, status)| AgentRebindHint { agent_id, status }) + .collect() +} + pub fn summarize_text(text: &str, limit: usize) -> String { let take = limit.saturating_sub(3); let mut count = 0; diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index eb0819f5bd..627ec21196 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -11610,6 +11610,98 @@ fn mode_only_override_preserves_legacy_full_access_posture() -> Result<()> { Ok(()) } +fn rebind_event(event: &str, agent_id: &str, seq: u64) -> RuntimeEventRecord { + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq, + timestamp: Utc::now(), + thread_id: "thr_test".to_string(), + turn_id: Some("turn_test".to_string()), + item_id: None, + event: event.to_string(), + payload: json!({ "agent_id": agent_id }), + } +} + +#[test] +fn collect_agent_rebind_hints_resumes_a_mid_fanout_session() { + // Mirror what runtime_threads persists during a real fanout: three + // workers spawned, two finished, one still running when the session + // was killed. The TUI re-attach must rebuild placeholders for the + // running worker AND the two completed workers (the fanout card + // tracks all of them so the dot-grid stays accurate post-resume). + let events = vec![ + rebind_event("agent.spawned", "agent_a", 1), + rebind_event("agent.spawned", "agent_b", 2), + rebind_event("agent.spawned", "agent_c", 3), + rebind_event("agent.progress", "agent_a", 4), + rebind_event("agent.completed", "agent_a", 5), + rebind_event("agent.progress", "agent_b", 6), + rebind_event("agent.completed", "agent_b", 7), + rebind_event("agent.progress", "agent_c", 8), + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 3, "every fanout worker must be rebound"); + let by_id: std::collections::BTreeMap<&str, AgentRebindStatus> = hints + .iter() + .map(|h| (h.agent_id.as_str(), h.status)) + .collect(); + assert_eq!(by_id.get("agent_a"), Some(&AgentRebindStatus::Completed)); + assert_eq!(by_id.get("agent_b"), Some(&AgentRebindStatus::Completed)); + assert_eq!( + by_id.get("agent_c"), + Some(&AgentRebindStatus::InProgress), + "in-flight worker must rebind in InProgress, not downgrade" + ); +} + +#[test] +fn collect_agent_rebind_hints_ignores_unrelated_events() { + // Status / tool events should not produce phantom hints — only the + // agent.* family carries the contract we re-bind from. + let events = vec![ + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq: 1, + timestamp: Utc::now(), + thread_id: "thr".to_string(), + turn_id: None, + item_id: None, + event: "tool.completed".to_string(), + payload: json!({"name": "read_file"}), + }, + rebind_event("agent.spawned", "agent_x", 2), + RuntimeEventRecord { + schema_version: CURRENT_RUNTIME_SCHEMA_VERSION, + seq: 3, + timestamp: Utc::now(), + thread_id: "thr".to_string(), + turn_id: None, + item_id: None, + event: "compaction.completed".to_string(), + payload: json!({"messages_after": 12}), + }, + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0].agent_id, "agent_x"); +} + +#[test] +fn collect_agent_rebind_hints_does_not_downgrade_completed_to_in_progress() { + // Out-of-order replay: a stale `agent.progress` arriving after the + // completed event must NOT clobber the terminal status. This matters + // when an event log is concatenated from interrupted segments. + let events = vec![ + rebind_event("agent.spawned", "agent_y", 1), + rebind_event("agent.completed", "agent_y", 2), + rebind_event("agent.progress", "agent_y", 3), + ]; + let hints = collect_agent_rebind_hints(&events); + assert_eq!(hints.len(), 1); + assert_eq!(hints[0].status, AgentRebindStatus::Completed); +} + /// Helper for the `fork_at_user_message` tests: write a sequence of /// (user, assistant) turns under the given thread id. Each turn gets /// one UserMessage item carrying `user_text` in `detail` plus one diff --git a/crates/tui/src/session_tree.rs b/crates/tui/src/session_tree.rs index 1403b18d68..8b934059b9 100644 --- a/crates/tui/src/session_tree.rs +++ b/crates/tui/src/session_tree.rs @@ -158,6 +158,32 @@ impl SessionJournal { pub fn append_message(&mut self, message: Message) -> EntryId { self.append(SessionEntryKind::Message { message }) } + pub fn append_compaction( + &mut self, + summary: String, + tokens_before: Option, + tokens_after: Option, + model: Option, + ) -> EntryId { + self.append(SessionEntryKind::Compaction { + summary, + tokens_before, + tokens_after, + model, + }) + } + pub fn append_branch_summary( + &mut self, + branch_id: String, + summary: String, + parent_branch_id: Option, + ) -> EntryId { + self.append(SessionEntryKind::BranchSummary { + branch_id, + summary, + parent_branch_id, + }) + } pub fn branch_to(&mut self, entry_id: &str) -> Result<(), String> { if self.entries.iter().any(|e| e.id == entry_id) { self.leaf_id = Some(entry_id.to_string()); @@ -521,6 +547,25 @@ mod tests { ); } #[test] + fn compaction_fits() { + let mut j = SessionJournal::new(); + let id = j.append_compaction("summary".into(), Some(1000), Some(100), None); + assert!(j.contains(&id)); + assert!(matches!( + j.leaf().unwrap().kind, + SessionEntryKind::Compaction { .. } + )); + } + #[test] + fn branch_summary_fits() { + let mut j = SessionJournal::new(); + let a = j.append(SessionEntryKind::User { + text: "root".into(), + }); + let b = j.append_branch_summary(a.clone(), "branch summary".into(), None); + assert!(j.contains(&b)); + } + #[test] fn spawn_depth_fork() { let mut j = SessionJournal::with_spawn_depth(1); let forked = j.fork_from(None).unwrap(); diff --git a/crates/tui/src/tools/execution_envelope.rs b/crates/tui/src/tools/execution_envelope.rs index 700410ee5c..aab8ef0853 100644 --- a/crates/tui/src/tools/execution_envelope.rs +++ b/crates/tui/src/tools/execution_envelope.rs @@ -96,6 +96,22 @@ impl ExecutionEnvelope { pub(crate) const fn is_unrestricted(self) -> bool { self.write && self.network && self.shell } + + /// Intersect with another envelope. Used wherever a child envelope is + /// derived from a parent one: every field takes the more restrictive side, + /// so a descendant can never widen an ancestor. + /// + /// Exercised by this module's tests today; the grandchild-derivation path + /// that consumes it in production lands with the ratification UI. + #[must_use] + #[allow(dead_code)] + pub(crate) const fn narrow(self, other: Self) -> Self { + Self { + write: self.write && other.write, + network: self.network && other.network, + shell: self.shell && other.shell, + } + } } /// Tool names whose execution is governed by a different, stricter mechanism @@ -770,4 +786,22 @@ mod tests { ) .expect("a write-capable, shell-capable child keeps its gates"); } + + #[test] + fn narrowing_never_widens() { + let parent = ExecutionEnvelope { + write: false, + network: true, + shell: true, + }; + let child = ExecutionEnvelope { + write: true, + network: false, + shell: true, + }; + let narrowed = parent.narrow(child); + assert!(!narrowed.write, "a child cannot regain the parent's writes"); + assert!(!narrowed.network, "a child cannot regain its own denial"); + assert!(narrowed.shell); + } } diff --git a/crates/tui/src/tools/large_output_router.rs b/crates/tui/src/tools/large_output_router.rs index 1dff2fd035..7f9f93725f 100644 --- a/crates/tui/src/tools/large_output_router.rs +++ b/crates/tui/src/tools/large_output_router.rs @@ -283,6 +283,21 @@ impl WorkshopVariables { self.last_tool_result = raw.to_string(); self.last_tool_name = tool_name.to_string(); } + + /// Retrieve and clear the stored raw output (consume semantics so the + /// variable is not accidentally promoted twice). + /// + /// Called by the `promote_to_context` tool (not yet wired in this PR). + #[must_use] + #[allow(dead_code)] // consumed by promote_to_context tool in follow-up + pub fn take_raw(&mut self) -> Option<(String, String)> { + if self.last_tool_result.is_empty() { + return None; + } + let content = std::mem::take(&mut self.last_tool_result); + let name = std::mem::take(&mut self.last_tool_name); + Some((name, content)) + } } // ── Adaptive evidence routing (#4619) ───────────────────────────────────────── @@ -515,14 +530,17 @@ mod tests { } #[test] - fn workshop_variables_store_raw_records_last_output() { + fn workshop_variables_store_and_take() { let mut vars = WorkshopVariables::default(); - assert!(vars.last_tool_result.is_empty()); - assert!(vars.last_tool_name.is_empty()); + assert!(vars.take_raw().is_none()); vars.store_raw("read_file", "raw content here"); - assert_eq!(vars.last_tool_name, "read_file"); - assert_eq!(vars.last_tool_result, "raw content here"); + let taken = vars.take_raw().expect("should have content"); + assert_eq!(taken.0, "read_file"); + assert_eq!(taken.1, "raw content here"); + + // Second take is empty — consume semantics + assert!(vars.take_raw().is_none()); } #[test] diff --git a/crates/tui/src/tools/verify.rs b/crates/tui/src/tools/verify.rs index ed2e60a06d..c45fd06f94 100644 --- a/crates/tui/src/tools/verify.rs +++ b/crates/tui/src/tools/verify.rs @@ -291,6 +291,17 @@ impl VerifyTool { critic_effort: ReasoningEffort::Max, } } + + /// Override the critic reasoning tier. Values below `High` are clamped up to + /// `High` — elevated reasoning is the whole point of this tool. This is the + /// seam for a future `[verify] critic_effort` config knob; production + /// registration currently uses the `Max` default from [`Self::new`]. + #[allow(dead_code)] + #[must_use] + pub fn with_critic_effort(mut self, effort: ReasoningEffort) -> Self { + self.critic_effort = clamp_to_elevated(effort); + self + } } #[async_trait] @@ -907,6 +918,14 @@ mod tests { assert!(req_max.tools.is_none()); } + #[test] + fn with_critic_effort_clamps_below_high() { + let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Low); + assert_eq!(tool.critic_effort, ReasoningEffort::High); + let tool = VerifyTool::new(None, "m".to_string()).with_critic_effort(ReasoningEffort::Max); + assert_eq!(tool.critic_effort, ReasoningEffort::Max); + } + // === Critic-finds-a-planted-bug (mocked model) === #[tokio::test] diff --git a/crates/tui/src/tui/active_cell.rs b/crates/tui/src/tui/active_cell.rs index 97c1f966ae..ed923f5585 100644 --- a/crates/tui/src/tui/active_cell.rs +++ b/crates/tui/src/tui/active_cell.rs @@ -105,6 +105,15 @@ impl ActiveCell { } } + /// Current revision counter. Wraps on overflow which is fine for cache + /// invalidation; the chance of a wrap-around collision is astronomical + /// over a single session and any miss only causes one extra re-render. + #[must_use] + #[allow(dead_code)] // Used by App::bump_active_cell_revision and future cache wiring. + pub fn revision(&self) -> u64 { + self.revision + } + /// Increment the revision counter. Call any time an entry is mutated. pub fn bump_revision(&mut self) { self.revision = self.revision.wrapping_add(1); @@ -349,11 +358,13 @@ mod tests { } #[test] - fn push_tool_records_entry_at_index() { + fn push_tool_records_entry_and_revision_advances() { let mut cell = ActiveCell::new(); + let r0 = cell.revision(); let idx = cell.push_tool("t1", exec_cell("ls")); assert_eq!(idx, 0); assert_eq!(cell.entry_count(), 1); + assert!(cell.revision() != r0); assert_eq!(cell.entry_index_for_tool("t1"), Some(0)); } @@ -406,9 +417,11 @@ mod tests { #[test] fn push_thinking_records_entry_at_tail() { let mut cell = ActiveCell::new(); + let r0 = cell.revision(); let idx = cell.push_thinking(thinking_cell("planning…", true)); assert_eq!(idx, 0); assert_eq!(cell.entry_count(), 1); + assert!(cell.revision() != r0); } #[test] diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index a7d2af10cc..88dc7d8201 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -4303,6 +4303,7 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { is_diff: false, }))); let app_active_before = app.active_cell_revision; + let cell_active_before = app.active_cell.as_ref().expect("active cell").revision(); app.mark_live_motion_updated(); @@ -4312,9 +4313,14 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { assert_eq!(app.history_revisions[3], history_before[3]); assert_eq!(app.history_revisions[4], history_before[4]); assert_ne!(app.active_cell_revision, app_active_before); + assert_ne!( + app.active_cell.as_ref().expect("active cell").revision(), + cell_active_before + ); let history_after_all_live = app.history_revisions.clone(); let app_active_after_all_live = app.active_cell_revision; + let cell_active_after_all_live = app.active_cell.as_ref().expect("active cell").revision(); app.mark_live_history_motion_updated(); assert_eq!(app.history_revisions[0], history_after_all_live[0]); @@ -4323,6 +4329,10 @@ fn live_motion_invalidation_only_bumps_live_transcript_rows() { assert_eq!(app.history_revisions[3], history_after_all_live[3]); assert_eq!(app.history_revisions[4], history_after_all_live[4]); assert_eq!(app.active_cell_revision, app_active_after_all_live); + assert_eq!( + app.active_cell.as_ref().expect("active cell").revision(), + cell_active_after_all_live + ); } #[test] diff --git a/crates/tui/src/tui/goldens/notifications_100x30.txt b/crates/tui/src/tui/goldens/notifications_100x30.txt new file mode 100644 index 0000000000..e50b7b7808 --- /dev/null +++ b/crates/tui/src/tui/goldens/notifications_100x30.txt @@ -0,0 +1,30 @@ +NOTIFICATIONS · 2 unread + ▸ ◆ approval rm -rf target/ in worktree — 14:41 + whale-2 wants to clean the build directory + ◆ turn done turn surfaced ✓ — 14:38 + ○ whale done whale-3 done — 14:20 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/tui/src/tui/goldens/notifications_120x32.txt b/crates/tui/src/tui/goldens/notifications_120x32.txt new file mode 100644 index 0000000000..756834b5ba --- /dev/null +++ b/crates/tui/src/tui/goldens/notifications_120x32.txt @@ -0,0 +1,32 @@ +NOTIFICATIONS · 2 unread + ▸ ◆ approval rm -rf target/ in worktree — 14:41 + whale-2 wants to clean the build directory + ◆ turn done turn surfaced ✓ — 14:38 + ○ whale done whale-3 done — 14:20 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/tui/src/tui/goldens/notifications_160x40.txt b/crates/tui/src/tui/goldens/notifications_160x40.txt new file mode 100644 index 0000000000..ba46279c2d --- /dev/null +++ b/crates/tui/src/tui/goldens/notifications_160x40.txt @@ -0,0 +1,40 @@ +NOTIFICATIONS · 2 unread + ▸ ◆ approval rm -rf target/ in worktree — 14:41 + whale-2 wants to clean the build directory + ◆ turn done turn surfaced ✓ — 14:38 + ○ whale done whale-3 done — 14:20 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/tui/src/tui/goldens/notifications_80x24.txt b/crates/tui/src/tui/goldens/notifications_80x24.txt new file mode 100644 index 0000000000..2b8ff0bdff --- /dev/null +++ b/crates/tui/src/tui/goldens/notifications_80x24.txt @@ -0,0 +1,24 @@ +NOTIFICATIONS · 2 unread + ▸ ◆ approval rm -rf target/ in worktree — 14:41 + whale-2 wants to clean the build directory + ◆ turn done turn surfaced ✓ — 14:38 + ○ whale done whale-3 done — 14:20 + + + + + + + + + + + + + + + + + + + diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index fa41fb1e9e..3006cc2c7f 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -3200,6 +3200,7 @@ pub(crate) mod tideline_exports { pub use super::tideline_stream::{ TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream, + tideline_stream_hitboxes, }; } diff --git a/crates/tui/src/tui/history/tideline_stream.rs b/crates/tui/src/tui/history/tideline_stream.rs index c1abb363f4..39738d028b 100644 --- a/crates/tui/src/tui/history/tideline_stream.rs +++ b/crates/tui/src/tui/history/tideline_stream.rs @@ -289,3 +289,37 @@ pub fn render_tideline_stream(area: Rect, buf: &mut Buffer, stream: &TidelineStr ); } } + +/// Row hitboxes for the stream (transcript click path, spec §6): one rect +/// per event, fleet trees spanning their edges. +#[must_use] +#[allow(dead_code)] // translation scaffolding: wired by the landing slice +pub fn tideline_stream_hitboxes(area: Rect, stream: &TidelineStream<'_>) -> Vec { + let mut out = Vec::new(); + if area.width < 10 || area.height < 1 { + return out; + } + let legend_reserve = if area.height > 2 { 1 } else { 0 }; + let body_height = area.height - legend_reserve; + let mut y = area.y; + let bottom = area.y + body_height; + for event in stream.events { + if y >= bottom { + break; + } + let height = match event { + TidelineStreamEvent::PodFormation { edges } => edges.len().max(1) as u16, + _ => 1, + }; + if y + height <= bottom { + out.push(Rect { + x: area.x, + y, + width: area.width, + height, + }); + } + y += height; + } + out +} diff --git a/crates/tui/src/tui/history/tideline_stream_tests.rs b/crates/tui/src/tui/history/tideline_stream_tests.rs index edcdf2ebff..73eec1f021 100644 --- a/crates/tui/src/tui/history/tideline_stream_tests.rs +++ b/crates/tui/src/tui/history/tideline_stream_tests.rs @@ -2,7 +2,7 @@ //! stage suite lives in `work_surface::tideline::tests` — both components //! share the reserved `work_*` golden names). -use super::tideline_stream::TidelineReceiptState; +use super::tideline_stream::{TidelineReceiptState, TidelineStreamEvent, tideline_stream_hitboxes}; use crate::palette::ChromeInk; #[test] @@ -30,3 +30,13 @@ fn receipt_states_pair_marks_with_words_and_ink_families() { } } } + +#[test] +fn stream_hitboxes_empty_on_degenerate_area() { + let events = vec![TidelineStreamEvent::UserTurn { + text: "x".to_string(), + }]; + let theme = crate::palette::UI_THEME; + let stream = super::tideline_stream::TidelineStream::new(&theme, &events); + assert!(tideline_stream_hitboxes(ratatui::layout::Rect::new(0, 0, 2, 1), &stream).is_empty()); +} diff --git a/crates/tui/src/tui/notifications.rs b/crates/tui/src/tui/notifications.rs index 5613e5058b..2f8bcbd4ee 100644 --- a/crates/tui/src/tui/notifications.rs +++ b/crates/tui/src/tui/notifications.rs @@ -2431,3 +2431,265 @@ mod tests { COMPLETION_SOUND_FILE_MISSING_WARNED.store(false, Ordering::SeqCst); } } + +// --------------------------------------------------------------------------- +// Tideline notifications inbox (spec §5a "Notifications inbox"): the +// attention surface that replaces the toast soup. Records are typed — the +// same `NotificationKind` disclosure policy the desktop payloads use — and +// the unread mark is the sanctioned gold ◆. Translation scaffolding in the +// topbar mold: a pure deterministic widget over injected records (`App` +// projects `status_toasts`/`sticky_status` into it at the landing slice); +// not wired into `ui/frame.rs` (#5698 gate). + +use ratatui::{ + buffer::Buffer, + layout::Rect, + style::{Modifier, Style}, +}; +use unicode_width::UnicodeWidthStr; + +use crate::palette::{ChromeInk, UiTheme, chrome_style}; + +/// One attention record: a typed projection of a status toast / sticky +/// status / desktop payload. `at` is an injected clock string so renders +/// stay deterministic (spec §5a: caller owns the wall clock). +#[derive(Debug, Clone)] +#[allow(dead_code)] // translation scaffolding: wired by the landing slice +pub struct TidelineInboxRecord { + pub kind: NotificationKind, + pub title: String, + /// One-line body, already disclosure-approved per kind. + pub body: Option, + /// Wall-clock label, e.g. `14:42`. + pub at: String, + pub read: bool, +} + +impl TidelineInboxRecord { + /// Kind word — a noun, never "Error" (spec §7 failure microcopy rule). + #[must_use] + pub fn kind_word(&self) -> &'static str { + match self.kind { + NotificationKind::TurnComplete => "turn done", + NotificationKind::SubagentTerminal => "whale done", + NotificationKind::ApprovalNeeded => "approval", + NotificationKind::InputNeeded => "question", + NotificationKind::ElevationNeeded => "sandbox", + NotificationKind::ModelNotify => "notify", + } + } + + /// Per-kind ink per the §5d table: interactive asks read as cognition + /// (permission family), completions as outcome, terminal whales as info. + #[must_use] + pub fn kind_ink(&self) -> ChromeInk { + match self.kind { + NotificationKind::TurnComplete => ChromeInk::Outcome, + NotificationKind::SubagentTerminal => ChromeInk::Info, + NotificationKind::ApprovalNeeded | NotificationKind::InputNeeded => { + ChromeInk::PermissionAsk + } + NotificationKind::ElevationNeeded => ChromeInk::PermissionFullAccess, + NotificationKind::ModelNotify => ChromeInk::MetadataValue, + } + } +} + +/// What the caller owes the inbox render. +#[allow(dead_code)] // translation scaffolding: wired by the landing slice +pub struct TidelineInbox<'a> { + pub theme: &'a UiTheme, + pub records: &'a [TidelineInboxRecord], + /// Selected row (Enter inspects, `r` marks read, Esc backs out). + pub selected: usize, + pub ascii_safe: bool, +} + +#[allow(dead_code)] // translation scaffolding: builder methods feed tests + the landing slice +impl<'a> TidelineInbox<'a> { + #[allow(dead_code)] // translation scaffolding: wired by the landing slice + #[must_use] + pub fn new(theme: &'a UiTheme, records: &'a [TidelineInboxRecord]) -> Self { + Self { + theme, + records, + selected: 0, + ascii_safe: false, + } + } + + #[must_use] + pub fn selected(mut self, selected: usize) -> Self { + self.selected = selected; + self + } + + #[must_use] + pub fn ascii_safe(mut self, ascii_safe: bool) -> Self { + self.ascii_safe = ascii_safe; + self + } + + fn sym(&self, glyph: &str) -> String { + if !self.ascii_safe { + return glyph.to_string(); + } + if let Some(fb) = crate::tui::glyphs::ascii_fallback(glyph) { + return fb.to_string(); + } + glyph + .chars() + .map(|c| { + crate::tui::glyphs::ascii_fallback(&c.to_string()) + .map(str::to_string) + .unwrap_or_else(|| c.to_string()) + }) + .collect() + } +} + +fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style { + chrome_style(theme, ink) +} + +fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) { + buf.set_stringn(x, y, text, text.width(), style); +} + +/// Paint the notifications inbox: header row (count of unread), then one +/// row per record — unread gold ◆, read hollow ○, selected `▸`, kind word, +/// title, injected time. Truncates, never wraps. +#[allow(dead_code)] // translation scaffolding: wired by the landing slice +pub fn render_tideline_inbox(area: Rect, buf: &mut Buffer, inbox: &TidelineInbox<'_>) { + if area.width < 8 || area.height < 2 { + return; + } + let theme = inbox.theme; + let unread = inbox.records.iter().filter(|record| !record.read).count(); + let header = if unread == 0 { + "NOTIFICATIONS".to_string() + } else { + format!("NOTIFICATIONS · {unread} unread") + }; + put( + buf, + area.x, + area.y, + &header, + chrome(theme, ChromeInk::Metadata).add_modifier(Modifier::BOLD), + ); + + if inbox.records.is_empty() { + put( + buf, + area.x, + area.y + 1, + "quiet water — nothing needs you", + chrome(theme, ChromeInk::MetadataHint), + ); + return; + } + + let width = area.width as usize; + let mut y = area.y + 1; + for (index, record) in inbox.records.iter().enumerate() { + if y >= area.y + area.height { + break; + } + let selected = inbox.selected == index; + let marker = if selected { "▸ " } else { " " }; + let mark = if record.read { "○" } else { "◆" }; + let mark_ink = if record.read { + ChromeInk::MetadataDim + } else { + ChromeInk::Attention + }; + let row = format!("{} {} — {}", record.kind_word(), record.title, record.at); + let row = truncate_to_width_owned(&inbox.sym(&row), width.saturating_sub(6)); + put( + buf, + area.x + 2, + y, + &inbox.sym(marker), + chrome(theme, ChromeInk::Identity), + ); + put( + buf, + area.x + 4, + y, + &inbox.sym(mark), + chrome(theme, mark_ink), + ); + let mut style = chrome(theme, record.kind_ink()); + if record.read { + style = chrome(theme, ChromeInk::MetadataDim); + } + if selected { + style = style.add_modifier(Modifier::BOLD); + } + put(buf, area.x + 6, y, &row, style); + // The selected record's approved body earns its own indented row — + // the inspect affordance; other bodies stay collapsed. + if selected + && let Some(body) = record.body.as_deref() + && y + 1 < area.y + area.height + { + put( + buf, + area.x + 8, + y + 1, + &truncate_to_width_owned(&inbox.sym(body), width.saturating_sub(10)), + chrome(theme, ChromeInk::MetadataHint), + ); + y += 1; + } + y += 1; + } +} + +fn truncate_to_width_owned(text: &str, width: usize) -> String { + let mut out = String::new(); + let mut used = 0; + for ch in text.chars() { + let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + w > width { + break; + } + out.push(ch); + used += w; + } + out +} + +/// Row hitboxes for one render (spec §6): one rect per record, matching the +/// painted rows exactly — the selected record's body row belongs to its +/// rect. Must be called with the same inputs as [`render_tideline_inbox`]. +#[must_use] +#[allow(dead_code)] // translation scaffolding: wired by the landing slice +pub fn tideline_inbox_hitboxes(area: Rect, inbox: &TidelineInbox<'_>) -> Vec { + let mut out = Vec::new(); + if area.width < 8 || area.height < 2 { + return out; + } + let mut y = area.y + 1; + for (index, record) in inbox.records.iter().enumerate() { + let mut height = 1; + if inbox.selected == index && record.body.is_some() { + height = 2; + } + if y + height > area.y + area.height { + break; + } + out.push(Rect { + x: area.x + 2, + y, + width: area.width.saturating_sub(2), + height, + }); + y += height; + } + out +} + +#[cfg(test)] +mod tideline_tests; diff --git a/crates/tui/src/tui/notifications/tideline_tests.rs b/crates/tui/src/tui/notifications/tideline_tests.rs new file mode 100644 index 0000000000..bc25199c67 --- /dev/null +++ b/crates/tui/src/tui/notifications/tideline_tests.rs @@ -0,0 +1,181 @@ +//! Golden-buffer contract for the Tideline notifications inbox (spec §5a/ +//! §5c). Goldens: `notifications_{w}x{h}` at the four blocker sizes. +//! Re-bless with `CODEWHALE_BLESS_GOLDENS=1`. + +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use unicode_width::UnicodeWidthChar; + +use super::{ + NotificationKind, TidelineInbox, TidelineInboxRecord, render_tideline_inbox, + tideline_inbox_hitboxes, +}; +use crate::palette::UI_THEME; +use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; + +fn record(kind: NotificationKind, title: &str, at: &str, read: bool) -> TidelineInboxRecord { + TidelineInboxRecord { + kind, + title: title.to_string(), + body: None, + at: at.to_string(), + read, + } +} + +/// The approved inbox fixture: one interactive ask, one completion, one read +/// terminal whale, second row selected (so its body row shows). +fn records() -> Vec { + vec![ + TidelineInboxRecord { + kind: NotificationKind::ApprovalNeeded, + title: "rm -rf target/ in worktree".to_string(), + body: Some("whale-2 wants to clean the build directory".to_string()), + at: "14:41".to_string(), + read: false, + }, + record( + NotificationKind::TurnComplete, + "turn surfaced ✓", + "14:38", + false, + ), + record( + NotificationKind::SubagentTerminal, + "whale-3 done", + "14:20", + true, + ), + ] +} + +fn draw(width: u16, height: u16, inbox: &TidelineInbox<'_>) -> String { + render_golden_text(width, height, |buf| { + render_tideline_inbox(Rect::new(0, 0, width, height), buf, inbox); + }) +} + +#[test] +fn notifications_matches_goldens_at_blocker_sizes() { + for (w, h) in BLOCKER_SIZES { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); + assert_matches_golden(&format!("notifications_{w}x{h}"), &draw(w, h, &inbox)); + } +} + +#[test] +fn notifications_header_counts_unread() { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs); + let text = draw(80, 24, &inbox); + assert!(text.contains("NOTIFICATIONS · 2 unread"), "{text}"); + let all_read: Vec = recs + .iter() + .map(|r| TidelineInboxRecord { + read: true, + ..r.clone() + }) + .collect(); + let text = draw(80, 24, &TidelineInbox::new(&UI_THEME, &all_read)); + let header = text.lines().next().unwrap_or_default().trim(); + assert_eq!(header, "NOTIFICATIONS", "read inbox drops the count"); +} + +#[test] +fn notifications_unread_rows_carry_the_gold_mark() { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs); + let text = draw(80, 24, &inbox); + assert!( + text.contains("◆ approval"), + "unread ask is gold-marked: {text}" + ); + assert!(text.contains("○ whale done"), "read row is hollow: {text}"); +} + +#[test] +fn notifications_selected_body_replaces_not_doubles() { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); + let text = draw(80, 24, &inbox); + assert!( + text.contains("whale-2 wants to clean"), + "selected body row: {text}" + ); + // Row 2 (the completion) must still be present exactly once. + assert_eq!(text.matches("turn surfaced").count(), 1, "{text}"); +} + +#[test] +fn notifications_empty_state_is_quiet_not_blank() { + let inbox = TidelineInbox::new(&UI_THEME, &[]); + let text = draw(80, 24, &inbox); + assert!(text.contains("quiet water"), "{text}"); +} + +#[test] +fn notifications_ascii_safe_projects_marks() { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs).ascii_safe(true); + let text = draw(80, 24, &inbox); + assert!(text.contains("* approval"), "gold ◆ projects to *: {text}"); + assert!( + text.contains(". whale done"), + "read ○ projects to .: {text}" + ); + for ch in text.chars() { + if ch != '\n' { + assert_eq!(ch.width(), Some(1), "ascii-safe single-width: {ch:?}"); + } + } +} + +#[test] +fn notifications_hitboxes_match_painted_rows() { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs).selected(0); + let (w, h) = (100, 30); + let area = Rect::new(0, 0, w, h); + let mut buf = Buffer::empty(area); + render_tideline_inbox(area, &mut buf, &inbox); + let hitboxes = tideline_inbox_hitboxes(area, &inbox); + assert_eq!(hitboxes.len(), 3, "one rect per record"); + // First rect covers the selected record's body row. + assert_eq!(hitboxes[0].height, 2); + // No overlaps, all inside, all cover painted text. + for pair in hitboxes.windows(2) { + assert!(pair[0].y + pair[0].height <= pair[1].y, "no overlap"); + } + for rect in &hitboxes { + let cells: String = (rect.x..rect.x + rect.width) + .map(|x| buf[(x, rect.y)].symbol().to_string()) + .collect(); + assert!(!cells.trim().is_empty(), "rect {rect:?} covers empty cells"); + } +} + +#[test] +fn notifications_kind_words_never_say_error() { + for kind in [ + NotificationKind::TurnComplete, + NotificationKind::SubagentTerminal, + NotificationKind::ApprovalNeeded, + NotificationKind::InputNeeded, + NotificationKind::ElevationNeeded, + NotificationKind::ModelNotify, + ] { + let rec = record(kind, "t", "00:00", false); + assert_ne!(rec.kind_word(), "error"); + assert_ne!(rec.kind_word(), "Error"); + } +} + +#[test] +fn notifications_degenerate_sizes_do_not_panic() { + for (w, h) in [(0u16, 0), (4, 1), (8, 2), (200, 50)] { + let recs = records(); + let inbox = TidelineInbox::new(&UI_THEME, &recs); + let _ = draw(w, h, &inbox); + } +} diff --git a/crates/tui/src/tui/transcript_cache.rs b/crates/tui/src/tui/transcript_cache.rs index 9ed5852c35..bb9fca8fec 100644 --- a/crates/tui/src/tui/transcript_cache.rs +++ b/crates/tui/src/tui/transcript_cache.rs @@ -121,6 +121,14 @@ impl TranscriptCache { self.insertion_order.push_back(key); } + /// Drop every cached entry. Used when the underlying transcript shape + /// changes drastically (e.g. session reset). + #[allow(dead_code)] // Reserved for /clear and session-reset call sites. + pub fn clear(&mut self) { + self.entries.clear(); + self.insertion_order.clear(); + } + #[cfg(test)] pub fn len(&self) -> usize { self.entries.len() @@ -214,4 +222,13 @@ mod tests { assert!(cache.get(CellId::History(2), 80, 1).is_some()); assert_eq!(cache.len(), 2); } + + #[test] + fn clear_drops_everything() { + let mut cache = TranscriptCache::new(); + cache.insert(CellId::History(0), 80, 1, vec![line("v1")]); + cache.clear(); + assert!(cache.get(CellId::History(0), 80, 1).is_none()); + assert_eq!(cache.len(), 0); + } } diff --git a/crates/tui/src/tui/whales.rs b/crates/tui/src/tui/whales.rs index c7c5688f2e..4c36b893c2 100644 --- a/crates/tui/src/tui/whales.rs +++ b/crates/tui/src/tui/whales.rs @@ -16,8 +16,8 @@ //! roles map onto the six species; roles without a species (`worker`, //! `general`, `custom`, unknown) render the plain Codewhale whale. //! - **State is evidence, never decoration.** [`WhaleState`] is derived only -//! from real runtime facts ([`WhaleState::for_subagent`]). *Working* is -//! asserted only for a child +//! from real runtime facts ([`WhaleState::for_subagent`], +//! [`WhaleState::for_shell_phase`]). *Working* is asserted only for a child //! or turn that is actually running. //! - **Every state pairs a glyph cue with a word** ([`WhaleState::word`]), so //! state never depends on color alone (same rule as `menu_style`). @@ -38,6 +38,7 @@ use crate::palette::{self, UiTheme}; use crate::tools::subagent::{AgentWorkerStatus, FleetRole, SubAgentResult, SubAgentStatus}; use crate::tui::glyphs; use crate::tui::motion::mode::MotionMode; +use crate::tui::underwater::ShellPhase; /// Cells occupied by a badge (species mark + body). pub const BADGE_WIDTH: usize = 2; @@ -248,6 +249,20 @@ impl WhaleState { SubAgentStatus::Cancelled => Self::Offline, } } + + /// State from the operator session phase. Public contract for the shell + /// header / Fleet setup role pane (no consumer in this lane yet). + #[allow(dead_code)] + #[must_use] + pub const fn for_shell_phase(phase: ShellPhase) -> Self { + match phase { + ShellPhase::Idle | ShellPhase::Done => Self::Resting, + ShellPhase::Typing => Self::Thinking, + ShellPhase::Working | ShellPhase::Verifying => Self::Working, + ShellPhase::Waiting | ShellPhase::Approval => Self::Waiting, + ShellPhase::Failed => Self::Blocked, + } + } } /// Resolved inks for one theme. Accents are contrast-enforced against the @@ -593,6 +608,42 @@ mod tests { assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); } + #[test] + fn shell_phase_maps_without_inventing_work() { + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Idle), + WhaleState::Resting + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Done), + WhaleState::Resting + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Typing), + WhaleState::Thinking + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Working), + WhaleState::Working + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Verifying), + WhaleState::Working + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Waiting), + WhaleState::Waiting + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Approval), + WhaleState::Waiting + ); + assert_eq!( + WhaleState::for_shell_phase(ShellPhase::Failed), + WhaleState::Blocked + ); + } + #[test] fn badge_accents_meet_secondary_chrome_contrast_on_dark_and_light() { // Flat Whale shells are terminal-owned and therefore deliberately diff --git a/crates/tui/src/tui/work_surface/tideline/tests.rs b/crates/tui/src/tui/work_surface/tideline/tests.rs index 1300eb21f9..ec9e70b93e 100644 --- a/crates/tui/src/tui/work_surface/tideline/tests.rs +++ b/crates/tui/src/tui/work_surface/tideline/tests.rs @@ -17,6 +17,7 @@ use crate::palette::UI_THEME; use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; use crate::tui::history::tideline_exports::{ TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream, + tideline_stream_hitboxes, }; fn events() -> Vec { @@ -184,6 +185,7 @@ fn work_stage_hitboxes_match_painted_rows() { let groups = groups(); let events = events(); let rail = TidelineRail::new(&UI_THEME, &groups).focused(true); + let stream = TidelineStream::new(&UI_THEME, &events).selected(Some(3)); let stage = TidelineWorkStage { rail: TidelineRail::new(&UI_THEME, &groups).focused(true), stream: TidelineStream::new(&UI_THEME, &events).selected(Some(3)), @@ -200,6 +202,18 @@ fn work_stage_hitboxes_match_painted_rows() { .collect(); assert!(!cells.trim().is_empty(), "rail rect covers empty cells"); } + + let stream_area = Rect::new(22, 0, w - 22, h); + let stream_boxes = tideline_stream_hitboxes(stream_area, &stream); + assert_eq!(stream_boxes.len(), events.len(), "one rect per event"); + // The fleet tree's rect spans its three edge rows. + assert_eq!(stream_boxes[1].height, 3); + for rect in &stream_boxes { + let cells: String = (rect.x..rect.x + rect.width) + .map(|x| buf[(x, rect.y)].symbol().to_string()) + .collect(); + assert!(!cells.trim().is_empty(), "stream rect covers empty cells"); + } } #[test] diff --git a/crates/workflow/src/fleet_preflight.rs b/crates/workflow/src/fleet_preflight.rs index be8b376e87..a441d0cb31 100644 --- a/crates/workflow/src/fleet_preflight.rs +++ b/crates/workflow/src/fleet_preflight.rs @@ -223,6 +223,17 @@ impl RoutePreflight { self.workers.iter().find(|route| route.member_id == key) } + /// Fail unless every worker route is runnable. + /// + /// Called before a Workflow is allowed to start, so a member with no + /// credential is a startup error rather than a first-task surprise. + pub fn require_all_ready(&self) -> Result<(), PreflightError> { + for route in &self.workers { + route.require_ready()?; + } + Ok(()) + } + /// Whether any worker route talks to a different provider than the Router. /// This is what a receipt discloses as cross-provider inference. #[must_use] @@ -353,8 +364,9 @@ mod tests { detail: "no ZAI_API_KEY".to_string(), }; - let err = route - .require_ready() + let preflight = RoutePreflight::new(vec![route], None); + let err = preflight + .require_all_ready() .expect_err("a member with no credential must not start"); assert!(matches!(err, PreflightError::CredentialMissing { .. })); let message = err.to_string(); diff --git a/crates/workflow/src/fleet_snapshot.rs b/crates/workflow/src/fleet_snapshot.rs index 53b7e59a01..3cc72410f3 100644 --- a/crates/workflow/src/fleet_snapshot.rs +++ b/crates/workflow/src/fleet_snapshot.rs @@ -431,6 +431,18 @@ fn exact_members(exact: &ExactFleet) -> Vec { .collect() } +/// Verify a snapshot that arrived from anywhere other than [`FleetSnapshot::capture`]. +/// +/// The free function exists for load/deserialize seams that hold a snapshot by +/// reference and only need the yes/no answer — a durable-write guard, a replay +/// loader, a cache read. It is the same check as +/// [`FleetSnapshot::verify_content_hash`]; having a named entry point is what +/// lets those call sites read as "verify before use" rather than as an +/// incidental method call. +pub fn verify_snapshot_content_hash(snapshot: &FleetSnapshot) -> Result<(), ExactFleetError> { + snapshot.verify_content_hash() +} + /// Normalize an exact Fleet's **legacy inline** Router into the captured /// service, if it used the prototype form. /// @@ -488,6 +500,7 @@ permissions = "analyst" fn a_freshly_captured_snapshot_verifies() { let snapshot = captured(); assert!(snapshot.verify_content_hash().is_ok()); + assert!(verify_snapshot_content_hash(&snapshot).is_ok()); assert!(snapshot.into_verified().is_ok()); } diff --git a/crates/workflow/src/gates.rs b/crates/workflow/src/gates.rs index 1b6e51c8de..b604ce1ec0 100644 --- a/crates/workflow/src/gates.rs +++ b/crates/workflow/src/gates.rs @@ -13,6 +13,7 @@ //! wire in later; unit tests cover block/approve/retry/escalate paths. use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -254,6 +255,20 @@ impl LaneGateBoard { Ok(()) } + /// Latest handoff of `kind` from `from_role` to `to_role`, if any. + pub fn latest_handoff( + &self, + from_role: &str, + to_role: &str, + kind: &str, + ) -> Option<&HandoffArtifact> { + self.artifacts.iter().rev().find(|a| { + a.from_role.eq_ignore_ascii_case(from_role) + && a.to_role.eq_ignore_ascii_case(to_role) + && a.kind.eq_ignore_ascii_case(kind) + }) + } + /// Remove and return the latest handoffs addressed to `to_role` (newest /// first), up to `limit`. /// @@ -280,6 +295,22 @@ impl LaneGateBoard { consumed } + /// Persist board under a lane directory (JSON). + pub fn save_to_dir(&self, dir: &Path) -> Result { + std::fs::create_dir_all(dir).map_err(|e| GateError::Io(e.to_string()))?; + let path = dir.join("gates.json"); + let json = + serde_json::to_string_pretty(self).map_err(|e| GateError::Serde(e.to_string()))?; + std::fs::write(&path, json).map_err(|e| GateError::Io(e.to_string()))?; + Ok(path) + } + + pub fn load_from_dir(dir: &Path) -> Result { + let path = dir.join("gates.json"); + let text = std::fs::read_to_string(&path).map_err(|e| GateError::Io(e.to_string()))?; + serde_json::from_str(&text).map_err(|e| GateError::Serde(e.to_string())) + } + /// Compact status for `lane status` / panel surfaces. pub fn status_summary(&self) -> Vec { self.gates @@ -307,11 +338,16 @@ pub enum GateError { EmptyGateId, #[error("handoff lane id `{got}` does not match board lane `{expected}`")] LaneMismatch { expected: String, got: String }, + #[error("io error: {0}")] + Io(String), + #[error("serde error: {0}")] + Serde(String), } #[cfg(test)] mod tests { use super::*; + use tempfile::tempdir; /// Inline copy of the canonical stopship-style gate specs (scout → /// implementer → reviewer → verifier → release_lead). The production @@ -373,6 +409,12 @@ mod tests { }) .unwrap(); + let art = board + .latest_handoff("scout", "implementer", "findings") + .expect("findings artifact"); + assert_eq!(art.id, "art-1"); + assert!(art.payload.contains("4090")); + // Approve scout gate so implementer unblocks. let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap(); assert_eq!(state, GateState::Passed); @@ -509,4 +551,27 @@ mod tests { assert_eq!(state, GateState::Passed); assert!(board.role_is_blocked(&gates, "verifier").is_none()); } + + #[test] + fn board_persists_for_lane_status() { + let dir = tempdir().unwrap(); + let mut board = LaneGateBoard::new("lane-persist"); + board.install_gates(&stopship_gate_specs()); + board + .evaluate( + &stopship_gate_specs()[0], + GateOutcome::Fail { + reason: "scout incomplete".into(), + }, + ) + .unwrap(); + board.save_to_dir(dir.path()).unwrap(); + let loaded = LaneGateBoard::load_from_dir(dir.path()).unwrap(); + let summary = loaded.status_summary(); + assert!( + summary + .iter() + .any(|l| l.gate_id == "scout-findings" && l.state == "blocked") + ); + } } diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index 84ed470c87..ea93936099 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -19,9 +19,11 @@ pub mod fleet_reasoning; pub mod fleet_snapshot; mod gates; mod js_authoring; +mod model_policy; mod named_fleet; pub mod reasoning_router; pub mod redaction; +mod review_repair; mod role_resolve; use std::collections::BTreeSet; @@ -54,7 +56,7 @@ pub use fleet_reasoning::{ }; pub use fleet_snapshot::{ FleetSnapshot, FleetSnapshotLegacyRole, FleetSnapshotMember, FleetSnapshotRouter, - QualifiedFleetId, captured_legacy_inline_router, + QualifiedFleetId, captured_legacy_inline_router, verify_snapshot_content_hash, }; pub use gates::{ GateError, GateKind, GateOn, GateOnFail, GateOutcome, GateSpec, GateState, GateStatusLine, @@ -64,6 +66,7 @@ pub use js_authoring::{ JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow, compile_typescript_workflow, }; +pub use model_policy::*; pub use named_fleet::{ FleetDocument, FleetSchema, FleetSearchRoot, NamedFleet, NamedFleetError, STOPSHIP_REQUIRED_ROLES, exact_schema_revision, load_named_fleet, load_named_fleet_file, @@ -78,6 +81,10 @@ pub use redaction::{ REDACTION_ABSOLUTE_PATH, REDACTION_RELATIVE_PATH, REDACTION_SECRET, Redaction, redact_for_disclosure, }; +pub use review_repair::{ + IterationReceipt, IterationVerdict, ReviewRepairBounds, ReviewRepairError, ReviewRepairLoop, + ReviewRepairPolicy, RouteReceipt, RoutedBy, StopReason, +}; pub use role_resolve::{ FleetRoleMap, FleetRoleResolveError, ResolvedWorkflowAgent, normalize_token, resolve_workflow_agent, validate_role_token, diff --git a/crates/workflow/src/model_policy.rs b/crates/workflow/src/model_policy.rs new file mode 100644 index 0000000000..70754f2128 --- /dev/null +++ b/crates/workflow/src/model_policy.rs @@ -0,0 +1,654 @@ +use std::collections::BTreeMap; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{AgentType, ModelPolicy, WorkflowUsage}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelRole { + Planner, + LeafReasoner, + Implementer, + Reviewer, + Teacher, + Student, + JsonExtractor, +} + +impl From for ModelRole { + fn from(agent_type: AgentType) -> Self { + match agent_type { + AgentType::General | AgentType::Explore => Self::LeafReasoner, + AgentType::Plan => Self::Planner, + AgentType::Review | AgentType::Verifier => Self::Reviewer, + AgentType::Implementer => Self::Implementer, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct ModelCapabilities { + #[serde(default)] + pub tool_calls: bool, + #[serde(default)] + pub json_mode: bool, + #[serde(default)] + pub prompt_cache: bool, + #[serde(default)] + pub large_context: bool, + #[serde(default)] + pub streaming: bool, +} + +impl ModelCapabilities { + #[must_use] + pub fn satisfies(self, required: Self) -> bool { + (!required.tool_calls || self.tool_calls) + && (!required.json_mode || self.json_mode) + && (!required.prompt_cache || self.prompt_cache) + && (!required.large_context || self.large_context) + && (!required.streaming || self.streaming) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProviderModel { + pub provider: String, + pub model: String, + #[serde(default)] + pub capabilities: ModelCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResolvedModel { + pub role: ModelRole, + pub provider: String, + pub model: String, + pub capabilities: ModelCapabilities, + pub source: ModelSelectionSource, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ModelSelectionSource { + Primary, + Fallback, + RoleDefault, +} + +#[derive(Debug, Clone, Default)] +pub struct ProviderRegistry { + models: BTreeMap, + role_policies: BTreeMap, +} + +impl ProviderRegistry { + pub fn new() -> Self { + Self::default() + } + + pub fn with_model(mut self, model: ProviderModel) -> Self { + self.insert_model(model); + self + } + + pub fn with_role_policy(mut self, role: ModelRole, policy: ModelPolicy) -> Self { + self.role_policies.insert(role, policy); + self + } + + pub fn insert_model(&mut self, model: ProviderModel) { + self.models + .insert(model_key(&model.provider, &model.model), model); + } + + pub fn resolve_role( + &self, + role: ModelRole, + policy: Option<&ModelPolicy>, + required: ModelCapabilities, + ) -> Result { + let policy = match policy { + Some(policy) => (policy, ModelSelectionSource::Primary), + None => ( + self.role_policies + .get(&role) + .ok_or(ModelPolicyError::MissingPolicy { role })?, + ModelSelectionSource::RoleDefault, + ), + }; + self.resolve_policy(role, policy.0, policy.1, required) + } + + fn resolve_policy( + &self, + role: ModelRole, + policy: &ModelPolicy, + primary_source: ModelSelectionSource, + required: ModelCapabilities, + ) -> Result { + let candidates = model_candidates(policy)?; + let mut rejected = Vec::new(); + for (index, candidate) in candidates.iter().enumerate() { + let source = if index == 0 { + primary_source + } else { + ModelSelectionSource::Fallback + }; + let Some(model) = self + .models + .get(&model_key(&candidate.provider, &candidate.model)) + else { + rejected.push(format!( + "{}/{}: unknown", + candidate.provider, candidate.model + )); + continue; + }; + if model.capabilities.satisfies(required) { + return Ok(ResolvedModel { + role, + provider: model.provider.clone(), + model: model.model.clone(), + capabilities: model.capabilities, + source, + }); + } + rejected.push(format!( + "{}/{}: missing required capabilities", + model.provider, model.model + )); + } + Err(ModelPolicyError::NoCapableModel { role, rejected }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionRequest { + pub role: ModelRole, + pub prompt: String, + #[serde(default)] + pub require_json: bool, + #[serde(default)] + pub model_policy: ModelPolicy, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CompletionResponse { + pub text: String, + #[serde(default)] + pub usage: WorkflowUsage, +} + +pub trait ModelProvider { + fn provider(&self) -> &str; + fn model(&self) -> &str; + fn capabilities(&self) -> ModelCapabilities; + fn complete( + &self, + request: &CompletionRequest, + ) -> Result; +} + +#[derive(Debug, Clone)] +pub struct MockModelProvider { + provider: String, + model: String, + capabilities: ModelCapabilities, + response: CompletionResponse, +} + +impl MockModelProvider { + pub fn new( + provider: impl Into, + model: impl Into, + capabilities: ModelCapabilities, + response: impl Into, + ) -> Self { + Self { + provider: provider.into(), + model: model.into(), + capabilities, + response: CompletionResponse { + text: response.into(), + usage: WorkflowUsage::default(), + }, + } + } +} + +impl ModelProvider for MockModelProvider { + fn provider(&self) -> &str { + &self.provider + } + + fn model(&self) -> &str { + &self.model + } + + fn capabilities(&self) -> ModelCapabilities { + self.capabilities + } + + fn complete( + &self, + _request: &CompletionRequest, + ) -> Result { + Ok(self.response.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ModelPolicyError { + #[error("no model policy configured for role `{role:?}`")] + MissingPolicy { role: ModelRole }, + #[error("model policy must include a model for role resolution")] + MissingModel, + #[error("fallback model `{model}` requires a provider when the primary policy has none")] + MissingFallbackProvider { model: String }, + #[error("no configured model satisfies role `{role:?}` requirements: {rejected:?}")] + NoCapableModel { + role: ModelRole, + rejected: Vec, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ModelProviderError { + #[error("model provider `{provider}/{model}` failed: {reason}")] + Failed { + provider: String, + model: String, + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum JsonRepairError { + #[error("json parse failed before and after one repair pass: {reason}")] + Parse { reason: String }, +} + +pub fn parse_json_with_repair(raw: &str) -> Result { + match serde_json::from_str(raw) { + Ok(parsed) => Ok(parsed), + Err(first) => { + let repaired = repair_json_text_once(raw); + serde_json::from_str(&repaired).map_err(|second| JsonRepairError::Parse { + reason: format!("{first}; repair failed: {second}"), + }) + } + } +} + +pub fn repair_json_text_once(raw: &str) -> String { + let trimmed = raw.trim(); + let without_fence = trimmed + .strip_prefix("```json") + .or_else(|| trimmed.strip_prefix("```")) + .and_then(|value| value.strip_suffix("```")) + .map(str::trim) + .unwrap_or(trimmed); + + first_valid_json_payload(without_fence) + .unwrap_or(without_fence) + .to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ModelCandidate { + provider: String, + model: String, +} + +fn model_candidates(policy: &ModelPolicy) -> Result, ModelPolicyError> { + let mut candidates = Vec::new(); + let Some(primary_model) = policy.model.as_ref() else { + return Err(ModelPolicyError::MissingModel); + }; + candidates.push(candidate_from_model( + policy.provider.as_deref(), + primary_model, + )?); + for fallback in &policy.fallback_models { + candidates.push(candidate_from_model(policy.provider.as_deref(), fallback)?); + } + Ok(candidates) +} + +fn candidate_from_model( + default_provider: Option<&str>, + model: &str, +) -> Result { + if let Some((provider, model)) = model.split_once('/') { + return Ok(ModelCandidate { + provider: provider.to_string(), + model: model.to_string(), + }); + } + let Some(provider) = default_provider else { + return Err(ModelPolicyError::MissingFallbackProvider { + model: model.to_string(), + }); + }; + Ok(ModelCandidate { + provider: provider.to_string(), + model: model.to_string(), + }) +} + +fn model_key(provider: &str, model: &str) -> String { + format!("{provider}/{model}") +} + +// Repair scans untrusted model text once per possible container start. Keep the +// fallback bounded when malformed output contains a long delimiter flood. +const JSON_REPAIR_CANDIDATE_LIMIT: usize = 64; + +fn first_valid_json_payload(raw: &str) -> Option<&str> { + let mut attempted = 0; + for (start, open) in raw.char_indices() { + if !matches!(open, '{' | '[') { + continue; + } + attempted += 1; + if attempted > JSON_REPAIR_CANDIDATE_LIMIT { + break; + } + + let Some(candidate) = balanced_json_payload(&raw[start..]) else { + continue; + }; + if serde_json::from_str::(candidate).is_ok() { + return Some(candidate); + } + } + None +} + +fn balanced_json_payload(raw: &str) -> Option<&str> { + let mut stack = Vec::new(); + let mut in_string = false; + let mut escaped = false; + + for (offset, character) in raw.char_indices() { + if in_string { + if escaped { + escaped = false; + } else { + match character { + '\\' => escaped = true, + '"' => in_string = false, + _ => {} + } + } + continue; + } + + match character { + '"' => in_string = true, + '{' | '[' => stack.push(character), + '}' | ']' => { + let expected_open = if character == '}' { '{' } else { '[' }; + if stack.pop() != Some(expected_open) { + return None; + } + if stack.is_empty() { + return Some(&raw[..offset + character.len_utf8()]); + } + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn model(provider: &str, model: &str, capabilities: ModelCapabilities) -> ProviderModel { + ProviderModel { + provider: provider.to_string(), + model: model.to_string(), + capabilities, + } + } + + #[test] + fn provider_capability_fallback() { + let registry = ProviderRegistry::new() + .with_model(model("mock", "plain", ModelCapabilities::default())) + .with_model(model( + "mock", + "json", + ModelCapabilities { + json_mode: true, + ..ModelCapabilities::default() + }, + )); + let policy = ModelPolicy { + provider: Some("mock".to_string()), + model: Some("plain".to_string()), + fallback_models: vec!["json".to_string()], + }; + + let resolved = registry + .resolve_role( + ModelRole::JsonExtractor, + Some(&policy), + ModelCapabilities { + json_mode: true, + ..ModelCapabilities::default() + }, + ) + .expect("fallback json model should satisfy the role"); + + assert_eq!(resolved.model, "json"); + assert_eq!(resolved.source, ModelSelectionSource::Fallback); + } + + #[test] + fn role_default_policy_resolves_model() { + let registry = ProviderRegistry::new() + .with_model(model( + "mock", + "planner", + ModelCapabilities { + large_context: true, + ..ModelCapabilities::default() + }, + )) + .with_role_policy( + ModelRole::Planner, + ModelPolicy { + provider: Some("mock".to_string()), + model: Some("planner".to_string()), + fallback_models: Vec::new(), + }, + ); + + let resolved = registry + .resolve_role( + ModelRole::Planner, + None, + ModelCapabilities { + large_context: true, + ..ModelCapabilities::default() + }, + ) + .expect("role default should resolve"); + + assert_eq!(resolved.role, ModelRole::Planner); + assert_eq!(resolved.source, ModelSelectionSource::RoleDefault); + } + + #[test] + fn agent_type_maps_to_model_role() { + assert_eq!(ModelRole::from(AgentType::Plan), ModelRole::Planner); + assert_eq!( + ModelRole::from(AgentType::Implementer), + ModelRole::Implementer + ); + assert_eq!(ModelRole::from(AgentType::Verifier), ModelRole::Reviewer); + } + + #[test] + fn json_repair_fallback() { + #[derive(Debug, Deserialize, PartialEq, Eq)] + struct Payload { + answer: String, + } + + let parsed: Payload = parse_json_with_repair( + r#"Here is the JSON: +```json +{"answer":"ok"} +``` +"#, + ) + .expect("repair should extract fenced JSON"); + + assert_eq!( + parsed, + Payload { + answer: "ok".to_string() + } + ); + } + + #[test] + fn json_repair_fallback_fails_closed() { + let err = parse_json_with_repair::("not json") + .expect_err("non-json text should fail closed"); + + assert!(matches!(err, JsonRepairError::Parse { .. })); + } + + #[test] + fn mock_provider_returns_configured_response() { + let provider = MockModelProvider::new( + "mock", + "fast", + ModelCapabilities::default(), + "mock response", + ); + let request = CompletionRequest { + role: ModelRole::LeafReasoner, + prompt: "say something".to_string(), + require_json: false, + model_policy: ModelPolicy::default(), + }; + + let response = provider.complete(&request).expect("mock should respond"); + + assert_eq!(provider.provider(), "mock"); + assert_eq!(provider.model(), "fast"); + assert_eq!(response.text, "mock response"); + } + + #[test] + fn repair_json_text_once_extracts_supported_payloads() { + // Plain JSON object + assert_eq!( + repair_json_text_once(r#"{"key": "value"}"#), + r#"{"key": "value"}"# + ); + + // Plain JSON array + assert_eq!(repair_json_text_once(r#"[1, 2, 3]"#), r#"[1, 2, 3]"#); + + // Markdown fenced JSON object + assert_eq!( + repair_json_text_once("```json\n{\"key\": \"value\"}\n```"), + r#"{"key": "value"}"# + ); + + // Markdown fenced JSON array + assert_eq!( + repair_json_text_once("```json\n[1, 2, 3]\n```"), + r#"[1, 2, 3]"# + ); + + // Generic markdown fence + assert_eq!( + repair_json_text_once("```\n{\"key\": \"value\"}\n```"), + r#"{"key": "value"}"# + ); + + // JSON object embedded in text + assert_eq!( + repair_json_text_once("Here is the JSON:\n\n{\"key\": \"value\"}\n\nHope this helps!"), + r#"{"key": "value"}"# + ); + + // JSON array embedded in text + assert_eq!( + repair_json_text_once("Some text before [1, 2, 3] and some text after"), + r#"[1, 2, 3]"# + ); + + // Nested structures (object containing array) + assert_eq!( + repair_json_text_once(r#"{"key": [1, 2, 3]}"#), + r#"{"key": [1, 2, 3]}"# + ); + + // Nested structures (array containing object) + assert_eq!( + repair_json_text_once(r#"[{"key": "value"}]"#), + r#"[{"key": "value"}]"# + ); + + // Fenced JSON embedded in text + assert_eq!( + repair_json_text_once("Here is the JSON:\n```json\n{\"key\": \"value\"}\n```\nDone."), + r#"{"key": "value"}"# + ); + + // No valid JSON, fallback to trimmed text + assert_eq!( + repair_json_text_once("Just some plain text without json"), + "Just some plain text without json" + ); + + // Fenced plain text, falls back to stripped and trimmed text + assert_eq!( + repair_json_text_once("```json\nJust some plain text\n```"), + "Just some plain text" + ); + + // An unmatched opening bracket must not block a later valid object. + assert_eq!(repair_json_text_once("[note {\"ok\":1}"), r#"{"ok":1}"#); + + // Delimiters inside strings must not terminate the outer object. + assert_eq!( + repair_json_text_once(r#"Prefix text {"data": "[nested]"} postfix text"#), + r#"{"data": "[nested]"}"# + ); + + // The earliest balanced candidate may still be invalid JSON. Keep scanning + // until a balanced candidate also parses successfully. + assert_eq!( + repair_json_text_once(r#"[not-json] then {"ok":true}"#), + r#"{"ok":true}"# + ); + + // Escaped quotes and backslashes stay inside the JSON string. + assert_eq!( + repair_json_text_once( + r#"prefix {"value":"a \"quoted\" [item]","path":"C:\\tmp"} suffix"# + ), + r#"{"value":"a \"quoted\" [item]","path":"C:\\tmp"}"# + ); + + // A mismatched outer delimiter must not consume a later valid payload. + assert_eq!( + repair_json_text_once(r#"[broken} then [1,{"ok":true}]"#), + r#"[1,{"ok":true}]"# + ); + } +} diff --git a/crates/workflow/src/review_repair.rs b/crates/workflow/src/review_repair.rs new file mode 100644 index 0000000000..b318c18b15 --- /dev/null +++ b/crates/workflow/src/review_repair.rs @@ -0,0 +1,890 @@ +//! Bounded review→repair automation (#3832). +//! +//! ## What this is not +//! +//! It is **not a fourth Mode.** Codewhale has exactly three Modes — Plan, Act, +//! Operate — and exactly three permission postures — Ask, Auto-Review, Full +//! Access. A review→repair loop is a *Workflow shape*: an ordering of existing +//! roles over existing gates. It therefore lives here, beside [`crate::gates`], +//! and carries no mode, no posture, and no permission of its own. Whatever Mode +//! and posture the session already has continue to govern every step; this type +//! only decides *when to stop*. +//! +//! ## The four things that make it safe +//! +//! 1. **Explicit ceilings.** [`ReviewRepairBounds`] caps iterations, wall-clock +//! seconds, and tool calls. Every ceiling is checked *before* work starts, so +//! the loop cannot overrun by one extra iteration; a zero ceiling means the +//! loop never runs rather than running forever. +//! 2. **Exact routes on the record.** Each iteration must carry a +//! [`RouteReceipt`] for the reviewer and, where the policy requires one, the +//! verifier: role, exact provider/model, requested→effective reasoning, and +//! who routed it. An iteration with no reviewer route is refused. +//! 3. **Human ratification where policy says.** When +//! [`ReviewRepairPolicy::require_human_ratification`] is set, a clean verify +//! does not finish the loop: it parks at +//! [`StopReason::AwaitingRatification`] until [`ReviewRepairLoop::ratify`] +//! records an explicit human decision. +//! 4. **Fail closed on stale input.** The loop pins the digest of the artifact +//! under review. If an iteration reports a different digest — the branch +//! moved, the diff was rebuilt, the findings came from an older tree — the +//! loop halts at [`StopReason::StaleInput`] instead of repairing against +//! something the reviewer never saw. + +use serde::{Deserialize, Serialize}; + +/// Explicit ceilings on a review→repair loop. There is no unbounded variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRepairBounds { + /// Maximum review→repair iterations. `0` means the loop never starts. + pub max_iterations: u32, + /// Maximum cumulative wall-clock seconds across all iterations. + pub max_wall_clock_secs: u64, + /// Maximum cumulative tool calls across all iterations. + pub max_tool_calls: u32, +} + +impl ReviewRepairBounds { + /// Conservative defaults for an unattended loop: short, cheap, and easy to + /// re-run by hand if it stops early. + #[must_use] + pub fn conservative() -> Self { + Self { + max_iterations: 3, + max_wall_clock_secs: 900, + max_tool_calls: 120, + } + } +} + +impl Default for ReviewRepairBounds { + fn default() -> Self { + Self::conservative() + } +} + +/// Who chose the route for a step. Distinguishing these is the difference +/// between "the user picked this model" and "a Router picked it". +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RoutedBy { + /// The exact Fleet member route, frozen at Fleet capture. + Fleet, + /// A Router chose reasoning for the frozen member route. The Router's own + /// exact identity is recorded so its spend is attributable. + Router { provider: String, model: String }, + /// The session's current model selection. + SessionDefault, +} + +impl RoutedBy { + /// Human-readable source label for receipts and Workflow rows. + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Fleet => "fleet".to_string(), + Self::Router { provider, model } => format!("router {provider}/{model}"), + Self::SessionDefault => "session default".to_string(), + } + } +} + +/// The exact route a reviewer or verifier ran on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RouteReceipt { + /// Fleet role (`reviewer`, `verifier`, `implementer`, …). + pub role: String, + pub provider: String, + pub model: String, + /// What the plan asked for. + pub requested_reasoning: String, + /// What the provider actually applied. Divergence is shown, not smoothed. + pub effective_reasoning: String, + pub routed_by: RoutedBy, +} + +impl RouteReceipt { + /// True when every identity field is present. An incomplete route is not a + /// receipt; it is a claim. + #[must_use] + pub fn is_complete(&self) -> bool { + !self.role.trim().is_empty() + && !self.provider.trim().is_empty() + && !self.model.trim().is_empty() + && !self.requested_reasoning.trim().is_empty() + && !self.effective_reasoning.trim().is_empty() + } + + /// One-line display: role, exact route, requested→effective, routed by. + #[must_use] + pub fn display_line(&self) -> String { + let reasoning = if self.requested_reasoning == self.effective_reasoning { + self.effective_reasoning.clone() + } else { + format!("{}→{}", self.requested_reasoning, self.effective_reasoning) + }; + format!( + "{}: {}/{} reasoning {reasoning} (routed by {})", + self.role, + self.provider, + self.model, + self.routed_by.label() + ) + } +} + +/// The verdict a reviewer/verifier pair produced for one iteration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IterationVerdict { + /// Review and verification both passed. The loop may finish. + Clean, + /// Findings remain; another repair iteration is warranted. + RepairsNeeded, + /// The reviewer refused to judge (missing input, tool failure, …). The loop + /// stops rather than treating an absent judgment as a pass. + Inconclusive, +} + +/// What one iteration actually did. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct IterationReceipt { + /// 1-based iteration number, assigned by the loop. + pub iteration: u32, + /// Digest of the artifact this iteration reviewed. + pub input_digest: String, + pub reviewer: RouteReceipt, + /// Present when the policy requires a separate verify step. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub verifier: Option, + pub verdict: IterationVerdict, + /// Bounded finding summaries. Not the findings themselves. + #[serde(default)] + pub finding_summaries: Vec, + pub tool_calls_used: u32, + pub elapsed_secs: u64, +} + +/// Why a review→repair loop stopped. Every variant is terminal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StopReason { + /// Clean verdict, and either no ratification was required or it was given. + Verified, + /// A human explicitly rejected the result. + RatificationDeclined { + note: String, + }, + /// Clean verdict, waiting on the human the policy requires. + AwaitingRatification, + IterationCeiling { + max_iterations: u32, + }, + TimeCeiling { + max_wall_clock_secs: u64, + elapsed_secs: u64, + }, + ToolCeiling { + max_tool_calls: u32, + used: u32, + }, + /// The artifact under review changed underneath the loop. + StaleInput { + pinned: String, + reported: String, + }, + /// The reviewer could not judge, or its receipt was incomplete. + Inconclusive { + reason: String, + }, +} + +impl StopReason { + /// True only for the one outcome that means "this loop succeeded". + #[must_use] + pub fn is_success(&self) -> bool { + matches!(self, Self::Verified) + } + + /// True when the loop stopped because it hit an explicit ceiling. These are + /// honest incompletions, not failures. + #[must_use] + pub fn is_ceiling(&self) -> bool { + matches!( + self, + Self::IterationCeiling { .. } | Self::TimeCeiling { .. } | Self::ToolCeiling { .. } + ) + } + + /// Stable receipt line. UI surfaces localize around it; the numbers here are + /// the ones the user must be able to check. + #[must_use] + pub fn receipt(&self) -> String { + match self { + Self::Verified => "verified".to_string(), + Self::RatificationDeclined { note } => format!("ratification declined: {note}"), + Self::AwaitingRatification => { + "clean; awaiting human ratification before this counts as done".to_string() + } + Self::IterationCeiling { max_iterations } => { + format!("stopped at the iteration ceiling ({max_iterations})") + } + Self::TimeCeiling { + max_wall_clock_secs, + elapsed_secs, + } => format!("stopped at the time ceiling ({elapsed_secs}s of {max_wall_clock_secs}s)"), + Self::ToolCeiling { + max_tool_calls, + used, + } => format!("stopped at the tool ceiling ({used} of {max_tool_calls} calls)"), + Self::StaleInput { pinned, reported } => format!( + "stopped: input changed under review (pinned {pinned}, reported {reported})" + ), + Self::Inconclusive { reason } => format!("stopped: no usable verdict ({reason})"), + } + } +} + +/// Policy knobs that are not ceilings. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRepairPolicy { + /// A clean verdict parks at [`StopReason::AwaitingRatification`] until a + /// human decides. + #[serde(default)] + pub require_human_ratification: bool, + /// Every iteration must carry a verifier receipt in addition to the + /// reviewer's. A missing verifier is inconclusive, never a pass. + #[serde(default)] + pub require_verifier_receipt: bool, +} + +impl Default for ReviewRepairPolicy { + fn default() -> Self { + // Fail closed by default: a human confirms, and review alone is not + // verification. + Self { + require_human_ratification: true, + require_verifier_receipt: true, + } + } +} + +/// A bounded review→repair loop over one lane's artifact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ReviewRepairLoop { + pub lane_id: String, + pub bounds: ReviewRepairBounds, + pub policy: ReviewRepairPolicy, + /// Digest of the artifact this loop was authorized to work on. + pub pinned_input_digest: String, + #[serde(default)] + pub iterations: Vec, + #[serde(default)] + pub elapsed_secs: u64, + #[serde(default)] + pub tool_calls_used: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stopped: Option, +} + +/// Refusal from [`ReviewRepairLoop::begin_iteration`] or +/// [`ReviewRepairLoop::record_iteration`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReviewRepairError { + /// The loop already stopped; it does not restart. + AlreadyStopped(StopReason), + /// A ceiling or fail-closed condition ended the loop on this call. + Stopped(StopReason), + /// The receipt itself was unusable. + IncompleteReceipt(String), +} + +impl ReviewRepairError { + /// The terminal reason, when this refusal carries one. + #[must_use] + pub fn stop_reason(&self) -> Option<&StopReason> { + match self { + Self::AlreadyStopped(reason) | Self::Stopped(reason) => Some(reason), + Self::IncompleteReceipt(_) => None, + } + } +} + +impl std::fmt::Display for ReviewRepairError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AlreadyStopped(reason) => { + write!(f, "review-repair already stopped: {}", reason.receipt()) + } + Self::Stopped(reason) => write!(f, "{}", reason.receipt()), + Self::IncompleteReceipt(detail) => { + write!(f, "review-repair receipt is unusable: {detail}") + } + } + } +} + +impl std::error::Error for ReviewRepairError {} + +impl ReviewRepairLoop { + /// Start a loop pinned to the digest of the artifact under review. + #[must_use] + pub fn new( + lane_id: impl Into, + pinned_input_digest: impl Into, + bounds: ReviewRepairBounds, + policy: ReviewRepairPolicy, + ) -> Self { + Self { + lane_id: lane_id.into(), + bounds, + policy, + pinned_input_digest: pinned_input_digest.into(), + iterations: Vec::new(), + elapsed_secs: 0, + tool_calls_used: 0, + stopped: None, + } + } + + /// Whether the loop has stopped, and why. + #[must_use] + pub fn stop_reason(&self) -> Option<&StopReason> { + self.stopped.as_ref() + } + + /// Claim the next iteration slot, or refuse. + /// + /// Ceilings are checked here — *before* any model or tool spend — so a loop + /// that has exhausted its budget cannot buy one more iteration to find out. + pub fn begin_iteration(&mut self) -> Result { + if let Some(reason) = self.stopped.clone() { + return Err(ReviewRepairError::AlreadyStopped(reason)); + } + let next = self.iterations.len() as u32 + 1; + if next > self.bounds.max_iterations { + return Err(self.stop(StopReason::IterationCeiling { + max_iterations: self.bounds.max_iterations, + })); + } + if self.elapsed_secs >= self.bounds.max_wall_clock_secs { + return Err(self.stop(StopReason::TimeCeiling { + max_wall_clock_secs: self.bounds.max_wall_clock_secs, + elapsed_secs: self.elapsed_secs, + })); + } + if self.tool_calls_used >= self.bounds.max_tool_calls { + return Err(self.stop(StopReason::ToolCeiling { + max_tool_calls: self.bounds.max_tool_calls, + used: self.tool_calls_used, + })); + } + Ok(next) + } + + /// Record a completed iteration and decide whether the loop continues. + /// + /// Returns `Ok(None)` when another iteration is warranted, `Ok(Some(reason))` + /// when the loop is done. + pub fn record_iteration( + &mut self, + receipt: IterationReceipt, + ) -> Result, ReviewRepairError> { + if let Some(reason) = self.stopped.clone() { + return Err(ReviewRepairError::AlreadyStopped(reason)); + } + + // Fail closed on stale input before anything in the receipt is trusted: + // a verdict about a different tree is not a verdict about this one. + if receipt.input_digest != self.pinned_input_digest { + return Err(self.stop(StopReason::StaleInput { + pinned: self.pinned_input_digest.clone(), + reported: receipt.input_digest.clone(), + })); + } + if !receipt.reviewer.is_complete() { + return Err(ReviewRepairError::IncompleteReceipt(format!( + "reviewer route for iteration {} is missing identity fields", + receipt.iteration + ))); + } + if self.policy.require_verifier_receipt { + match receipt.verifier.as_ref() { + None => { + return Err(self.stop(StopReason::Inconclusive { + reason: "policy requires a verifier receipt and none was produced" + .to_string(), + })); + } + Some(verifier) if !verifier.is_complete() => { + return Err(ReviewRepairError::IncompleteReceipt(format!( + "verifier route for iteration {} is missing identity fields", + receipt.iteration + ))); + } + Some(_) => {} + } + } + + self.elapsed_secs = self.elapsed_secs.saturating_add(receipt.elapsed_secs); + self.tool_calls_used = self.tool_calls_used.saturating_add(receipt.tool_calls_used); + let verdict = receipt.verdict; + self.iterations.push(receipt); + + // Overrun is reported honestly: the ceiling is the reason we stop, even + // though this iteration's spend already happened. + if self.tool_calls_used > self.bounds.max_tool_calls { + let reason = StopReason::ToolCeiling { + max_tool_calls: self.bounds.max_tool_calls, + used: self.tool_calls_used, + }; + self.stopped = Some(reason.clone()); + return Ok(Some(reason)); + } + if self.elapsed_secs > self.bounds.max_wall_clock_secs { + let reason = StopReason::TimeCeiling { + max_wall_clock_secs: self.bounds.max_wall_clock_secs, + elapsed_secs: self.elapsed_secs, + }; + self.stopped = Some(reason.clone()); + return Ok(Some(reason)); + } + + match verdict { + IterationVerdict::Inconclusive => { + let reason = StopReason::Inconclusive { + reason: "reviewer returned no usable verdict".to_string(), + }; + self.stopped = Some(reason.clone()); + Ok(Some(reason)) + } + IterationVerdict::Clean => { + let reason = if self.policy.require_human_ratification { + StopReason::AwaitingRatification + } else { + StopReason::Verified + }; + self.stopped = Some(reason.clone()); + Ok(Some(reason)) + } + IterationVerdict::RepairsNeeded => { + if self.iterations.len() as u32 >= self.bounds.max_iterations { + let reason = StopReason::IterationCeiling { + max_iterations: self.bounds.max_iterations, + }; + self.stopped = Some(reason.clone()); + return Ok(Some(reason)); + } + Ok(None) + } + } + } + + /// Record the human decision a parked loop is waiting for. + /// + /// Only a loop actually parked at [`StopReason::AwaitingRatification`] can be + /// ratified: a loop that stopped at a ceiling, on stale input, or + /// inconclusively cannot be waved through, because there is no clean result + /// to approve. + pub fn ratify( + &mut self, + approved: bool, + note: impl Into, + ) -> Result<&StopReason, ReviewRepairError> { + match self.stopped.clone() { + Some(StopReason::AwaitingRatification) => { + self.stopped = Some(if approved { + StopReason::Verified + } else { + StopReason::RatificationDeclined { note: note.into() } + }); + Ok(self.stopped.as_ref().expect("just set")) + } + Some(other) => Err(ReviewRepairError::AlreadyStopped(other)), + None => Err(ReviewRepairError::IncompleteReceipt( + "the loop has not produced a clean result to ratify".to_string(), + )), + } + } + + /// Every route this loop ran, in order, for the Workflow row and receipts. + #[must_use] + pub fn route_lines(&self) -> Vec { + let mut lines = Vec::new(); + for iteration in &self.iterations { + lines.push(format!( + "#{} {}", + iteration.iteration, + iteration.reviewer.display_line() + )); + if let Some(verifier) = iteration.verifier.as_ref() { + lines.push(format!( + "#{} {}", + iteration.iteration, + verifier.display_line() + )); + } + } + lines + } + + /// One-line budget statement: what was used against what was allowed. + #[must_use] + pub fn budget_line(&self) -> String { + format!( + "iterations {}/{}, tools {}/{}, elapsed {}s/{}s", + self.iterations.len(), + self.bounds.max_iterations, + self.tool_calls_used, + self.bounds.max_tool_calls, + self.elapsed_secs, + self.bounds.max_wall_clock_secs, + ) + } + + fn stop(&mut self, reason: StopReason) -> ReviewRepairError { + self.stopped = Some(reason.clone()); + ReviewRepairError::Stopped(reason) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route(role: &str) -> RouteReceipt { + RouteReceipt { + role: role.to_string(), + provider: "zhipu".to_string(), + model: "glm-5.2".to_string(), + requested_reasoning: "high".to_string(), + effective_reasoning: "high".to_string(), + routed_by: RoutedBy::Fleet, + } + } + + fn receipt(iteration: u32, digest: &str, verdict: IterationVerdict) -> IterationReceipt { + IterationReceipt { + iteration, + input_digest: digest.to_string(), + reviewer: route("reviewer"), + verifier: Some(route("verifier")), + verdict, + finding_summaries: Vec::new(), + tool_calls_used: 5, + elapsed_secs: 10, + } + } + + fn loop_with(bounds: ReviewRepairBounds, policy: ReviewRepairPolicy) -> ReviewRepairLoop { + ReviewRepairLoop::new("lane-1", "digest-a", bounds, policy) + } + + #[test] + fn iteration_ceiling_stops_before_spending_another_turn() { + let bounds = ReviewRepairBounds { + max_iterations: 2, + ..ReviewRepairBounds::conservative() + }; + let mut lane = loop_with(bounds, ReviewRepairPolicy::default()); + + for i in 1..=2 { + let n = lane.begin_iteration().expect("iteration within ceiling"); + assert_eq!(n, i); + let stop = lane + .record_iteration(receipt(i, "digest-a", IterationVerdict::RepairsNeeded)) + .expect("recorded"); + if i < 2 { + assert!(stop.is_none()); + } else { + assert_eq!( + stop, + Some(StopReason::IterationCeiling { max_iterations: 2 }) + ); + } + } + + let err = lane.begin_iteration().expect_err("loop is done"); + assert!(matches!(err, ReviewRepairError::AlreadyStopped(_))); + assert!(lane.stop_reason().expect("stopped").is_ceiling()); + assert!(!lane.stop_reason().expect("stopped").is_success()); + } + + #[test] + fn zero_iteration_ceiling_never_starts() { + let mut lane = loop_with( + ReviewRepairBounds { + max_iterations: 0, + ..ReviewRepairBounds::conservative() + }, + ReviewRepairPolicy::default(), + ); + let err = lane + .begin_iteration() + .expect_err("a zero ceiling runs nothing"); + assert_eq!( + err.stop_reason(), + Some(&StopReason::IterationCeiling { max_iterations: 0 }) + ); + } + + #[test] + fn tool_and_time_ceilings_stop_the_loop_and_report_the_overrun() { + let mut lane = loop_with( + ReviewRepairBounds { + max_iterations: 5, + max_wall_clock_secs: 600, + max_tool_calls: 4, + }, + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let stop = lane + .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) + .expect("recorded"); + assert_eq!( + stop, + Some(StopReason::ToolCeiling { + max_tool_calls: 4, + used: 5 + }) + ); + assert!(lane.budget_line().contains("tools 5/4")); + + let mut timed = loop_with( + ReviewRepairBounds { + max_iterations: 5, + max_wall_clock_secs: 5, + max_tool_calls: 100, + }, + ReviewRepairPolicy::default(), + ); + timed.begin_iteration().expect("first iteration"); + let stop = timed + .record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) + .expect("recorded"); + assert_eq!( + stop, + Some(StopReason::TimeCeiling { + max_wall_clock_secs: 5, + elapsed_secs: 10 + }) + ); + } + + #[test] + fn stale_input_fails_closed_without_repairing() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + + let err = lane + .record_iteration(receipt(1, "digest-b", IterationVerdict::Clean)) + .expect_err("a verdict about another tree is not a verdict about this one"); + + assert_eq!( + err.stop_reason(), + Some(&StopReason::StaleInput { + pinned: "digest-a".to_string(), + reported: "digest-b".to_string(), + }) + ); + assert!(lane.iterations.is_empty(), "stale work is not recorded"); + assert!(!lane.stop_reason().expect("stopped").is_success()); + } + + #[test] + fn clean_verdict_parks_for_human_ratification() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let stop = lane + .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) + .expect("recorded"); + + assert_eq!(stop, Some(StopReason::AwaitingRatification)); + assert!(!lane.stop_reason().expect("stopped").is_success()); + + let after = lane.ratify(true, "").expect("ratify a parked loop"); + assert_eq!(after, &StopReason::Verified); + assert!(lane.stop_reason().expect("stopped").is_success()); + } + + #[test] + fn declined_ratification_is_not_success() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + lane.record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) + .expect("recorded"); + + lane.ratify(false, "diff touches release scripts") + .expect("decline is a valid decision"); + assert_eq!( + lane.stop_reason(), + Some(&StopReason::RatificationDeclined { + note: "diff touches release scripts".to_string() + }) + ); + assert!(!lane.stop_reason().expect("stopped").is_success()); + } + + #[test] + fn a_ceiling_stop_cannot_be_waved_through_by_ratification() { + let mut lane = loop_with( + ReviewRepairBounds { + max_iterations: 1, + ..ReviewRepairBounds::conservative() + }, + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + lane.record_iteration(receipt(1, "digest-a", IterationVerdict::RepairsNeeded)) + .expect("recorded"); + + let err = lane + .ratify(true, "looks fine to me") + .expect_err("there is no clean result to approve"); + assert!(matches!(err, ReviewRepairError::AlreadyStopped(_))); + assert!(!lane.stop_reason().expect("stopped").is_success()); + } + + #[test] + fn without_the_ratification_policy_a_clean_verdict_verifies_directly() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy { + require_human_ratification: false, + require_verifier_receipt: true, + }, + ); + lane.begin_iteration().expect("first iteration"); + let stop = lane + .record_iteration(receipt(1, "digest-a", IterationVerdict::Clean)) + .expect("recorded"); + assert_eq!(stop, Some(StopReason::Verified)); + } + + #[test] + fn a_missing_verifier_is_inconclusive_not_a_pass() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let mut r = receipt(1, "digest-a", IterationVerdict::Clean); + r.verifier = None; + + let err = lane + .record_iteration(r) + .expect_err("review alone is not verification"); + assert!(matches!( + err.stop_reason(), + Some(StopReason::Inconclusive { .. }) + )); + } + + #[test] + fn an_incomplete_route_is_refused_without_ending_the_loop() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let mut r = receipt(1, "digest-a", IterationVerdict::Clean); + r.reviewer.model = " ".to_string(); + + let err = lane + .record_iteration(r) + .expect_err("a route without a model is a claim"); + assert!(matches!(err, ReviewRepairError::IncompleteReceipt(_))); + assert!( + lane.stop_reason().is_none(), + "the caller may retry with a real receipt" + ); + } + + #[test] + fn inconclusive_review_stops_instead_of_passing() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let stop = lane + .record_iteration(receipt(1, "digest-a", IterationVerdict::Inconclusive)) + .expect("recorded"); + assert!(matches!(stop, Some(StopReason::Inconclusive { .. }))); + } + + #[test] + fn route_lines_show_exact_routes_and_requested_to_effective_reasoning() { + let mut lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + lane.begin_iteration().expect("first iteration"); + let mut r = receipt(1, "digest-a", IterationVerdict::Clean); + r.reviewer.requested_reasoning = "max".to_string(); + r.reviewer.effective_reasoning = "high".to_string(); + r.reviewer.routed_by = RoutedBy::Router { + provider: "moonshot".to_string(), + model: "kimi-k3".to_string(), + }; + lane.record_iteration(r).expect("recorded"); + + let lines = lane.route_lines(); + assert_eq!( + lines.len(), + 2, + "reviewer and verifier both appear: {lines:?}" + ); + assert!(lines[0].contains("reviewer: zhipu/glm-5.2")); + assert!(lines[0].contains("max→high"), "{lines:?}"); + assert!(lines[0].contains("routed by router moonshot/kimi-k3")); + assert!(lines[1].contains("verifier: zhipu/glm-5.2")); + } + + #[test] + fn default_policy_fails_closed() { + let policy = ReviewRepairPolicy::default(); + assert!(policy.require_human_ratification); + assert!(policy.require_verifier_receipt); + let bounds = ReviewRepairBounds::default(); + assert!(bounds.max_iterations > 0); + assert!(bounds.max_wall_clock_secs > 0); + assert!(bounds.max_tool_calls > 0); + } + + #[test] + fn review_repair_defines_no_mode_or_permission_posture() { + // #3832 must not grow a fourth Mode or a fourth posture. This module's + // serialized surface is the check: a loop carries ceilings, routes, and + // verdicts — never a mode, posture, approval policy, or sandbox setting. + let lane = loop_with( + ReviewRepairBounds::conservative(), + ReviewRepairPolicy::default(), + ); + let json = serde_json::to_string(&lane).expect("serialize"); + for forbidden in [ + "mode", + "posture", + "approval_policy", + "sandbox", + "permission", + "auto_review", + "full_access", + ] { + assert!( + !json.contains(forbidden), + "review-repair leaked {forbidden} into its own state: {json}" + ); + } + } +} From bc54777488320a90c7b0bae07fa5e55469f56b9f Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 15:06:07 +0800 Subject: [PATCH 03/18] test: restore remaining reserved seams and pins Follow-up to the reservation audit: a fresh 7-lane review of this PR found reservation-class code the first restoration pass left deleted, and two live-behavior coverage pins lost by the sweep. Restored from the pre-sweep commit with their tests: - protocol: workroom module (Phase-1 types per docs/rfcs/3209-workrooms.md and docs/WORKROOM_*.md, which describe it as shipped - same reservation class as the restored OpEnvelope), - state: the session-tree ops that implement the #5261/#5262 placeholders' semantics (fork_at_message, set_current_leaf_id, list_leaf_messages, clear_messages, delete_thread, find_thread_name_by_id, the cascade test, and the parity tail), - workflow: stopship_gate_pipeline canonical constructor + re-export (gates.rs is "wire in later" scaffolding; tests drop the inline copy), and a wire-compatibility note on WorkflowRunStatus::ReplayDiverged, left unconstructable by the replay.rs deletion, - tui: goal_loop is_success (#2666 token/time visibility) and the #4122 convergence seam (DelegateCard::as_workflow_history_panel, WorkflowPanel::render_history_card/from_direct_subagent + tests), - config: current_on_empty_chain_returns_default_provider - the only test of ProviderChain::current()'s empty-table fallback, - tui: grouping_key_collapses_shell_flag_variants - the only test of the documented v0.8.37 approval session-grant contract that approving "cargo build" covers "cargo build --release". Signed-off-by: asto Signed-off-by: asto18089 --- crates/config/src/lib.rs | 14 + crates/protocol/src/lib.rs | 1 + crates/protocol/src/workroom.rs | 347 +++++++++++++++++++ crates/state/src/lib.rs | 253 ++++++++++++-- crates/state/tests/parity_state.rs | 63 +++- crates/tui/src/goal_loop.rs | 10 + crates/tui/src/tools/approval_cache.rs | 11 + crates/tui/src/tui/widgets/agent_card.rs | 64 ++++ crates/tui/src/tui/widgets/workflow_panel.rs | 187 ++++++++++ crates/workflow/src/gates.rs | 99 +++--- crates/workflow/src/lib.rs | 5 +- 11 files changed, 973 insertions(+), 81 deletions(-) create mode 100644 crates/protocol/src/workroom.rs diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 4e4ff2d92b..a98dd4a4fb 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -1548,6 +1548,20 @@ impl ProviderChain { } } +#[cfg(test)] +mod provider_chain_tests { + use super::*; + + #[test] + fn current_on_empty_chain_returns_default_provider() { + let chain = ProviderChain { + providers: vec![], + position: 0, + }; + assert_eq!(chain.current(), ProviderKind::default()); + } +} + /// On-disk schema for the `[hook_sinks]` table. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct HookSinksToml { diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index de385deaa7..3bf853d3c2 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -17,6 +17,7 @@ pub mod ids; pub mod journal; pub mod op; pub mod runtime; +pub mod workroom; /// Common trait for lifecycle status enums across the protocol layer. /// diff --git a/crates/protocol/src/workroom.rs b/crates/protocol/src/workroom.rs new file mode 100644 index 0000000000..1a3c9c9704 --- /dev/null +++ b/crates/protocol/src/workroom.rs @@ -0,0 +1,347 @@ +//! Workroom types — durable chat-native containers for threaded agent work. +//! +//! A [`Workroom`] groups threads, events, and external references into a +//! stable, addressable surface that can be accessed from the TUI, mobile page, +//! chat bridges, and programmatic Runtime API consumers. +//! +//! See `docs/rfcs/3209-workrooms.md` for the full design. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write; + +/// Unique identifier for a workroom. +/// +/// Stable across restarts. Opaque to callers; generated via UUID v4 with a +/// `wr_` prefix for link recognition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct WorkroomId(pub String); + +impl WorkroomId { + /// Create a new workroom id from a UUID v4 string. + pub fn new() -> Self { + Self(format!("wr_{}", uuid::Uuid::new_v4().simple())) + } +} + +impl Default for WorkroomId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for WorkroomId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// A durable container for threaded agent conversations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Workroom { + pub id: WorkroomId, + pub title: String, + pub workspace: Option, + pub repo_identity: Option, + pub owner: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub visibility: WorkroomVisibility, +} + +/// GitHub repository identity attached to a workroom. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RepoRef { + pub owner: String, + pub name: String, +} + +/// Visibility controls for a workroom. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkroomVisibility { + /// Only the local user can access. + Private, + /// Accessible to callers bearing one of the listed bearer tokens. + Shared { allowed_tokens: Vec }, +} + +/// A thread within a workroom. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomThread { + pub id: String, + pub workroom_id: WorkroomId, + pub title: String, + pub kind: WorkroomThreadKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_ref: Option, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkroomThreadKind { + Channel, + DirectMessage, + AgentTask, + ApprovalQueue, + ReceiptLog, +} + +/// An external reference that can be attached to a workroom thread. +/// +/// Stores only metadata — no API keys, tokens, or secrets. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExternalThreadRef { + GitHubIssue { + owner: String, + repo: String, + number: u64, + }, + GitHubPullRequest { + owner: String, + repo: String, + number: u64, + }, + GitHubCommit { + owner: String, + repo: String, + sha: String, + }, + GitHubCheck { + owner: String, + repo: String, + check_run_id: u64, + }, +} + +/// An event within a workroom thread, attributed to a specific agent/model. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomEvent { + pub id: String, + pub thread_id: String, + pub workroom_id: WorkroomId, + pub timestamp: DateTime, + pub kind: WorkroomEventKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "event", rename_all = "snake_case")] +pub enum WorkroomEventKind { + Message { content: String }, + Mention { mentioned_user: String }, + ToolCall { tool_name: String, summary: String }, + ToolResult { tool_name: String, success: bool }, + ApprovalRequest { tool_name: String }, + ArtifactLinked { path: String, kind: String }, + Receipt { summary: String }, + Failure { error: String }, + NeedsHuman { reason: String }, + Resumed, +} + +/// Attribution metadata recording which agent and model produced an event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentAttribution { + pub provider: String, + pub model: String, + pub agent_id: String, +} + +/// A shareable link that resolves to a workroom, thread, or event. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomLink { + pub workroom_id: WorkroomId, + #[serde(skip_serializing_if = "Option::is_none")] + pub thread_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, +} + +impl WorkroomLink { + /// Parse a `codewhale://workroom/...` URL. + /// + /// Accepted forms: + /// - `codewhale://workroom/wr_` + /// - `codewhale://workroom/wr_/thread/` + /// - `codewhale://workroom/wr_/event/` + pub fn parse(url: &str) -> Option { + let rest = url.strip_prefix("codewhale://workroom/")?; + let mut segments = rest.split('/'); + let workroom_id = parse_segment_with_prefix(segments.next()?, "wr_")?; + let next = segments.next(); + let (thread_id, event_id) = match next { + None => (None, None), + Some("thread") => { + let thread_id = non_empty_segment(segments.next()?)?; + match segments.next() { + None => (Some(thread_id), None), + Some("event") => { + let event_id = non_empty_segment(segments.next()?)?; + if segments.next().is_some() { + return None; + } + (Some(thread_id), Some(event_id)) + } + _ => return None, + } + } + Some("event") => { + let event_id = non_empty_segment(segments.next()?)?; + if segments.next().is_some() { + return None; + } + (None, Some(event_id)) + } + _ => return None, + }; + + Some(Self { + workroom_id: WorkroomId(workroom_id), + thread_id, + event_id, + }) + } + + /// Serialise back to the `codewhale://workroom/...` URL form. + pub fn to_url(&self) -> String { + let mut url = format!("codewhale://workroom/{}", self.workroom_id); + if let Some(ref thread_id) = self.thread_id { + write!(url, "/thread/{thread_id}").unwrap(); + if let Some(ref event_id) = self.event_id { + write!(url, "/event/{event_id}").unwrap(); + } + } else if let Some(ref event_id) = self.event_id { + write!(url, "/event/{event_id}").unwrap(); + } + url + } +} + +fn parse_segment_with_prefix(segment: &str, prefix: &str) -> Option { + let segment = non_empty_segment(segment)?; + if segment.len() == prefix.len() || !segment.starts_with(prefix) { + return None; + } + Some(segment) +} + +fn non_empty_segment(segment: &str) -> Option { + if segment.is_empty() { + None + } else { + Some(segment.to_string()) + } +} + +/// Summary projection of a workroom for list/inbox views. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomSummary { + pub id: WorkroomId, + pub title: String, + pub updated_at: DateTime, + pub active_threads: usize, +} + +/// Paginated list of workrooms. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomListResponse { + pub workrooms: Vec, +} + +/// Response from the `/workroom/resolve` endpoint. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkroomResolveResponse { + pub link: WorkroomLink, + pub thread_title: Option, + pub external_ref: Option, + pub recent_events: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn workroom_id_new_is_stable() { + let id = WorkroomId::new(); + assert!(id.0.starts_with("wr_")); + assert_eq!(id.0.len(), 35); // "wr_" + 32 hex chars + } + + #[test] + fn workroom_link_parse_workroom_only() { + let link = WorkroomLink::parse("codewhale://workroom/wr_abc123def456").unwrap(); + assert_eq!(link.workroom_id.0, "wr_abc123def456"); + assert!(link.thread_id.is_none()); + assert!(link.event_id.is_none()); + } + + #[test] + fn workroom_link_parse_with_thread() { + let link = WorkroomLink::parse("codewhale://workroom/wr_abc/thread/thr_xyz").unwrap(); + assert_eq!(link.workroom_id.0, "wr_abc"); + assert_eq!(link.thread_id.as_deref(), Some("thr_xyz")); + assert!(link.event_id.is_none()); + } + + #[test] + fn workroom_link_parse_with_event() { + let link = WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt_789").unwrap(); + assert_eq!(link.workroom_id.0, "wr_abc"); + assert_eq!(link.event_id.as_deref(), Some("evt_789")); + assert!(link.thread_id.is_none()); + } + + #[test] + fn workroom_link_roundtrip() { + let original = "codewhale://workroom/wr_abc/thread/thr_x/event/evt_y"; + let parsed = WorkroomLink::parse(original).unwrap(); + assert_eq!(parsed.to_url(), original); + } + + #[test] + fn workroom_link_reject_bad_prefix() { + assert!(WorkroomLink::parse("http://workroom/wr_abc").is_none()); + assert!(WorkroomLink::parse("codewhale://not-workroom/wr_abc").is_none()); + } + + #[test] + fn workroom_link_rejects_malformed_paths() { + assert!(WorkroomLink::parse("codewhale://workroom/").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/abc").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/wr_").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/thread/").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/unknown/x").is_none()); + assert!(WorkroomLink::parse("codewhale://workroom/wr_abc/event/evt/x").is_none()); + } + + #[test] + fn external_thread_ref_serde_roundtrip() { + let issue = ExternalThreadRef::GitHubIssue { + owner: "Hmbown".into(), + repo: "CodeWhale".into(), + number: 3209, + }; + let json = serde_json::to_string(&issue).unwrap(); + let back: ExternalThreadRef = serde_json::from_str(&json).unwrap(); + assert!(matches!(back, ExternalThreadRef::GitHubIssue { .. })); + } + + #[test] + fn agent_attribution_serde_roundtrip() { + let attr = AgentAttribution { + provider: "deepseek".into(), + model: "deepseek-v4-pro".into(), + agent_id: "sub_agent_1".into(), + }; + let json = serde_json::to_string(&attr).unwrap(); + let back: AgentAttribution = serde_json::from_str(&json).unwrap(); + assert_eq!(back.provider, "deepseek"); + assert_eq!(back.model, "deepseek-v4-pro"); + } +} diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs index 954305f7d9..24bcbee55d 100644 --- a/crates/state/src/lib.rs +++ b/crates/state/src/lib.rs @@ -642,7 +642,7 @@ impl StateStore { /// Insert or update thread metadata. /// /// This does **not** update `current_leaf_id`; use [`append_message`](Self::append_message) - /// for that. + /// or [`set_current_leaf_id`](Self::set_current_leaf_id) for that. pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> { let conn = self.conn()?; conn.execute( @@ -788,6 +788,15 @@ impl StateStore { Ok(()) } + /// Permanently delete a thread and all of its associated data + /// (messages, checkpoints, dynamic tools) via cascading foreign keys. + pub fn delete_thread(&self, id: &str) -> Result<()> { + let conn = self.conn()?; + conn.execute("DELETE FROM threads WHERE id = ?1", params![id]) + .context("failed to delete thread")?; + Ok(()) + } + /// Set the memory mode for a thread. /// /// Pass `None` to clear the memory mode. @@ -970,6 +979,62 @@ impl StateStore { Ok(changed > 0) } + /// List all leaf messages in a thread. + /// + /// A leaf message is one that has no other message referencing it as a parent. + /// In a branching conversation tree, there may be multiple leaf messages. + pub fn list_leaf_messages(&self, thread_id: &str) -> Result> { + let conn = self.conn()?; + let mut stmt = conn + .prepare( + r#" + SELECT m1.id, m1.thread_id, m1.role, m1.content, m1.item_json, m1.created_at, m1.parent_entry_id + FROM messages m1 + LEFT JOIN messages m2 ON m1.id = m2.parent_entry_id + WHERE m1.thread_id = ?1 AND m2.id IS NULL + "#, + ) + .context("failed to prepare message listing query")?; + let mut rows = stmt + .query(params![thread_id]) + .with_context(|| format!("failed to list leaf messages for thread {thread_id}"))?; + let mut out = Vec::new(); + while let Some(row) = rows.next().context("failed to iterate message rows")? { + let item_json: Option = row.get(4).context("failed to read item json")?; + let item = item_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .with_context(|| { + format!("failed to parse message item json in thread {thread_id}") + })?; + out.push(MessageRecord { + id: row.get(0).context("failed to read message id")?, + thread_id: row.get(1).context("failed to read message thread id")?, + role: row.get(2).context("failed to read message role")?, + content: row.get(3).context("failed to read message content")?, + item, + created_at: row.get(5).context("failed to read message timestamp")?, + parent_entry_id: row.get(6).context("failed to read parent entry id")?, + }); + } + Ok(out) + } + + /// Update the current leaf message pointer for a thread. + /// + /// This controls which branch of the conversation tree is considered active + /// when listing messages via [`list_messages`](Self::list_messages). + pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> { + let conn = self.conn()?; + conn.execute( + "UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2", + params![current_leaf_id, thread_id], + ) + .context("failed to update thread current leaf id")?; + Ok(()) + } + /// Replace the dynamic tools for a thread. /// /// All existing dynamic tools for the thread are deleted and replaced with the @@ -1156,6 +1221,96 @@ impl StateStore { Ok(out) } + /// Fork the conversation at a specific message. + /// + /// Creates a new message whose parent is `message_id` and updates the thread's + /// `current_leaf_id` to the new message. Returns the ID of the new message. + /// This enables branching conversations from any point in the history. + pub fn fork_at_message( + &self, + message_id: &str, + role: &str, + content: &str, + item: Option, + ) -> Result { + let mut conn = self.conn()?; + let created_at = Utc::now().timestamp(); + let item_json = item + .as_ref() + .map(serde_json::to_string) + .transpose() + .context("failed to serialize message item payload")?; + + let tx = conn + .transaction() + .context("failed to begin fork message transaction")?; + + let thread_id: String = tx + .query_row( + "SELECT thread_id FROM messages WHERE id = ?1", + params![message_id], + |row| row.get(0), + ) + .with_context(|| format!("failed to query thread id for message {message_id}"))?; + + let next_leaf_id: i64 = tx.query_row( + r#" + INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id) + SELECT ?1, ?2, ?3, ?4, ?5, ?6 + RETURNING id + "#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0) + ).with_context(|| format!("failed to fork at message for thread {thread_id:?}"))?; + + tx.execute( + r#" + UPDATE threads + SET current_leaf_id = ?1 + WHERE id = ?2; + "#, + params![next_leaf_id, thread_id], + ) + .with_context(|| { + format!("failed to update thread current leaf id for thread {thread_id:?}") + })?; + + tx.commit() + .context("failed to commit fork message transaction")?; + + Ok(next_leaf_id) + } + + /// Delete all messages belonging to a thread and reset its `current_leaf_id`. + /// + /// Returns the number of messages deleted. + pub fn clear_messages(&self, thread_id: &str) -> Result { + let mut conn = self.conn()?; + let tx = conn + .transaction() + .context("failed to begin clear messages transaction")?; + + tx.execute( + r#" + UPDATE threads + SET current_leaf_id = NULL + WHERE id = ?1; + "#, + params![thread_id], + ) + .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; + let result = tx + .execute( + r#" + DELETE FROM messages WHERE thread_id = ?1 + "#, + params![thread_id], + ) + .with_context(|| format!("failed to clear messages for thread {thread_id}"))?; + tx.commit() + .context("failed to commit clear messages transaction")?; + + Ok(result) + } + /// Save (or update) a named checkpoint for a thread. /// /// If a checkpoint with the same `thread_id` and `checkpoint_id` already exists, @@ -1508,6 +1663,16 @@ impl StateStore { operation() } + /// Find the display name for a thread by its ID, using the session index. + /// + /// Returns `None` if the thread is not in the index or has no name. + pub fn find_thread_name_by_id(&self, thread_id: &str) -> Result> { + let map = self.session_index_map()?; + Ok(map + .get(thread_id) + .and_then(|entry| entry.thread_name.clone())) + } + /// Look up display names for multiple thread IDs at once. /// /// Returns a map from thread ID to its name (which may be `None`). @@ -1998,6 +2163,50 @@ mod tests { assert!(err.to_string().contains("thread missing-thread not found")); } + #[test] + fn delete_thread_cascades_child_rows() { + let store = temp_state_store("thread-delete-cascade"); + store + .upsert_thread(&test_thread("thread-1")) + .expect("upsert thread"); + store + .append_message("thread-1", "user", "hello", None) + .expect("append message"); + store + .save_checkpoint("thread-1", "checkpoint-1", &serde_json::json!({"ok": true})) + .expect("save checkpoint"); + store + .persist_dynamic_tools( + "thread-1", + &[DynamicToolRecord { + position: 0, + name: "test_tool".to_string(), + description: Some("test".to_string()), + input_schema: serde_json::json!({"type": "object"}), + }], + ) + .expect("persist dynamic tools"); + store + .upsert_thread_goal(&test_goal("thread-1", "Ship v0.8.67")) + .expect("upsert goal"); + + store.delete_thread("thread-1").expect("delete thread"); + + let conn = store.conn().expect("conn"); + for table in [ + "messages", + "checkpoints", + "thread_dynamic_tools", + "thread_goals", + ] { + let sql = format!("SELECT COUNT(*) FROM {table} WHERE thread_id = ?1"); + let count: i64 = conn + .query_row(&sql, params!["thread-1"], |row| row.get(0)) + .expect("count child rows"); + assert_eq!(count, 0, "{table} row survived thread deletion"); + } + } + #[test] fn state_store_reuses_one_connection_across_operations_and_clones() { let store = temp_state_store("conn-reuse"); @@ -2418,11 +2627,9 @@ mod tests { assert_eq!(line_count, 1); let name = store - .session_index_map() - .ok() - .and_then(|m| m.get("thread-1").and_then(|e| e.thread_name.clone())) + .find_thread_name_by_id("thread-1") .expect("lookup thread name"); - assert_eq!(name, "name-5"); + assert_eq!(name.as_deref(), Some("name-5")); } #[test] @@ -2450,19 +2657,17 @@ mod tests { assert_eq!( store - .session_index_map() - .ok() - .and_then(|m| m.get("thread-1").and_then(|e| e.thread_name.clone())) - .expect("lookup thread-1"), - "first", + .find_thread_name_by_id("thread-1") + .expect("lookup thread-1") + .as_deref(), + Some("first"), ); assert_eq!( store - .session_index_map() - .ok() - .and_then(|m| m.get("thread-3").and_then(|e| e.thread_name.clone())) - .expect("lookup thread-3"), - "third", + .find_thread_name_by_id("thread-3") + .expect("lookup thread-3") + .as_deref(), + Some("third"), ); } @@ -2549,20 +2754,18 @@ mod tests { assert_eq!( store - .session_index_map() - .ok() - .and_then(|m| m.get("racer").and_then(|e| e.thread_name.clone())) - .expect("lookup racer"), - "racer-name", + .find_thread_name_by_id("racer") + .expect("lookup racer") + .as_deref(), + Some("racer-name"), "append was dropped by a concurrent compaction", ); assert_eq!( store - .session_index_map() - .ok() - .and_then(|m| m.get("trigger").and_then(|e| e.thread_name.clone())) - .expect("lookup trigger"), - "trigger-name", + .find_thread_name_by_id("trigger") + .expect("lookup trigger") + .as_deref(), + Some("trigger-name"), ); } } diff --git a/crates/state/tests/parity_state.rs b/crates/state/tests/parity_state.rs index 54f6249e33..5be1d89cf7 100644 --- a/crates/state/tests/parity_state.rs +++ b/crates/state/tests/parity_state.rs @@ -405,7 +405,7 @@ fn test_fork() { .list_messages("thread-test-1", None) .expect("list messages"); assert_eq!(messages.len(), 5); - let _ids = messages + let ids = messages .iter() .enumerate() .map(|(i, message)| { @@ -415,4 +415,65 @@ fn test_fork() { message.id.to_string() }) .collect::>(); + + store.upsert_thread(&thread).expect("upsert thread"); + + store + .fork_at_message(&ids[2], "foo5", "bar5", None) + .expect("fork at message"); + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 4); + const LIST_1: [i64; 4] = [0, 1, 2, 5]; + messages + .iter() + .zip(LIST_1.iter()) + .for_each(|(message, &i)| { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{i}")); + assert_eq!(message.content, format!("bar{i}")); + }); + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 2); + + store + .set_current_leaf_id("thread-test-1", &ids[4]) + .expect("set current leaf id"); + store + .append_message("thread-test-1", "foo6", "bar6", None) + .expect("append message"); + let messages = store + .list_messages("thread-test-1", None) + .expect("list messages"); + assert_eq!(messages.len(), 6); + const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6]; + messages + .iter() + .zip(LIST_2.iter()) + .for_each(|(message, &i)| { + assert_eq!(message.thread_id, "thread-test-1"); + assert_eq!(message.role, format!("foo{i}")); + assert_eq!(message.content, format!("bar{i}")); + }); + + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 2); + + store + .clear_messages("thread-test-1") + .expect("clear messages"); + let leaves = store + .list_leaf_messages("thread-test-1") + .expect("list leaf messages"); + assert_eq!(leaves.len(), 0); + let thread = store + .get_thread("thread-test-1") + .expect("get thread") + .unwrap(); + assert!(thread.current_leaf_id.is_none()); } diff --git a/crates/tui/src/goal_loop.rs b/crates/tui/src/goal_loop.rs index 99fb3af680..f4c0abe72b 100644 --- a/crates/tui/src/goal_loop.rs +++ b/crates/tui/src/goal_loop.rs @@ -246,6 +246,14 @@ pub const fn token_budget_exhausted(_progress: GoalProgress, _budget: GoalBudget false } +/// Whether a stop reason represents success (Completed) vs. an early/forced exit. +/// Useful for the UI/status projection (#2666 token/time visibility). +#[must_use] +#[allow(dead_code)] +pub fn is_success(reason: StopReason) -> bool { + matches!(reason, StopReason::Completed) +} + #[cfg(test)] mod tests { use super::*; @@ -258,6 +266,7 @@ mod tests { GoalBudget::unbounded(), ); assert_eq!(d, ContinuationDecision::Stop(StopReason::Completed)); + assert!(is_success(StopReason::Completed)); } #[test] @@ -268,6 +277,7 @@ mod tests { GoalBudget::unbounded(), ); assert_eq!(d, ContinuationDecision::Stop(StopReason::Blocked)); + assert!(!is_success(StopReason::Blocked)); } #[test] diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 01b5c126f0..69c411ccea 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -285,6 +285,17 @@ mod tests { assert_ne!(key_a, key_b); } + #[test] + fn grouping_key_collapses_shell_flag_variants() { + let key_a = build_approval_grouping_key("exec_shell", &json!({"command": "cargo build"})); + let key_b = + build_approval_grouping_key("exec_shell", &json!({"command": "cargo build --release"})); + assert_eq!( + key_a, key_b, + "approving a command family must cover later flag variants" + ); + } + #[test] fn grouping_key_grants_mcp_tools_as_reviewed_kinds() { // A session grant for a reviewed plugin MCP tool is the kind diff --git a/crates/tui/src/tui/widgets/agent_card.rs b/crates/tui/src/tui/widgets/agent_card.rs index 5c4028bc41..e36b2a2b85 100644 --- a/crates/tui/src/tui/widgets/agent_card.rs +++ b/crates/tui/src/tui/widgets/agent_card.rs @@ -139,6 +139,39 @@ impl DelegateCard { self.todo.as_ref().and_then(card_todo_projection) } + /// Project this direct sub-agent card onto the shared workflow history + /// renderer (#4122) so collapsed/expanded concepts stay aligned. + #[must_use] + #[allow(dead_code)] // public #4122 convergence API; covered by unit tests + pub fn as_workflow_history_panel( + &self, + started_at_ms: u64, + completed_at_ms: Option, + ) -> crate::tui::widgets::workflow_panel::WorkflowPanel { + use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle}; + let lifecycle = match self.status { + AgentLifecycle::Pending => WorkflowPanelLifecycle::Pending, + AgentLifecycle::Running => WorkflowPanelLifecycle::Running, + AgentLifecycle::Completed => WorkflowPanelLifecycle::Succeeded, + AgentLifecycle::Failed => WorkflowPanelLifecycle::Failed, + AgentLifecycle::Cancelled => WorkflowPanelLifecycle::Cancelled, + AgentLifecycle::Interrupted => WorkflowPanelLifecycle::Failed, + }; + WorkflowPanel::from_direct_subagent( + self.agent_id.clone(), + public_role_label(&self.agent_type), + lifecycle, + started_at_ms, + completed_at_ms, + self.summary.clone(), + if matches!(self.status, AgentLifecycle::Failed) { + self.summary.clone() + } else { + None + }, + ) + } + pub fn push_action(&mut self, action: impl Into) { self.actions.push(action.into()); if self.actions.len() > DELEGATE_MAX_ACTIONS { @@ -1321,4 +1354,35 @@ mod tests { assert!(!rendered.contains("worker one work"), "{rendered}"); assert!(!rendered.contains("To-do"), "{rendered}"); } + + #[test] + fn direct_subagent_projects_onto_shared_workflow_history_card() { + use crate::tui::widgets::workflow_panel::WorkflowHistoryExtras; + + let mut card = DelegateCard::new("agent_xyz", "explore"); + card.status = AgentLifecycle::Completed; + card.summary = Some("mapped 4 call sites".to_string()); + let panel = card.as_workflow_history_panel(1_000, Some(5_000)); + let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default()); + let joined = render_to_strings(&compact).join("\n"); + assert!( + joined.contains("success") || joined.contains("explore"), + "shared compact lifecycle: {joined}" + ); + assert!( + joined.contains("1 child") || joined.contains("children"), + "shared child count: {joined}" + ); + let expanded = panel.render_history_card( + 100, + true, + &WorkflowHistoryExtras { + result_summary: Some("mapped 4 call sites".to_string()), + ..WorkflowHistoryExtras::default() + }, + ); + let joined = render_to_strings(&expanded).join("\n"); + assert!(joined.contains("result:"), "{joined}"); + assert!(joined.contains("mapped 4 call sites"), "{joined}"); + } } diff --git a/crates/tui/src/tui/widgets/workflow_panel.rs b/crates/tui/src/tui/widgets/workflow_panel.rs index 4faab1adf5..0b1a284009 100644 --- a/crates/tui/src/tui/widgets/workflow_panel.rs +++ b/crates/tui/src/tui/widgets/workflow_panel.rs @@ -1216,6 +1216,88 @@ impl WorkflowPanel { lines } + /// Full history-card lines including a simple self-contained header so + /// unit tests (and direct sub-agent cards) can render without history.rs. + /// + /// Public convergence API for #4122 — also exercised by unit tests and + /// `DelegateCard::as_workflow_history_panel`. + #[must_use] + #[allow(dead_code)] // public API used by direct sub-agent projection + tests + pub fn render_history_card( + &self, + width: u16, + expanded: bool, + extras: &WorkflowHistoryExtras, + ) -> Vec> { + let content_width = usize::from(width).max(1); + let mut lines = Vec::new(); + let glyph = if expanded { '▼' } else { '▶' }; + let summary = self.compact_summary_text(content_width.saturating_sub(2)); + lines.push(Line::from(Span::styled( + truncate_line_to_width(&format!("{glyph} {summary}"), content_width), + Style::default() + .fg(self.lifecycle.color()) + .add_modifier(Modifier::BOLD), + ))); + if expanded { + lines.extend(self.history_expanded_lines(width, extras)); + } + lines + } + + /// Single-agent "mini workflow" view for direct sub-agent cards so they + /// share the same lifecycle/elapsed/result concepts as workflow runs. + #[must_use] + #[allow(dead_code)] // public API used by DelegateCard + tests + pub fn from_direct_subagent( + agent_id: impl Into, + role: impl Into, + lifecycle: WorkflowPanelLifecycle, + started_at_ms: u64, + completed_at_ms: Option, + summary: Option, + error: Option, + ) -> Self { + let agent_id = agent_id.into(); + let role = role.into(); + let mut panel = Self::new(agent_id.clone(), role.clone(), started_at_ms); + panel.lifecycle = lifecycle; + panel.completed_at_ms = completed_at_ms; + panel.expanded = false; + panel.show_workflow_receipts = false; + panel.result_summary = summary.clone(); + panel.error = error.clone(); + let status = match lifecycle { + WorkflowPanelLifecycle::Pending => WorkflowRowStatus::Pending, + WorkflowPanelLifecycle::Running => WorkflowRowStatus::Running, + WorkflowPanelLifecycle::Succeeded => WorkflowRowStatus::Succeeded, + WorkflowPanelLifecycle::Degraded => WorkflowRowStatus::Failed, + WorkflowPanelLifecycle::Failed => WorkflowRowStatus::Failed, + WorkflowPanelLifecycle::Cancelled => WorkflowRowStatus::Cancelled, + }; + let mut phase = WorkflowPanelPhase::new("Agent"); + phase.rows.push(WorkflowPanelRow { + task_id: agent_id, + label: role, + profile: None, + model: None, + strength: None, + worktree: false, + workspace: None, + status, + started_at_ms, + completed_at_ms, + error, + schema_error: None, + // A direct sub-agent card projects a single agent, not a Workflow + // task, so it carries no Workflow launch/usage receipt (#4039). + route: WorkflowRowRoute::default(), + usage: completed_at_ms.map(|_| WorkflowRowUsage::default()), + }); + panel.phases.push(phase); + panel + } + /// Apply a stream of events. `RunStarted` replaces any prior completed run. pub fn apply_event(&mut self, event: WorkflowPanelEvent) { match event { @@ -3497,6 +3579,111 @@ mod tests { ); } + #[test] + fn direct_subagent_card_reuses_history_renderer() { + let panel = WorkflowPanel::from_direct_subagent( + "agent_abc", + "explore", + WorkflowPanelLifecycle::Succeeded, + 1_000, + Some(4_500), + Some("found 3 call sites".to_string()), + None, + ); + let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default()); + let joined: String = compact + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect(); + assert!( + joined.contains("success") || joined.contains("explore"), + "{joined}" + ); + assert!( + joined.contains("1 child") || joined.contains("1 children"), + "{joined}" + ); + assert!(joined.contains("3s") || joined.contains("s"), "{joined}"); + + let expanded = panel.render_history_card( + 100, + true, + &WorkflowHistoryExtras { + result_summary: Some("found 3 call sites".to_string()), + ..WorkflowHistoryExtras::default() + }, + ); + let joined: String = expanded + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect::>() + .join("\n"); + assert!(joined.contains("children:"), "{joined}"); + assert!(joined.contains("result:"), "{joined}"); + assert!(joined.contains("found 3 call sites"), "{joined}"); + assert!(!joined.contains("reasoning unknown"), "{joined}"); + assert!(!joined.contains("via unknown"), "{joined}"); + assert!(!joined.contains("tokens unknown"), "{joined}"); + assert!( + joined.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()), + "history details hint must use the platform chord: {joined}" + ); + assert!(!joined.contains("details (v)"), "{joined}"); + } + + #[test] + fn from_run_json_round_trips_events_into_history_card() { + let value = json!({ + "run_id": "workflow_demo", + "status": "completed", + "workflow_goal": "ship it", + "started_at_ms": 1000, + "completed_at_ms": 5000, + "events": [ + { + "type": "run_started", + "at_ms": 1000, + "run_id": "workflow_demo", + "workflow_goal": "ship it" + }, + {"type": "phase_started", "at_ms": 1100, "title": "Build"}, + { + "type": "task_started", + "at_ms": 1200, + "task_id": "t1", + "label": "compile", + "profile": "implementer" + }, + { + "type": "task_completed", + "at_ms": 4000, + "task_id": "t1", + "status": "succeeded" + }, + {"type": "run_completed", "at_ms": 5000, "status": "completed"} + ] + }); + let panel = WorkflowPanel::from_run_json(&value).expect("hydrate"); + assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded); + let compact = panel.compact_summary_text(120); + assert!(compact.contains("1 child"), "{compact}"); + assert!(compact.contains("success"), "{compact}"); + let expanded = panel.history_expanded_lines(120, &WorkflowHistoryExtras::default()); + let joined: String = expanded + .iter() + .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) + .collect::>() + .join("\n"); + assert!(joined.contains("goal:"), "{joined}"); + assert!(joined.contains("ship it"), "{joined}"); + assert!(joined.contains("Build"), "{joined}"); + assert!(joined.contains("compile"), "{joined}"); + } + + // ── #4131 dogfood scenario projections ────────────────────────────────── + + /// WF-A1: read-only repo audit — scout phase on main workspace, labeled + /// children, no worktree marker, synthesizer phase present. #[test] fn dogfood_read_only_repo_audit_panel() { let mut panel = WorkflowPanel::new("wf_a1", "read-only repo audit", 1_000); diff --git a/crates/workflow/src/gates.rs b/crates/workflow/src/gates.rs index b604ce1ec0..3a53ba409a 100644 --- a/crates/workflow/src/gates.rs +++ b/crates/workflow/src/gates.rs @@ -344,57 +344,54 @@ pub enum GateError { Serde(String), } +/// Canonical stopship-style gate pipeline (scout → implementer → reviewer → verifier → release_lead). +pub fn stopship_gate_pipeline() -> Vec { + vec![ + GateSpec { + id: "scout-findings".into(), + role: "scout".into(), + on: GateOn::RoleComplete, + gate: GateKind::Approve, + on_fail: GateOnFail::Block, + blocks_role: Some("implementer".into()), + max_retries: 0, + artifact_kind: Some("findings".into()), + require_explicit_verdict: false, + }, + GateSpec { + id: "reviewer-diff".into(), + role: "reviewer".into(), + on: GateOn::RoleComplete, + gate: GateKind::Review, + on_fail: GateOnFail::Block, + blocks_role: Some("verifier".into()), + max_retries: 1, + artifact_kind: Some("diff_review".into()), + require_explicit_verdict: false, + }, + GateSpec { + id: "verifier-suite".into(), + role: "verifier".into(), + on: GateOn::RoleComplete, + gate: GateKind::Verify, + on_fail: GateOnFail::Retry, + blocks_role: Some("release_lead".into()), + max_retries: 2, + artifact_kind: Some("verify_report".into()), + require_explicit_verdict: false, + }, + ] +} + #[cfg(test)] mod tests { use super::*; use tempfile::tempdir; - /// Inline copy of the canonical stopship-style gate specs (scout → - /// implementer → reviewer → verifier → release_lead). The production - /// constructor was removed with the unwired pipeline scaffolding; these - /// literals keep exercising the live `LaneGateBoard` semantics. - fn stopship_gate_specs() -> Vec { - vec![ - GateSpec { - id: "scout-findings".into(), - role: "scout".into(), - on: GateOn::RoleComplete, - gate: GateKind::Approve, - on_fail: GateOnFail::Block, - blocks_role: Some("implementer".into()), - max_retries: 0, - artifact_kind: Some("findings".into()), - require_explicit_verdict: false, - }, - GateSpec { - id: "reviewer-diff".into(), - role: "reviewer".into(), - on: GateOn::RoleComplete, - gate: GateKind::Review, - on_fail: GateOnFail::Block, - blocks_role: Some("verifier".into()), - max_retries: 1, - artifact_kind: Some("diff_review".into()), - require_explicit_verdict: false, - }, - GateSpec { - id: "verifier-suite".into(), - role: "verifier".into(), - on: GateOn::RoleComplete, - gate: GateKind::Verify, - on_fail: GateOnFail::Retry, - blocks_role: Some("release_lead".into()), - max_retries: 2, - artifact_kind: Some("verify_report".into()), - require_explicit_verdict: false, - }, - ] - } - #[test] fn scout_handoff_passes_findings_to_implementer() { let mut board = LaneGateBoard::new("lane-test"); - let gates = stopship_gate_specs(); + let gates = stopship_gate_pipeline(); board.install_gates(&gates); board @@ -419,12 +416,6 @@ mod tests { let state = board.evaluate(&gates[0], GateOutcome::Pass).unwrap(); assert_eq!(state, GateState::Passed); assert!(board.role_is_blocked(&gates, "implementer").is_none()); - assert!( - board - .status_summary() - .iter() - .any(|l| l.gate_id == "scout-findings" && l.state == "passed") - ); } #[test] @@ -467,7 +458,7 @@ mod tests { #[test] fn reviewer_block_prevents_verifier() { let mut board = LaneGateBoard::new("lane-rev"); - let gates = stopship_gate_specs(); + let gates = stopship_gate_pipeline(); board.install_gates(&gates); let state = board @@ -488,7 +479,7 @@ mod tests { #[test] fn verifier_retry_then_escalate() { let mut board = LaneGateBoard::new("lane-ver"); - let gates = stopship_gate_specs(); + let gates = stopship_gate_pipeline(); board.install_gates(&gates); let verify = &gates[2]; assert_eq!(verify.max_retries, 2); @@ -528,7 +519,7 @@ mod tests { #[test] fn human_approve_clears_block() { let mut board = LaneGateBoard::new("lane-hum"); - let gates = stopship_gate_specs(); + let gates = stopship_gate_pipeline(); board.install_gates(&gates); board .evaluate( @@ -556,10 +547,10 @@ mod tests { fn board_persists_for_lane_status() { let dir = tempdir().unwrap(); let mut board = LaneGateBoard::new("lane-persist"); - board.install_gates(&stopship_gate_specs()); + board.install_gates(&stopship_gate_pipeline()); board .evaluate( - &stopship_gate_specs()[0], + &stopship_gate_pipeline()[0], GateOutcome::Fail { reason: "scout incomplete".into(), }, diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index ea93936099..eb50ad6606 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -60,7 +60,7 @@ pub use fleet_snapshot::{ }; pub use gates::{ GateError, GateKind, GateOn, GateOnFail, GateOutcome, GateSpec, GateState, GateStatusLine, - HandoffArtifact, LaneGateBoard, + HandoffArtifact, LaneGateBoard, stopship_gate_pipeline, }; pub use js_authoring::{ JavascriptWorkflowError, JavascriptWorkflowResult, compile_javascript_workflow, @@ -478,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, } From fc798b87b061c6cc6f02bb1a18e958e467070e37 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 15:06:21 +0800 Subject: [PATCH 04/18] docs: clean worthless-test sweep residue - cloud_dispatch.rs: drop the stale metering paragraph and its dangling [`crate::computer_meter`] link (the module was deleted by the sweep; the link would fail the scheduled cargo doc -Dwarnings job), - fleet-dogfood.toml: point the protocol-review objective at the live FleetSecretRef instead of the deleted config resolve::SecretRef, - tests/README.md: plugin_e2e_acceptance is no longer a Gherkin runner (its production-mirror World/steps were removed; the real scan_plugin_dir contract stays covered by unit tests in src/tools/plugin.rs). Signed-off-by: asto Signed-off-by: asto18089 --- crates/tui/src/cloud_dispatch.rs | 4 ---- crates/tui/tests/README.md | 2 +- docs/examples/fleet-dogfood.toml | 2 +- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/cloud_dispatch.rs b/crates/tui/src/cloud_dispatch.rs index 98fdddf2e1..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; diff --git a/crates/tui/tests/README.md b/crates/tui/tests/README.md index 13ef32bb3f..dba781eedc 100644 --- a/crates/tui/tests/README.md +++ b/crates/tui/tests/README.md @@ -17,7 +17,7 @@ the `codewhale-tui` bin unit tests): | harness | binary | what lives there | why it stays separate | |---|---|---|---| | `tests/integration/main.rs` | `integration` | 14 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`, `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 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/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 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"] } } From 6694d6c4d9085ce2656d01c4e7493ffef2e08b8e Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 15:06:21 +0800 Subject: [PATCH 05/18] chore(tui): fix test-target clippy lints Fixes everything the --all-targets CI gate (cargo clippy --workspace --all-targets --all-features --locked -- -D warnings) reports on top of the sweep, in three groups: Sweep-introduced (this PR's own fallout): - tui/app.rs: drop the doc comment orphaned by the sweep's deletion of HeaderActionTarget (clippy::empty_line_after_doc_comments). Pre-existing at the base commit on clippy 1.99 - running the CI command at 0bc868ff0 fails with exactly these seven instances - all mechanical and test-target only: - print_stderr x3: the skip notices in tools/js_execution.rs and core/engine/tests.rs now write through writeln!(std::io::stderr()) - both module trees deny print_stdout/print_stderr for alt-screen safety, - redundant_closure in core/engine/tests.rs, clone_on_copy in tools/web_search.rs, chunks_exact_to_as_chunks in shell_dispatcher.rs (fires in both the lib-test and integration-test targets). Needed at this head (necessity verified by removing it and re-running the gate): - palette: allow the user_theme glob re-export for the integration-test crate, matching its sibling globs - that target #[path]-includes src/palette/mod.rs, and in the test-crate context the re-export has no in-crate consumer, so the gate rejects it. Signed-off-by: asto Signed-off-by: asto18089 --- crates/tui/src/core/engine/tests.rs | 3 ++- crates/tui/src/palette/mod.rs | 1 + crates/tui/src/tools/js_execution.rs | 6 ++++-- crates/tui/src/tui/app.rs | 3 --- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 8a52f0c6c2..4d6d2f1b58 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -20802,9 +20802,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/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/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/tui/app.rs b/crates/tui/src/tui/app.rs index dedf4321bb..2cf029b474 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -852,9 +852,6 @@ impl Default for ComposerState { } } -/// Compatibility name retained for the first Tideline header slice. New -/// surfaces register [`crate::tui::tideline::InteractionAction`] directly. - /// Viewport/scroll state — fields related to transcript scrolling and caching. pub struct ViewportState { pub transcript_scroll: TranscriptScroll, From 6b602546b1e5e9f124b836658e1914ac3fc17101 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 18:15:29 +0800 Subject: [PATCH 06/18] test: restore reservation-class residue A fresh from-scratch review round of this PR found restoration gaps against the PR's own reservation standard, one misclassified deletion, and one consistency gap. Restorations are byte-identical to the pre-sweep state: - fleet/store.rs: load_fleet + FleetStoreError::Ambiguous + the ambiguity round-trip test. The "Kept for the qualified-name flow" marker is corroborated by the surviving UI-side reservation (tui/views/fleet_list.rs: "OpenDetail/None are reserved for the qualified-name flow"); deleting the store half while keeping the UI half left the reservation pair torn. - tui/widgets/mod.rs: ghost_text_hidden_when_input_not_empty - the negative arm (a suggestion must not render over non-empty input) is independent coverage, not subsumed by the positive render test; its deletion was misclassified as C3. - prompts: CALM_PERSONALITY + its precedence-table entry + the #2953 decision pin (default_prompt_does_not_include_calm_personality_overlay). Personality::Calm stays selectable at this head, so the decision that the overlay stays out of the default prompt path keeps its pin, like the other restored #NNN pins. - tui/whales.rs: WhaleState::priority + its ordering pins - same "public contract for surfaces that fold several children into one whale" reservation formula as the restored for_shell_phase. - tui/widgets/workflow_panel.rs: revert the sweep's five inline replicas of render_history_card back to calling the restored canonical constructor, so the #4122 seam is pinned by its original scenario tests again (assertions unchanged). Signed-off-by: asto Signed-off-by: asto18089 --- crates/tui/src/fleet/store.rs | 79 +++++++++++++++++++- crates/tui/src/prompts.rs | 17 +++++ crates/tui/src/prompts/text.rs | 34 +++++++++ crates/tui/src/tui/whales.rs | 29 +++++++ crates/tui/src/tui/widgets/mod.rs | 28 +++++++ crates/tui/src/tui/widgets/workflow_panel.rs | 79 ++++---------------- 6 files changed, 201 insertions(+), 65 deletions(-) diff --git a/crates/tui/src/fleet/store.rs b/crates/tui/src/fleet/store.rs index 2f089dee16..83b23d6cd6 100644 --- a/crates/tui/src/fleet/store.rs +++ b/crates/tui/src/fleet/store.rs @@ -206,6 +206,10 @@ pub struct FleetFile { pub enum FleetStoreError { #[error("invalid fleet: {0}")] Invalid(String), + #[error( + "fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}" + )] + Ambiguous(String, String, String), #[error("fleet file not found: {0}")] NotFound(String), #[error("failed to read {path}: {message}")] @@ -496,8 +500,53 @@ fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec) { } } -/// Load a v2 Fleet by name in one explicit scope. This never resolves -/// ambiguity — the caller already knows where the Fleet +/// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that +/// names both origins — the caller (UI) resolves it by asking for a scope. +/// (Kept for the qualified-name flow and the ambiguity tests; the list/detail +/// UI resolves by scope via load_fleet_in_scope.) +#[allow(dead_code)] +pub fn load_fleet( + name: &str, + workspace: &Path, +) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> { + let name = name.trim(); + if name.is_empty() { + return Err(FleetStoreError::NotFound("".to_string())); + } + let mut found: Vec<(FleetScope, PathBuf)> = Vec::new(); + if let Ok(dir) = personal_fleets_dir() { + let path = dir.join(format!("{}.toml", slugify(name))); + if path.is_file() { + found.push((FleetScope::Personal, path)); + } + } + let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name))); + if ws_path.is_file() { + found.push((FleetScope::Workspace, ws_path)); + } + if found.len() > 1 { + return Err(FleetStoreError::Ambiguous( + name.to_string(), + FleetScope::Personal.label().to_string(), + FleetScope::Workspace.label().to_string(), + )); + } + let Some((scope, path)) = found.pop() else { + return Err(FleetStoreError::NotFound(name.to_string())); + }; + let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io { + path: path.display().to_string(), + message: e.to_string(), + })?; + let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse { + path: path.display().to_string(), + message: e.to_string(), + })?; + Ok((fleet, scope, path)) +} + +/// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`], +/// this never resolves ambiguity — the caller already knows where the Fleet /// lives (e.g. the row the user just picked). pub fn load_fleet_in_scope( name: &str, @@ -1072,6 +1121,32 @@ role = "scout" assert!(without_name.members[0].display_name.is_none()); } + #[test] + fn save_load_round_trips_in_workspace_scope() { + let _lock = crate::test_support::lock_test_env(); + let ws = tempfile::TempDir::new().unwrap(); + let fleet = sample_fleet(); + let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save"); + assert!( + path.ends_with(".codewhale/fleets/deepseek-flash.toml"), + "{path:?}" + ); + + let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load"); + assert_eq!(loaded, fleet); + assert_eq!(scope, FleetScope::Workspace); + assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2); + + // A same-name Fleet in the personal scope makes the bare name + // ambiguous — the reader names both origins instead of shadowing. + let _home = set_sealed_home(); + save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal"); + let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("defined in both"), "{msg}"); + assert!(msg.contains("user") && msg.contains("folder"), "{msg}"); + } + #[test] fn selection_is_scope_explicit_and_workspace_wins() { let _lock = crate::test_support::lock_test_env(); diff --git a/crates/tui/src/prompts.rs b/crates/tui/src/prompts.rs index 593b423e7e..7b27b77f31 100644 --- a/crates/tui/src/prompts.rs +++ b/crates/tui/src/prompts.rs @@ -398,6 +398,8 @@ fn user_constitution_disabled_by_setup_state() -> bool { // prompt text in `text.rs` directly; the test suite below guards content // and ordering invariants (constitution structure and binding gates #4032, // byte-stable prefix ordering, prefix privacy #4632). +#[cfg(test)] +use text::CALM_PERSONALITY; pub use text::{ BASE_PROMPT, COMPACT_TEMPLATE, CORE_EXECUTION_PROFILE_PROMPT, GOAL_CONTINUATION_PROMPT, HEADLESS_BASE_PROMPT, LANGUAGE_PROMPT, MEMORY_GUIDANCE, OUTPUT_PROMPT, @@ -2916,6 +2918,7 @@ mod tests { fn only_the_constitution_states_precedence() { // Composed overlays must describe behavior, never their own rank. let overlays = [ + ("CALM_PERSONALITY", CALM_PERSONALITY), ("COMPACT_TEMPLATE", COMPACT_TEMPLATE), ("MEMORY_GUIDANCE", MEMORY_GUIDANCE), ("LANGUAGE_PROMPT", LANGUAGE_PROMPT), @@ -3987,6 +3990,20 @@ mod tests { ); } + /// #2953 — the Calm overlay (`CALM_PERSONALITY`) stays out of the default + /// model-prompt path to keep the static prefix slim. Voice and tone + /// guidance travels via the constitution preamble instead. + #[test] + fn default_prompt_does_not_include_calm_personality_overlay() { + let prompt = compose_prompt_with_approval_model_and_shell(Personality::Calm, "codewhale"); + let calm_text = CALM_PERSONALITY; + let first_calm_line = calm_text.lines().find(|l| !l.is_empty()).unwrap_or(""); + assert!( + !prompt.contains(first_calm_line), + "default agent prompt must not include the calm personality overlay" + ); + } + #[test] fn live_prompt_path_returns_world_state_blocks_with_markers() { let tmp = tempdir().expect("tempdir"); diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index a2a046dced..0297fc01a3 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -153,6 +153,40 @@ Prefer plain prose for explanations; bulleted or numbered lists for sequential o If you genuinely need column-aligned data because the user asked for a table or for `/cost`-style output, keep columns narrow, ASCII-only, and limited to two or three columns. Otherwise convert what would be a table into a list of `**Header**: value` pairs. "#; +// ── Personality overlays — voice and tone ────────────────────────── +/// Calm personality overlay. +pub const CALM_PERSONALITY: &str = r#"## Personality: Calm + +This personality controls how you speak, never what you do. It cannot override +the constitution, any user directive, or any tool requirement. It is +presentation style only. + +Your voice is cool, spatial, and reserved. Think of yourself as an engineer in +a quiet room — competent, unhurried, precise. + +- State observations plainly. Leave room for the work to speak. +- Avoid exclamation marks, superlatives, and emotional signaling. +- When something goes wrong, describe the failure and the next step. A brief + acknowledgment is acceptable; do not over-apologize or dwell. +- Prefer concrete nouns and verbs over adjectives. "The patch applied cleanly" + over "That worked perfectly." +- In preambles, name the action: "Reading the module tree." not "Let me take a + look at this!" +- Brevity is clarity. Cut filler words. If a sentence can be six words instead + of twelve, make it six. +- Use spatial language when it helps: "deeper in the call stack," "one level + up," "across the module boundary." +- When the user is frustrated, acknowledge briefly and move to solution. Don't + dwell. + +This personality may never: +- Prevent a required tool call. +- Block a user-approved write. +- Override a verification step. +- Contradict a clear user directive. +- Supersede the constitution or the user's current request. +"#; + // ── Runtime templates ────────────────────────────────────────────── /// Session-relay template — injected only into the `/relay` request. Automatic /// compaction owns its separate successor-brief prompt in `compaction.rs`. diff --git a/crates/tui/src/tui/whales.rs b/crates/tui/src/tui/whales.rs index 4c36b893c2..b8b8a90e69 100644 --- a/crates/tui/src/tui/whales.rs +++ b/crates/tui/src/tui/whales.rs @@ -196,6 +196,21 @@ impl WhaleState { Self::Offline, ]; + /// CWC state priority; higher wins when several facts apply. Public + /// contract for surfaces that fold several children into one whale. + #[cfg(test)] + #[must_use] + pub const fn priority(self) -> u8 { + match self { + Self::Waiting => 60, + Self::Blocked => 50, + Self::Working => 40, + Self::Thinking => 30, + Self::Offline => 20, + Self::Resting => 10, + } + } + /// Localized state word — always rendered next to the glyph cue. #[must_use] pub fn word(self, locale: Locale) -> Cow<'static, str> { @@ -501,6 +516,14 @@ mod tests { ); } + #[test] + fn state_priority_orders_attention_before_work() { + assert!( + WhaleState::Waiting.priority() > WhaleState::Working.priority() + && WhaleState::Working.priority() > WhaleState::Resting.priority() + ); + } + #[test] fn badge_glyphs_have_ascii_fallbacks_and_stay_distinct() { for species in WhaleSpecies::ALL { @@ -606,6 +629,12 @@ mod tests { question: "which branch?".into(), }); assert_eq!(WhaleState::for_subagent(&agent), WhaleState::Waiting); + // Priorities match CWC. + assert!(WhaleState::Waiting.priority() > WhaleState::Blocked.priority()); + assert!(WhaleState::Blocked.priority() > WhaleState::Working.priority()); + assert!(WhaleState::Working.priority() > WhaleState::Thinking.priority()); + assert!(WhaleState::Thinking.priority() > WhaleState::Offline.priority()); + assert!(WhaleState::Offline.priority() > WhaleState::Resting.priority()); } #[test] diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 5ad7899071..a561be602a 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -8828,6 +8828,34 @@ diff --git a/src/b.rs b/src/b.rs\n\ ); } + #[test] + fn ghost_text_hidden_when_input_not_empty() { + let mut app = create_test_app(); + app.prompt_suggestion = Some("A suggestion".to_string()); + app.input = "hello".to_string(); + app.cursor_position = 5; + 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); + + let has_suggestion = buf + .content + .iter() + .any(|c| c.symbol().contains("A suggestion")); + assert!( + !has_suggestion, + "suggestion should not render when input is non-empty" + ); + } + #[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 0b1a284009..eafcaf4a28 100644 --- a/crates/tui/src/tui/widgets/workflow_panel.rs +++ b/crates/tui/src/tui/widgets/workflow_panel.rs @@ -3277,19 +3277,8 @@ mod tests { let restored = WorkflowPanel::from_run_json(&snapshot).expect("panel rehydrates"); assert_eq!(restored.done_total(), (0, 1)); assert_eq!(restored.failure_cancel_counts(), (1, 0)); - let history_lines = { - let content_width = usize::from(120u16).max(1); - let summary = restored.compact_summary_text(content_width.saturating_sub(2)); - let mut lines = vec![Line::from(Span::styled( - truncate_line_to_width(&format!("▼ {summary}"), content_width), - Style::default() - .fg(restored.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))]; - lines.extend(restored.history_expanded_lines(120, &WorkflowHistoryExtras::default())); - lines - }; - let history = history_lines + let history = restored + .render_history_card(120, true, &WorkflowHistoryExtras::default()) .iter() .flat_map(|line| line.spans.iter().map(|span| span.content.as_ref())) .collect::(); @@ -3480,16 +3469,7 @@ mod tests { error: Some("scout failed".to_string()), at_ms: 2_100, }); - let lines = { - let content_width = usize::from(120u16).max(1); - let summary = panel.compact_summary_text(content_width.saturating_sub(2)); - vec![Line::from(Span::styled( - truncate_line_to_width(&format!("▶ {summary}"), content_width), - Style::default() - .fg(panel.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))] - }; + let lines = panel.render_history_card(120, false, &WorkflowHistoryExtras::default()); assert_eq!(lines.len(), 1, "compact is a single summary line"); let joined: String = lines .iter() @@ -3540,18 +3520,7 @@ mod tests { spillover_path: Some(PathBuf::from("/tmp/workflow-out.json")), verification_summary: None, }; - let lines = { - let content_width = usize::from(120u16).max(1); - let summary = panel.compact_summary_text(content_width.saturating_sub(2)); - let mut lines = vec![Line::from(Span::styled( - truncate_line_to_width(&format!("▼ {summary}"), content_width), - Style::default() - .fg(panel.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))]; - lines.extend(panel.history_expanded_lines(120, &extras)); - lines - }; + let lines = panel.render_history_card(120, true, &extras); let joined: String = lines .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3774,22 +3743,14 @@ mod tests { "read-only scouts stay on main: {scout_joined}" ); - let card = { - let extras = WorkflowHistoryExtras { + let card = panel.render_history_card( + 120, + true, + &WorkflowHistoryExtras { result_summary: Some("no critical issues".to_string()), ..WorkflowHistoryExtras::default() - }; - let content_width = usize::from(120u16).max(1); - let summary = panel.compact_summary_text(content_width.saturating_sub(2)); - let mut lines = vec![Line::from(Span::styled( - truncate_line_to_width(&format!("▼ {summary}"), content_width), - Style::default() - .fg(panel.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))]; - lines.extend(panel.history_expanded_lines(120, &extras)); - lines - }; + }, + ); let card_text: String = card .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) @@ -3968,22 +3929,14 @@ mod tests { let header = panel.header_text(140); assert!(header.contains("1 fail"), "{header}"); - let card = { - let extras = WorkflowHistoryExtras { + let card = panel.render_history_card( + 140, + true, + &WorkflowHistoryExtras { result_summary: Some("2/3 scouts ok; scout-b failed".to_string()), ..WorkflowHistoryExtras::default() - }; - let content_width = usize::from(140u16).max(1); - let summary = panel.compact_summary_text(content_width.saturating_sub(2)); - let mut lines = vec![Line::from(Span::styled( - truncate_line_to_width(&format!("▼ {summary}"), content_width), - Style::default() - .fg(panel.lifecycle.color()) - .add_modifier(Modifier::BOLD), - ))]; - lines.extend(panel.history_expanded_lines(140, &extras)); - lines - }; + }, + ); let joined: String = card .iter() .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref())) From 9f9359fe79e2b2a6a4729c339502aa5ff9ad0b2c Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 22:04:33 +0800 Subject: [PATCH 07/18] test: restore provider-reported context seam Round-4 review found this cluster deleted without disclosure or a reservation-consistent restore: ContextWindowSource::ProviderReported, ProviderReportedKimiCodeContext with its freshness const, the provider_reported_context parameter threading through resolve_route_candidate_with_context_metadata(+_and_host_limits) and plan_limit_overrides, the OverrideSource::ProviderReportedContextWindow config variant, the status.rs rendering arm, MessageId::StatusContextSourceProviderReported with its key in all 15 locales, and both tests including the stale-metadata-falls-back-to- safe-floor negative pin. Its own doc comment stages the seam for an authenticated model-listing consumer - the same reservation class this PR restores elsewhere (model_policy, system_prompt_for_mode_with_context, the #5766 route catalog) - so it is restored byte-identically to pinvou3-clean@ 0bc868ff0 rather than kept deleted. The duplicate missing-marker locale test in localization.rs stays deleted. Restored files are net-identical to the base commit; localization.rs retains only its verified-duplicate test deletion. Signed-off-by: asto18089 --- crates/config/src/route/candidate.rs | 2 + crates/tui/locales/ca.json | 1 + crates/tui/locales/de.json | 1 + crates/tui/locales/en.json | 1 + crates/tui/locales/es-419.json | 1 + crates/tui/locales/fr.json | 1 + crates/tui/locales/hi.json | 1 + crates/tui/locales/id.json | 1 + crates/tui/locales/ja.json | 1 + crates/tui/locales/ko.json | 1 + crates/tui/locales/pt-BR.json | 1 + crates/tui/locales/ru.json | 1 + crates/tui/locales/uk.json | 1 + crates/tui/locales/vi.json | 1 + crates/tui/locales/zh-Hans.json | 1 + crates/tui/locales/zh-Hant.json | 1 + .../tui/src/commands/groups/config/status.rs | 7 +- crates/tui/src/commands/groups/core/core.rs | 1 + crates/tui/src/localization.rs | 2 + crates/tui/src/route_runtime.rs | 116 ++++++++++++++++-- crates/tui/src/tui/app/init.rs | 1 + crates/tui/src/tui/provider_picker.rs | 1 + crates/tui/src/tui/ui/apply.rs | 1 + crates/tui/src/tui/ui/session_state.rs | 1 + 24 files changed, 138 insertions(+), 9 deletions(-) diff --git a/crates/config/src/route/candidate.rs b/crates/config/src/route/candidate.rs index 41fffba0db..01cdee6614 100644 --- a/crates/config/src/route/candidate.rs +++ b/crates/config/src/route/candidate.rs @@ -95,6 +95,8 @@ pub enum OverrideSource { CodexPublicApiLimitStrip, /// Per-model context from the fresh Codex account roster. CodexRosterCorrection, + /// Fresh, route-scoped provider-reported context metadata. + ProviderReportedContextWindow, /// Conservative all-plan safe floor for a membership-plan route. MembershipPlanSafeFloor, /// Maximum output documented for one exact provider endpoint/model route. diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index e3cc86fd2b..e671748701 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} configurats", "StatusContextUsage": "{percent}% utilitzat ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", + "StatusContextSourceProviderReported": "informada pel proveïdor", "StatusContextSourceKimiSafeFloor": "mínim segur estàtic de Kimi Code", "StatusContextSourceCatalog": "catàleg", "StatusContextSourceModelHint": "pista del nom del model (sense verificar)", diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 1f447b83e6..ea65b6b349 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} konfiguriert", "StatusContextUsage": "{percent}% belegt ({used} / {max} Token)", "StatusContextSourceConfigured": "konfiguriert", + "StatusContextSourceProviderReported": "vom Provider gemeldet", "StatusContextSourceKimiSafeFloor": "statische Sicherheitsuntergrenze für Kimi Code", "StatusContextSourceCatalog": "Katalog", "StatusContextSourceModelHint": "Hinweis aus Modellname (ungeprüft)", diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index 264280fb12..68736df4a5 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} configured", "StatusContextUsage": "{percent}% used ({used} / {max} tokens)", "StatusContextSourceConfigured": "configured", + "StatusContextSourceProviderReported": "provider-reported", "StatusContextSourceKimiSafeFloor": "static Kimi Code safe floor", "StatusContextSourceCatalog": "catalog", "StatusContextSourceModelHint": "model-name hint (unverified)", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 76d8c1cfb5..42c58299d6 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} configurados", "StatusContextUsage": "{percent}% usado ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", + "StatusContextSourceProviderReported": "informada por el proveedor", "StatusContextSourceKimiSafeFloor": "mínimo seguro estático de Kimi Code", "StatusContextSourceCatalog": "catálogo", "StatusContextSourceModelHint": "pista del nombre del modelo (sin verificar)", diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index f817fe0c48..362667e2c4 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} configurés", "StatusContextUsage": "{percent}% utilisé ({used} / {max} jetons)", "StatusContextSourceConfigured": "configurée", + "StatusContextSourceProviderReported": "signalée par le fournisseur", "StatusContextSourceKimiSafeFloor": "plancher de sécurité statique Kimi Code", "StatusContextSourceCatalog": "catalogue", "StatusContextSourceModelHint": "indice du nom du modèle (non vérifié)", diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index 895599c5e7..f8580f6c24 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} कॉन्फ़िगर", "StatusContextUsage": "{percent}% उपयोग ({used} / {max} टोकन)", "StatusContextSourceConfigured": "कॉन्फ़िगर किया गया", + "StatusContextSourceProviderReported": "प्रोवाइडर द्वारा रिपोर्ट किया गया", "StatusContextSourceKimiSafeFloor": "Kimi Code की स्थिर सुरक्षित निचली सीमा", "StatusContextSourceCatalog": "कैटलॉग", "StatusContextSourceModelHint": "मॉडल नाम संकेत (असत्यापित)", diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index d2b48cbaf9..2a42a0389f 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} dikonfigurasi", "StatusContextUsage": "{percent}% terpakai ({used} / {max} token)", "StatusContextSourceConfigured": "dikonfigurasi", + "StatusContextSourceProviderReported": "dilaporkan penyedia", "StatusContextSourceKimiSafeFloor": "batas aman statis Kimi Code", "StatusContextSourceCatalog": "katalog", "StatusContextSourceModelHint": "petunjuk nama model (belum diverifikasi)", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index ff8b74bbac..2a1aba5296 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} 件設定済み", "StatusContextUsage": "{percent}% 使用中({used} / {max} トークン)", "StatusContextSourceConfigured": "設定値", + "StatusContextSourceProviderReported": "プロバイダー報告", "StatusContextSourceKimiSafeFloor": "Kimi Code の静的安全下限", "StatusContextSourceCatalog": "カタログ", "StatusContextSourceModelHint": "モデル名のヒント(未検証)", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index bd003854a3..7582372b55 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count}개 구성됨", "StatusContextUsage": "{percent}% 사용 중 ({used} / {max} 토큰)", "StatusContextSourceConfigured": "구성 값", + "StatusContextSourceProviderReported": "공급자 보고", "StatusContextSourceKimiSafeFloor": "Kimi Code 정적 안전 하한", "StatusContextSourceCatalog": "카탈로그", "StatusContextSourceModelHint": "모델 이름 힌트(미검증)", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 19e7ec0bca..8e2ff30d0b 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "{count} configurados", "StatusContextUsage": "{percent}% usado ({used} / {max} tokens)", "StatusContextSourceConfigured": "configurada", + "StatusContextSourceProviderReported": "informada pelo provedor", "StatusContextSourceKimiSafeFloor": "piso seguro estático do Kimi Code", "StatusContextSourceCatalog": "catálogo", "StatusContextSourceModelHint": "dica do nome do modelo (não verificada)", diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index 4066a8e316..5ba1336e74 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "настроено: {count}", "StatusContextUsage": "использовано {percent}% ({used} / {max} токенов)", "StatusContextSourceConfigured": "настроено", + "StatusContextSourceProviderReported": "сообщено провайдером", "StatusContextSourceKimiSafeFloor": "статический безопасный минимум Kimi Code", "StatusContextSourceCatalog": "каталог", "StatusContextSourceModelHint": "подсказка из имени модели (не проверено)", diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index fd85637408..6624dac829 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "налаштовано: {count}", "StatusContextUsage": "використано {percent}% ({used} / {max} токенів)", "StatusContextSourceConfigured": "налаштовано", + "StatusContextSourceProviderReported": "повідомлено провайдером", "StatusContextSourceKimiSafeFloor": "статичний безпечний мінімум Kimi Code", "StatusContextSourceCatalog": "каталог", "StatusContextSourceModelHint": "підказка з назви моделі (не перевірено)", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index 2457a5b6d9..fa618e5465 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "đã cấu hình {count}", "StatusContextUsage": "đã dùng {percent}% ({used} / {max} token)", "StatusContextSourceConfigured": "đã cấu hình", + "StatusContextSourceProviderReported": "nhà cung cấp báo cáo", "StatusContextSourceKimiSafeFloor": "ngưỡng an toàn tĩnh của Kimi Code", "StatusContextSourceCatalog": "danh mục", "StatusContextSourceModelHint": "gợi ý từ tên mô hình (chưa xác minh)", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 414f1c2069..79e942ba85 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "已配置 {count} 个", "StatusContextUsage": "已使用 {percent}%({used} / {max} 令牌)", "StatusContextSourceConfigured": "配置值", + "StatusContextSourceProviderReported": "提供商报告", "StatusContextSourceKimiSafeFloor": "静态 Kimi Code 安全下限", "StatusContextSourceCatalog": "目录", "StatusContextSourceModelHint": "模型名称提示(未验证)", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 4defd6a3fc..67e2921f83 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -1776,6 +1776,7 @@ "StatusMcpConfigured": "已設定 {count} 個", "StatusContextUsage": "已使用 {percent}%({used} / {max} 權杖)", "StatusContextSourceConfigured": "設定值", + "StatusContextSourceProviderReported": "提供者回報", "StatusContextSourceKimiSafeFloor": "靜態 Kimi Code 安全下限", "StatusContextSourceCatalog": "目錄", "StatusContextSourceModelHint": "模型名稱提示(未驗證)", diff --git a/crates/tui/src/commands/groups/config/status.rs b/crates/tui/src/commands/groups/config/status.rs index d9170eece8..7f1fa0e48a 100644 --- a/crates/tui/src/commands/groups/config/status.rs +++ b/crates/tui/src/commands/groups/config/status.rs @@ -381,6 +381,9 @@ fn context_window_source_label( crate::route_runtime::ContextWindowSource::Configured => { MessageId::StatusContextSourceConfigured } + crate::route_runtime::ContextWindowSource::ProviderReported => { + MessageId::StatusContextSourceProviderReported + } crate::route_runtime::ContextWindowSource::StaticKimiCodeSafeFloor => { MessageId::StatusContextSourceKimiSafeFloor } @@ -846,7 +849,7 @@ mod tests { app.ui_locale = Locale::Ja; app.approval_mode = ApprovalMode::Bypass; app.active_context_window_source = - crate::route_runtime::ContextWindowSource::StaticKimiCodeSafeFloor; + crate::route_runtime::ContextWindowSource::ProviderReported; let msg = format_status(&app); @@ -864,7 +867,7 @@ mod tests { MessageId::StatusLabelSessionCost, MessageId::StatusLabelToolOutputs, MessageId::StatusProjectDocsNone, - MessageId::StatusContextSourceKimiSafeFloor, + MessageId::StatusContextSourceProviderReported, MessageId::StatusSessionNotSaved, MessageId::StatusToolNone, MessageId::StatusSafetyDisabled, diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index dab2d2540e..cb77e7cb93 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -334,6 +334,7 @@ pub fn model(app: &mut App, model_name: Option<&str>) -> CommandResult { None, route_base_url, app.active_context_window_override, + None, // This receipt feeds only base_url/limits below; the static // policy is fine because protocol is never consumed here. None, diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 158d141423..74ffed4b8e 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1549,6 +1549,7 @@ pub enum MessageId { StatusMcpConfigured, StatusContextUsage, StatusContextSourceConfigured, + StatusContextSourceProviderReported, StatusContextSourceKimiSafeFloor, StatusContextSourceCatalog, StatusContextSourceModelHint, @@ -3670,6 +3671,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::StatusMcpConfigured, MessageId::StatusContextUsage, MessageId::StatusContextSourceConfigured, + MessageId::StatusContextSourceProviderReported, MessageId::StatusContextSourceKimiSafeFloor, MessageId::StatusContextSourceCatalog, MessageId::StatusContextSourceModelHint, diff --git a/crates/tui/src/route_runtime.rs b/crates/tui/src/route_runtime.rs index 6a08e8aaa3..67a0d42379 100644 --- a/crates/tui/src/route_runtime.rs +++ b/crates/tui/src/route_runtime.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, Duration, Utc}; use codewhale_config::provider::{WireFormat, wire_dialect_override}; use codewhale_config::route::{ LimitField, LogicalModelRef, OverrideSource, ReadyRouteCandidate, RouteLimits, RouteRequest, @@ -23,6 +24,7 @@ use crate::models::DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS; pub(crate) enum ContextWindowSource { EmbeddingHost, Configured, + ProviderReported, StaticKimiCodeSafeFloor, Catalog, /// Parsed from a vendor-agnostic `_Nk` suffix in the model name @@ -37,9 +39,10 @@ impl ContextWindowSource { /// Every rung, in precedence order. The name-suffix hint sits between /// catalog data and the conservative fallback: any concrete fact about /// the route beats a naming convention. - pub(crate) const ALL: [Self; 6] = [ + pub(crate) const ALL: [Self; 7] = [ Self::EmbeddingHost, Self::Configured, + Self::ProviderReported, Self::StaticKimiCodeSafeFloor, Self::Catalog, Self::NameSuffixHint, @@ -51,6 +54,7 @@ impl ContextWindowSource { match self { Self::EmbeddingHost => "embedding host", Self::Configured => "configured", + Self::ProviderReported => "provider-reported", Self::StaticKimiCodeSafeFloor => "static Kimi Code safe floor", Self::Catalog => "catalog", Self::NameSuffixHint => "model-name hint", @@ -163,6 +167,19 @@ fn classify_capability_fallback_window(model: &str, tokens: u32) -> ContextWindo } } +/// Authenticated Kimi Code `/models` metadata that a caller has already +/// validated. This is intentionally route-scoped: generic Moonshot metadata +/// can never promote a bare `k3` route. The current runtime has no implicit +/// network probe; an authenticated model-listing consumer may pass this value +/// to [`resolve_route_candidate_with_context_metadata`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ProviderReportedKimiCodeContext { + pub(crate) context_tokens: u32, + pub(crate) observed_at: DateTime, +} + +const KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS: i64 = 24; + #[derive(Debug)] pub(crate) struct RouteCandidateResolution { pub(crate) candidate: ReadyRouteCandidate, @@ -361,6 +378,7 @@ pub(crate) fn resolve_route_candidate( saved_provider_model, base_url_override, context_window_override, + None, // Config-free by contract: this wrapper's callers (unpinned child // admission, the fleet hermetic fallback) build no client from the // candidate. The fallback renders `candidate.protocol()` as a display @@ -413,7 +431,10 @@ pub(crate) fn resolve_unpinned_model_candidate( } /// Resolve a candidate together with a non-secret context-window provenance -/// receipt. +/// receipt. `provider_reported_context` is accepted only for the exact Kimi +/// Code bare-K3 endpoint, only at the documented 1M entitlement, and only +/// while fresh; this prevents generic Moonshot or stale metadata from being +/// inherited by a membership-plan route. /// /// `custom_wire_override` must carry the dialect of the same table that /// supplied `base_url_override` when `provider` is @@ -427,6 +448,7 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( saved_provider_model: Option<&str>, base_url_override: Option, context_window_override: Option, + provider_reported_context: Option, custom_wire_override: Option, ) -> Result { resolve_route_candidate_with_context_metadata_and_host_limits( @@ -435,6 +457,7 @@ pub(crate) fn resolve_route_candidate_with_context_metadata( saved_provider_model, base_url_override, context_window_override, + provider_reported_context, None, custom_wire_override, ) @@ -469,6 +492,7 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( saved_provider_model: Option<&str>, base_url_override: Option, context_window_override: Option, + provider_reported_context: Option, host_limits: Option, custom_wire_override: Option, ) -> Result { @@ -499,7 +523,12 @@ fn resolve_route_candidate_with_context_metadata_and_host_limits( let resolved = resolver .resolve(&base_request) .map_err(|err| err.to_string())?; - let mut plan = plan_limit_overrides(provider, &resolved, context_window_override); + let mut plan = plan_limit_overrides( + provider, + &resolved, + context_window_override, + provider_reported_context, + ); if let Some(host_limits) = host_limits { append_embedding_host_limit_overrides(&mut plan, host_limits)?; } @@ -566,12 +595,14 @@ struct LimitOverridePlan { /// /// Precedence (unchanged from the previous post-hoc mutation order): /// provider-scoped roster/API corrections and exact-route documented output -/// facts first, then operator-configured context, then the membership-plan -/// safe floor, then catalog data, then the conservative fallback. +/// facts first, then operator-configured context, then fresh route-scoped +/// provider-reported context, then the membership-plan safe floor, then +/// catalog data, then the conservative fallback. fn plan_limit_overrides( provider: ApiProvider, resolved: &ReadyRouteCandidate, context_window_override: Option, + provider_reported_context: Option, ) -> LimitOverridePlan { let mut overrides = Vec::new(); let configured = context_window_override.filter(|window| *window > 0); @@ -640,10 +671,34 @@ fn plan_limit_overrides( &resolved.endpoint().base_url, resolved.wire_model_id().as_str(), ); + let now = Utc::now(); + if is_exact_kimi_code_k3 + && provider_reported_context.is_some_and(|reported| { + reported.context_tokens == 1_048_576 + && reported.observed_at <= now + && now.signed_duration_since(reported.observed_at) + <= Duration::hours(KIMI_CODE_REPORTED_CONTEXT_MAX_AGE_HOURS) + }) + { + let reported = provider_reported_context.expect("checked above"); + overrides.push(SourcedLimitOverride { + field: LimitField::ContextTokens, + value: Some(u64::from(reported.context_tokens)), + source: OverrideSource::ProviderReportedContextWindow, + }); + return LimitOverridePlan { + overrides, + context_window: ContextWindowResolution { + tokens: reported.context_tokens, + source: ContextWindowSource::ProviderReported, + }, + }; + } // Kimi Code's bare `k3` is a membership-plan route, not an alias for // Moonshot's public `kimi-k3` catalog entry. The safe all-plan floor is - // the route's next precedence after an explicit config. + // the route's next precedence after an explicit config or fresh, scoped + // provider report. if is_exact_kimi_code_k3 { overrides.push(SourcedLimitOverride { field: LimitField::ContextTokens, @@ -777,6 +832,7 @@ fn resolve_runtime_route_for_identity_with_limits( saved_provider_model, Some(route_config.deepseek_base_url()), route_config.context_window_for_provider_config(provider), + None, host_limits, custom_wire_override, )?; @@ -1266,6 +1322,7 @@ mod tests { base.clone(), None, None, + None, ) .expect("Kimi Code route"); assert_eq!(static_floor.context_window.tokens, 262_144); @@ -1278,8 +1335,12 @@ mod tests { ApiProvider::Moonshot, Some("k3"), None, - base, + base.clone(), Some(1_048_576), + Some(ProviderReportedKimiCodeContext { + context_tokens: 1_048_576, + observed_at: Utc::now(), + }), None, ) .expect("configured route"); @@ -1289,12 +1350,53 @@ mod tests { ContextWindowSource::Configured ); + let reported = resolve_route_candidate_with_context_metadata( + ApiProvider::Moonshot, + Some("k3"), + None, + base.clone(), + None, + Some(ProviderReportedKimiCodeContext { + context_tokens: 1_048_576, + observed_at: Utc::now(), + }), + None, + ) + .expect("fresh documented provider metadata"); + assert_eq!(reported.context_window.tokens, 1_048_576); + assert_eq!( + reported.context_window.source, + ContextWindowSource::ProviderReported + ); + + let stale = resolve_route_candidate_with_context_metadata( + ApiProvider::Moonshot, + Some("k3"), + None, + base, + None, + Some(ProviderReportedKimiCodeContext { + context_tokens: 1_048_576, + observed_at: Utc::now() - Duration::hours(25), + }), + None, + ) + .expect("stale metadata falls back safely"); + assert_eq!( + stale.context_window.source, + ContextWindowSource::StaticKimiCodeSafeFloor + ); + let generic_err = resolve_route_candidate_with_context_metadata( ApiProvider::Moonshot, Some("k3"), None, Some(crate::config::DEFAULT_MOONSHOT_BASE_URL.to_string()), None, + Some(ProviderReportedKimiCodeContext { + context_tokens: 1_048_576, + observed_at: Utc::now(), + }), None, ) .expect_err("bare k3 is rejected on the direct Moonshot endpoint (#4687)"); diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index a78769f53d..2d55dd7236 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -401,6 +401,7 @@ impl App { saved_provider_model, Some(configured_route_base_url.clone()), active_context_window_override, + None, // The launch identity's dialect — `effective_auth_config` // is scoped to the same identity that resolved // `configured_route_base_url` above, which is not always diff --git a/crates/tui/src/tui/provider_picker.rs b/crates/tui/src/tui/provider_picker.rs index 9df56467b0..c5f5c5cf55 100644 --- a/crates/tui/src/tui/provider_picker.rs +++ b/crates/tui/src/tui/provider_picker.rs @@ -696,6 +696,7 @@ impl ProviderDashboardRow { .then(|| configured_base_url.clone()) .flatten(), config.context_window_for_provider_config(provider), + None, // The dialect of the same table that supplied the base URL above // (this row's scoped config), so the row's supported-protocol // display matches what a turn on this row would bind. diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index c7e53653b1..4a4ec1c022 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -809,6 +809,7 @@ pub(crate) async fn apply_model_picker_choice( saved_provider_model, Some(config.deepseek_base_url()), config.context_window_for_provider_config(app.api_provider), + None, // The ambient table's dialect — the same table `deepseek_base_url` // above resolves — so this receipt cannot disagree with the turn. (app.api_provider == ApiProvider::Custom) diff --git a/crates/tui/src/tui/ui/session_state.rs b/crates/tui/src/tui/ui/session_state.rs index 232daa6aff..7c2ad636e5 100644 --- a/crates/tui/src/tui/ui/session_state.rs +++ b/crates/tui/src/tui/ui/session_state.rs @@ -947,6 +947,7 @@ pub(crate) fn resolve_loaded_session_route(app: &mut App, config: &Config) { saved_provider_model, Some(config.deepseek_base_url()), context_override, + None, // The ambient table's dialect — the same table `deepseek_base_url` // above resolves — so this receipt cannot disagree with the turn. (app.api_provider == ApiProvider::Custom) From a3f1fac33a4efcaac485b7dcad77aaaf32a8770a Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 22:05:55 +0800 Subject: [PATCH 08/18] test: restore misclassified regression pins Three pins the sweep deleted against its own classification standard, each restored byte-identically to pinvou3-clean@0bc868ff0: - working_set.rs: should_try_local_reference_completion_skips_bare_ separators_and_dots ("Regression for #1921"). The tested gate fn survives the sweep, and no surviving test exercised its positive needle arm or the "."/".."/"\\"/empty rejections - not C1 (it runs and can fail), not C2 (the subject was kept), not C3 (no surviving assertion superset). Same negative-arm class as the ghost-text restore. - command_safety.rs: the four git-status classify pins (bare/-s/--porcelain/not-equal-push). The surviving classify_git_push pins a different row of the arity table, so these were never an assertion subset of it. - prompts/text.rs: the module-doc line naming the CALM_PERSONALITY overlay, deleted by the sweep and missed by the round-3 CALM restoration; the layer list and numbering now match the restored const again. Signed-off-by: asto18089 --- crates/tui/src/command_safety.rs | 20 ++++++++++++++++++++ crates/tui/src/prompts/text.rs | 5 +++-- crates/tui/src/working_set.rs | 23 +++++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/command_safety.rs b/crates/tui/src/command_safety.rs index e121bb3b23..6a2e5b7256 100644 --- a/crates/tui/src/command_safety.rs +++ b/crates/tui/src/command_safety.rs @@ -2627,6 +2627,26 @@ mod tests { // ── git (arity 2 each) ──────────────────────────────────────────────────── + #[test] + fn classify_git_status_bare() { + assert_eq!(classify("git status"), "git status"); + } + + #[test] + fn classify_git_status_with_short_flag() { + assert_eq!(classify("git status -s"), "git status"); + } + + #[test] + fn classify_git_status_with_long_flag() { + assert_eq!(classify("git status --porcelain"), "git status"); + } + + #[test] + fn classify_git_push_does_not_equal_git_status() { + assert_ne!(classify("git push origin main"), "git status"); + } + #[test] fn classify_git_push() { assert_eq!(classify("git push origin main"), "git push"); diff --git a/crates/tui/src/prompts/text.rs b/crates/tui/src/prompts/text.rs index 0297fc01a3..39c5e4a453 100644 --- a/crates/tui/src/prompts/text.rs +++ b/crates/tui/src/prompts/text.rs @@ -14,8 +14,9 @@ //! in `../prompts.rs`): //! //! 1. Constitution (binding core: `BASE_PROMPT` + language/output law) -//! 2. Approval-policy overlays -//! 3. Runtime templates (compaction relay, goal continuation, memory, +//! 2. Personality overlay (`CALM_PERSONALITY` — one overlay, not a set) +//! 3. Approval-policy overlays +//! 4. Runtime templates (compaction relay, goal continuation, memory, //! core execution, sub-agent output contract) //! //! Edit prompt text here directly. Content and ordering invariants are diff --git a/crates/tui/src/working_set.rs b/crates/tui/src/working_set.rs index 2e6ef40e59..b450020820 100644 --- a/crates/tui/src/working_set.rs +++ b/crates/tui/src/working_set.rs @@ -2415,6 +2415,29 @@ mod tests { ); } + /// Regression for #1921 — typing `@/` (or `@.`) must NOT trigger the + /// `local_reference_paths` walk, which scans up to + /// `LOCAL_REFERENCE_SCAN_LIMIT` paths on the UI thread. On WSL2 with a + /// `/mnt/c/...` workspace this hangs the composer for seconds to minutes. + #[test] + fn should_try_local_reference_completion_skips_bare_separators_and_dots() { + // The trigger gate must reject bare separators/dots. + assert!(!should_try_local_reference_completion("/")); + assert!(!should_try_local_reference_completion("\\")); + assert!(!should_try_local_reference_completion(".")); + assert!(!should_try_local_reference_completion("..")); + // Empty string was already rejected; keep that. + assert!(!should_try_local_reference_completion("")); + + // Actionable references must still trigger. + assert!(should_try_local_reference_completion("./foo")); + assert!(should_try_local_reference_completion("../bar")); + assert!(should_try_local_reference_completion(".env")); + assert!(should_try_local_reference_completion("path/")); + assert!(should_try_local_reference_completion("path/to/file")); + assert!(should_try_local_reference_completion("/usr")); + } + #[test] fn cached_candidates_rank_like_live_completions() { // #3757: the composer caches one full candidate walk and ranks per From a634d9037bc92dd9f33965d94d4c0df67b00ab52 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 22:25:56 +0800 Subject: [PATCH 09/18] test: finish C2 cascade edges the sweep left behind Four small completions of the sweep's own C2 standard found by the round-4 review: - provider_templates.rs: re-point the deleted shadow-guard pin's one uncovered assertion (sensenova must never shadow a ProviderKind built-in) onto the surviving sensenova test; the surviving hosted-host enumeration covers only the other four templates. - core/fragments.rs: delete FragmentCapError::TooManyFragments - its producer validate_fragment_set was C2-deleted by the sweep, leaving an unconstructable variant behind. - tui/git_status.rs: delete chrome_label/chrome_ink and their six pins - the sweep deleted their sole caller (the underwater classic header) but left them, test-only-dead and unmarked, while deleting their sibling top_bar_chip. - tui/underwater.rs: delete ShellPhase::color and phase_ink with the test that pinned them - both carried the classic-header "deletion is its own slice" sanction this PR is the deletion slice for, and phase_ink's only remaining caller was color(). Signed-off-by: asto18089 --- crates/config/src/provider_templates.rs | 8 ++ crates/core/src/fragments.rs | 2 - crates/tui/src/tui/git_status.rs | 120 ------------------------ crates/tui/src/tui/phase_strip.rs | 23 ----- crates/tui/src/tui/underwater.rs | 24 +---- 5 files changed, 9 insertions(+), 168 deletions(-) diff --git a/crates/config/src/provider_templates.rs b/crates/config/src/provider_templates.rs index 5c755fe807..04a4786ad3 100644 --- a/crates/config/src/provider_templates.rs +++ b/crates/config/src/provider_templates.rs @@ -414,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/core/src/fragments.rs b/crates/core/src/fragments.rs index 867563106d..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> { 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/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 3a3e88d3b7..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; diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index 88e2591940..04c90b40fe 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -12,7 +12,7 @@ use std::borrow::Cow; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ layout::Rect, - style::{Color, Modifier, Style}, + style::{Modifier, Style}, text::{Line, Span}, }; use unicode_width::UnicodeWidthStr; @@ -568,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 From b2207cbe868da5d8c269d31e310f5007bd94de7c Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 22:25:56 +0800 Subject: [PATCH 10/18] docs(tui): fix stale cucumber runner count in harness header The residue-cleanup commit corrected tests/README.md to five Gherkin runners after the plugin_e2e_acceptance scenario deletion but left the harness header saying "six feature runners / six link jobs"; drop the count so the comment cannot drift again. Signed-off-by: asto18089 --- crates/tui/tests/cucumber/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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"))] From 4fb1c85f016a5af1b5e9859f3a9fc2dbaebe457c Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 13:28:42 +0800 Subject: [PATCH 11/18] test: restore pins misclassified as worthless Round-5 fresh-review findings, each restored byte-identically to pinvou3-clean@0bc868ff0 (verified by whole-file diff): - menu_style.rs: the selected-row canonical-trio, shared-background, disabled-muted, and theme-variant pins. All four subjects are live and wired (context_menu, command_palette, provider_picker, model_picker, worktree_manager, feedback_picker), the deleted tests carried real exact-value assertions, and no surviving test dominates them - the module itself keeps same-class exact assertions (hovered band, status tones), so the restatement theory cannot hold. - live_transcript.rs: set_tail_mode ("exposed for callers that retain an overlay across a backtrack cancel" - a forward-looking marker, the same reservation class the sweep honored elsewhere) with its mode() accessor and both pins: set_tail_mode_re_arms_sticky, plus the mode() match assertion the sweep trimmed from backtrack_preview_disables_sticky. - settings.rs: set_pinned_model_label ("label editing surface is exposed through settings serialization first" - staged-sequencing reservation) and the set_pinned_model_label call the sweep dropped from pinned_models_are_exact_ordered_and_round_trip. - hooks/executor.rs: HookExecutor::is_enabled ("Public API for hook system consumers") - the same public-contract reservation formula used to restore WhaleState::priority. Signed-off-by: asto --- crates/tui/src/hooks/executor.rs | 6 ++++ crates/tui/src/settings.rs | 20 +++++++++++ crates/tui/src/tui/live_transcript.rs | 29 ++++++++++++++++ crates/tui/src/tui/menu_style.rs | 49 +++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) diff --git a/crates/tui/src/hooks/executor.rs b/crates/tui/src/hooks/executor.rs index f522af6d53..354c710453 100644 --- a/crates/tui/src/hooks/executor.rs +++ b/crates/tui/src/hooks/executor.rs @@ -1593,6 +1593,12 @@ impl HookExecutor { } } + /// Check if hooks are enabled + #[allow(dead_code)] // Public API for hook system consumers + pub fn is_enabled(&self) -> bool { + self.config.enabled + } + /// Get the session ID /// Read-only access to the underlying configuration. Used by /// `/hooks` (#460 read-only MVP) so the user can list configured diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 6589a957dd..27ce9735cb 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -2100,6 +2100,25 @@ impl Settings { true } + #[allow(dead_code)] // label editing surface is exposed through settings serialization first + pub fn set_pinned_model_label( + &mut self, + provider: &str, + model: &str, + label: Option, + ) -> bool { + self.pinned_models + .iter_mut() + .find(|pin| { + pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model) + }) + .map(|pin| { + pin.label = label.filter(|value| !value.trim().is_empty()); + true + }) + .unwrap_or(false) + } + pub fn move_pinned_model(&mut self, provider: &str, model: &str, delta: isize) -> bool { let Some(index) = self.pinned_models.iter().position(|pin| { pin.provider.eq_ignore_ascii_case(provider) && pin.model.eq_ignore_ascii_case(model) @@ -5656,6 +5675,7 @@ mod tests { assert_eq!(settings.pinned_models[0].provider, "zai"); assert!(settings.move_pinned_model("openrouter", "glm-5.2", -1)); assert_eq!(settings.pinned_models[0].provider, "openrouter"); + assert!(settings.set_pinned_model_label("openrouter", "glm-5.2", Some("fast".to_string()))); let encoded = toml::to_string(&settings).unwrap(); let decoded: Settings = toml::from_str(&encoded).unwrap(); assert_eq!(decoded.pinned_models, settings.pinned_models); diff --git a/crates/tui/src/tui/live_transcript.rs b/crates/tui/src/tui/live_transcript.rs index 44fe56c39f..cb01327238 100644 --- a/crates/tui/src/tui/live_transcript.rs +++ b/crates/tui/src/tui/live_transcript.rs @@ -161,6 +161,22 @@ impl LiveTranscriptOverlay { self.preview_pin_pending.set(true); } + /// Return the overlay to live-tail mode (used when backtrack is + /// confirmed or canceled). Re-arms sticky-tail so streaming resumes. + #[allow(dead_code)] // exposed for callers that retain an overlay across a backtrack cancel; current UI just pops the view. + pub fn set_tail_mode(&mut self) { + self.mode = Mode::Tail; + self.sticky_to_bottom.set(true); + self.preview_pin_pending.set(false); + } + + /// For tests + UI: current mode. + #[allow(dead_code)] // currently consumed only by tests; kept public for symmetry with `set_*` setters. + #[must_use] + pub fn mode(&self) -> Mode { + self.mode + } + /// Pull the latest cells + revisions from `App` so the next `render` shows /// streaming mutations. Must be called before `view_stack.render` while /// this overlay is on top; otherwise the cells stay frozen at whatever @@ -1003,6 +1019,19 @@ mod tests { assert!(v.is_sticky()); v.set_backtrack_preview(0); assert!(!v.is_sticky()); + assert!(matches!( + v.mode(), + Mode::BacktrackPreview { selected_idx: 0 } + )); + } + + #[test] + fn set_tail_mode_re_arms_sticky() { + let mut v = LiveTranscriptOverlay::new(); + v.set_backtrack_preview(2); + v.set_tail_mode(); + assert!(v.is_sticky()); + assert!(matches!(v.mode(), Mode::Tail)); } #[test] diff --git a/crates/tui/src/tui/menu_style.rs b/crates/tui/src/tui/menu_style.rs index 53381ac5f8..dd193afae4 100644 --- a/crates/tui/src/tui/menu_style.rs +++ b/crates/tui/src/tui/menu_style.rs @@ -163,6 +163,43 @@ pub const fn status_mark(kind: StatusKind) -> StatusMark { mod tests { use super::*; + #[test] + fn selected_row_style_is_the_canonical_trio() { + assert_eq!( + selected_row_style(), + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + ); + } + + #[test] + fn selected_row_variants_keep_the_shared_background() { + assert_eq!( + selected_row_bg_style(), + Style::default().bg(palette::SELECTION_BG) + ); + assert_eq!( + selected_row_style_with_fg(palette::WHALE_ACTION), + Style::default() + .fg(palette::WHALE_ACTION) + .bg(palette::SELECTION_BG) + .add_modifier(Modifier::BOLD) + ); + } + + #[test] + fn disabled_selected_row_is_muted_ink_on_elevated_surface() { + assert_eq!( + disabled_selected_row_style(), + Style::default() + .fg(palette::TEXT_MUTED) + .bg(palette::SURFACE_ELEVATED) + .add_modifier(Modifier::DIM) + ); + } + #[test] fn hovered_row_is_elevated_band_without_selection_ink() { let hovered = hovered_row_style(); @@ -171,6 +208,18 @@ mod tests { assert_ne!(hovered, selected_row_bg_style()); } + #[test] + fn theme_variant_uses_the_previewed_themes_own_tokens() { + let theme = palette::UI_THEME; + assert_eq!( + theme_selected_row_style(&theme), + Style::default() + .fg(theme.text_body) + .bg(theme.selection_bg) + .add_modifier(Modifier::BOLD) + ); + } + #[test] fn every_status_kind_pairs_a_glyph_with_a_word() { // Non-color redundancy: neither cue may be empty, so a status never From 7058b69f1a9f60d73c1ac14a28ea7137efb4cad5 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 13:28:53 +0800 Subject: [PATCH 12/18] test: restore network_policy and stale-job reservation seams Two reservation-class gaps from the same fresh review, superseding the sweep's deletions and the round-5 vestige removal with byte-identical restores: - network_policy.rs: the whole file returns to the base state (net diff empty). The module header reserves the staged write side for named future wiring (`/network allow ` / `/network deny ` slash commands and the approval-modal hook): the mutators (`NetworkPolicy::add_allow`, `NetworkSessionCache::approve`/`deny`, `NetworkPolicyDecider::approve_session`/`deny_session`/ `approve_persistent`, `NetworkDenied`) plus their five tests. The sweep had deleted them while keeping the header's reservation language and the cache readers, leaving query-only state nothing could write. - tools/shell.rs + tools/shell/tests.rs: `ShellManager::remember_stale_job` ("Remember a restart-stale job so the UI can show it instead of hiding it") is the sole writer of the `stale_jobs` map whose read side is live production code (inspect_job's stale arm, list_jobs merge, the session-owner check); restoring it re-arms the trimmed portion of test_job_list_poll_cancel_and_stale_snapshot. Everything else in these files stays as the sweep left it - the files now differ from base only by the merged #90 description updates. Signed-off-by: asto --- crates/tui/src/network_policy.rs | 141 +++++++++++++++++++++++++++- crates/tui/src/tools/shell.rs | 37 ++++++++ crates/tui/src/tools/shell/tests.rs | 14 +++ 3 files changed, 189 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/network_policy.rs b/crates/tui/src/network_policy.rs index f9ce915e35..bbbbd06891 100644 --- a/crates/tui/src/network_policy.rs +++ b/crates/tui/src/network_policy.rs @@ -19,8 +19,9 @@ //! 3. [`NetworkAuditor`] — appends one plaintext line per outbound call to //! `~/.codewhale/audit.log` in the format described below. //! -//! In addition, [`NetworkSessionCache`] holds in-process session state that -//! the decider's `evaluate` short-circuit consults. +//! In addition, [`NetworkSessionCache`] holds in-process "approve once for +//! this session" state for the `Prompt` flow, and [`NetworkDenied`] is the +//! structured error surfaced to callers when a host is blocked. //! //! # Host-matching rules //! @@ -51,13 +52,14 @@ use std::sync::{Arc, Mutex}; use chrono::Utc; use serde::{Deserialize, Serialize}; +use thiserror::Error; /// What the policy decided about an outbound network call. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Decision { /// Allow the call without prompting. Allow, - /// Deny the call. + /// Deny the call. Surfaced to callers as [`NetworkDenied`]. Deny, /// Defer to the user via an approval prompt. Prompt, @@ -199,6 +201,22 @@ impl NetworkPolicy { self.default.into() } + /// Append `host` to the allow list (de-duplicated, case-insensitive). + /// Used by the prompt flow when the user picks "always for this host". + pub fn add_allow(&mut self, host: &str) { + let normalized = normalize_host(host); + if normalized.is_empty() { + return; + } + if !self + .allow + .iter() + .any(|existing| normalize_host(existing) == normalized) + { + self.allow.push(normalized); + } + } + /// Whether audit logging is enabled. #[must_use] pub fn audit_enabled(&self) -> bool { @@ -396,6 +414,37 @@ impl NetworkSessionCache { .map(|guard| guard.denied.contains(&normalized)) .unwrap_or(false) } + + /// Mark the host as approved for the rest of this session. + pub fn approve(&self, host: &str) { + let normalized = normalize_host(host); + if let Ok(mut guard) = self.inner.lock() { + guard.denied.remove(&normalized); + guard.approved.insert(normalized); + } + } + + /// Mark the host as denied for the rest of this session. + pub fn deny(&self, host: &str) { + let normalized = normalize_host(host); + if let Ok(mut guard) = self.inner.lock() { + guard.approved.remove(&normalized); + guard.denied.insert(normalized); + } + } +} + +/// Structured error surfaced to callers when an outbound call is blocked. +#[derive(Debug, Clone, Error)] +#[error("network call to '{0}' blocked by network policy")] +pub struct NetworkDenied(pub String); + +impl NetworkDenied { + /// The host that was denied. + #[must_use] + pub fn host(&self) -> &str { + &self.0 + } } /// Glue type that bundles a [`NetworkPolicy`] with a session cache and an @@ -516,6 +565,29 @@ impl NetworkPolicyDecider { decision } + /// Approve `host` for the rest of the session (one-shot). Audit log gets + /// `Prompt-Approved`. + pub fn approve_session(&self, host: &str, tool: &str) { + self.cache.approve(host); + self.audit_record(host, tool, "Prompt-Approved"); + } + + /// Deny `host` for the rest of the session. Audit log gets `Prompt-Denied`. + pub fn deny_session(&self, host: &str, tool: &str) { + self.cache.deny(host); + self.audit_record(host, tool, "Prompt-Denied"); + } + + /// Persist `host` into the policy's allow list (so it survives the session) + /// **and** approve it in-session. Returns the updated policy so callers can + /// write it back to disk. + pub fn approve_persistent(&mut self, host: &str, tool: &str) -> &NetworkPolicy { + self.policy.add_allow(host); + self.cache.approve(host); + self.audit_record(host, tool, "Prompt-Approved"); + &self.policy + } + /// Whether this host is explicitly configured for trusted proxy fake-IP /// DNS handling. #[must_use] @@ -624,6 +696,15 @@ mod tests { assert_eq!(p.decide(" "), Decision::Deny); } + #[test] + fn add_allow_dedupes_case_insensitively() { + let mut p = mk(Decision::Deny, &[], &[]); + p.add_allow("Example.COM"); + p.add_allow("example.com"); + assert_eq!(p.allow.len(), 1); + assert_eq!(p.allow[0], "example.com"); + } + #[test] fn trusted_proxy_fakeip_hosts_match_exact_and_subdomains() { let mut p = mk(Decision::Deny, &[], &[]); @@ -737,6 +818,53 @@ mod tests { assert!(!path.exists() || std::fs::read_to_string(&path).unwrap().is_empty()); } + #[test] + fn session_cache_short_circuits_evaluate() { + let policy = mk(Decision::Prompt, &[], &[]); + let decider = NetworkPolicyDecider::new(policy, None); + // First call returns Prompt. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Prompt + ); + decider.approve_session("api.example.com", "fetch_url"); + // After approve_session, the same host returns Allow without prompting. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Allow + ); + } + + #[test] + fn approve_persistent_writes_back_to_policy() { + let policy = mk(Decision::Prompt, &[], &[]); + let mut decider = NetworkPolicyDecider::new(policy, None); + decider.approve_persistent("api.example.com", "fetch_url"); + assert!( + decider + .policy() + .allow + .iter() + .any(|h| h == "api.example.com") + ); + // And the session cache also got updated, so fresh evaluate returns Allow. + assert_eq!( + decider.evaluate("api.example.com", "fetch_url"), + Decision::Allow + ); + } + + #[test] + fn deny_session_blocks_subsequent_evaluate() { + let policy = mk(Decision::Allow, &[], &[]); + let decider = NetworkPolicyDecider::new(policy, None); + decider.deny_session("evil.example.com", "fetch_url"); + assert_eq!( + decider.evaluate("evil.example.com", "fetch_url"), + Decision::Deny + ); + } + #[test] fn audit_records_terminal_decisions_through_decider() { let dir = tempdir().expect("tempdir"); @@ -764,4 +892,11 @@ mod tests { assert_eq!(Decision::parse("prompt"), Decision::Prompt); assert_eq!(Decision::parse("garbage"), Decision::Prompt); } + + #[test] + fn network_denied_carries_host() { + let err = NetworkDenied("api.example.com".to_string()); + assert_eq!(err.host(), "api.example.com"); + assert!(format!("{err}").contains("api.example.com")); + } } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index 847d289b7e..048a5893ce 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -3417,6 +3417,43 @@ impl ShellManager { owners } + /// Remember a restart-stale job so the UI can show it instead of hiding it. + #[allow(dead_code)] + pub fn remember_stale_job( + &mut self, + id: impl Into, + command: impl Into, + cwd: PathBuf, + linked_task_id: Option, + ) { + let id = id.into(); + self.stale_jobs.insert( + id.clone(), + ShellJobSnapshot { + id: id.clone(), + job_id: id, + command: command.into(), + cwd, + status: ShellStatus::Killed, + exit_code: None, + elapsed_ms: 0, + stdout_tail: String::new(), + stderr_tail: "Process is no longer attached to this TUI session.".to_string(), + stdout_len: 0, + stderr_len: 0, + stdin_available: false, + stale: true, + elapsed_since_output_ms: None, + linked_task_id, + owner_agent_id: None, + owner_agent_name: None, + origin_tool_call_id: None, + origin_turn_id: None, + owner_session_id: String::new(), + }, + ); + } + /// Clean up completed processes older than the given duration, then enforce /// the count and byte ceilings on what is left. /// diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 03b222c84c..9f5bb83e56 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -2146,6 +2146,20 @@ fn test_job_list_poll_cancel_and_stale_snapshot() { let detail = manager.inspect_job(&task_id).expect("inspect"); assert!(detail.stdout.contains("done")); assert_eq!(detail.snapshot.status, ShellStatus::Completed); + + manager.remember_stale_job( + "shell_stale", + "cargo test", + tmp.path().to_path_buf(), + Some("task_old".to_string()), + ); + let stale = manager + .list_jobs() + .into_iter() + .find(|job| job.id == "shell_stale") + .expect("stale job"); + assert!(stale.stale); + assert_eq!(stale.linked_task_id.as_deref(), Some("task_old")); } #[test] From afff68178130719bd30579724b5d8cf97e02c4ba Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 13:29:05 +0800 Subject: [PATCH 13/18] test: apply sweep classes to round-5 leftovers Consistency follow-ups applying the sweep's own classification rules uniformly, from the same fresh review: - drop the tautological steer-seed pins (assign + assert on submit_pending_steers_after_interrupt after push_pending_steer was deleted; drain_pending_scenario already covers the real arm/drain behavior), and the assertion-free detect_returns_some_shell and test_scroll_operations - error_taxonomy: the auth-envelope pin now feeds a raw key and pins the classifier-not-redactor contract (body carried verbatim, redaction stays the caller's job) instead of asserting a string absent from its own pre-redacted input; renamed to match what it tests - automation/tasks: the tests named aliases_hide_from_model_and_force_action lost their alias subjects to the C2 deletions and now pin only canonical visibility and per-action approval routing, so they are renamed accordingly (the GithubTool twin keeps its name - its alias machinery is live) - subagent: two doc comments still cited the deleted apply_spillover wrapper; they now name apply_spillover_with_artifact - route_billing: the deleted routed_zai_child_never_claims_api_dollars_without_full_route_config was the only pin of BillingPresentation::Unknown's label; its money-safety rows were already covered by child_route_billing_fails_closed_for_every_ambiguous_provider, so that survivor gains the label assertion instead Signed-off-by: asto --- crates/tui/src/error_taxonomy/tests.rs | 14 +++++++----- crates/tui/src/route_billing.rs | 1 + crates/tui/src/shell_dispatcher.rs | 6 ----- crates/tui/src/tools/automation.rs | 2 +- crates/tui/src/tools/subagent/mod.rs | 5 +++-- crates/tui/src/tools/tasks.rs | 2 +- crates/tui/src/tui/app/tests.rs | 31 -------------------------- 7 files changed, 14 insertions(+), 47 deletions(-) diff --git a/crates/tui/src/error_taxonomy/tests.rs b/crates/tui/src/error_taxonomy/tests.rs index aef2a61ea6..54ba11422f 100644 --- a/crates/tui/src/error_taxonomy/tests.rs +++ b/crates/tui/src/error_taxonomy/tests.rs @@ -52,19 +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() { - // Redaction of the raw key is the caller's responsibility before the - // envelope is built; the envelope itself must classify a 401 as a - // critical authentication error and never surface the raw key. +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, - "Invalid API Key: [redacted 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); assert!(envelope.message.contains("Invalid API Key")); - assert!(!envelope.message.contains("tp-secret-token-plan-value")); + assert!(envelope.message.contains("tp-secret-token-plan-value")); } #[test] diff --git a/crates/tui/src/route_billing.rs b/crates/tui/src/route_billing.rs index c197861a03..5c6268943a 100644 --- a/crates/tui/src/route_billing.rs +++ b/crates/tui/src/route_billing.rs @@ -1639,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", diff --git a/crates/tui/src/shell_dispatcher.rs b/crates/tui/src/shell_dispatcher.rs index df6e70c9e3..a90058f1b6 100644 --- a/crates/tui/src/shell_dispatcher.rs +++ b/crates/tui/src/shell_dispatcher.rs @@ -653,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 { diff --git a/crates/tui/src/tools/automation.rs b/crates/tui/src/tools/automation.rs index b1be25dfd1..74a50a5b00 100644 --- a/crates/tui/src/tools/automation.rs +++ b/crates/tui/src/tools/automation.rs @@ -532,7 +532,7 @@ mod tests { } #[test] - fn aliases_hide_from_model_and_force_action() { + 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/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index c783679cc4..b15f13cd83 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -10827,7 +10827,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, @@ -16081,7 +16081,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 19ce2933ba..2d86f69948 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -1385,7 +1385,7 @@ mod tests { } #[test] - fn aliases_hide_from_model_and_force_action() { + fn canonical_visibility_and_per_action_approval() { let canonical = TasksTool::new("tasks"); assert!(canonical.model_visible()); assert_eq!( diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 88dc7d8201..31664ae338 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -4418,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()); @@ -5342,17 +5334,6 @@ fn sticky_error_ttl_is_capped_and_clears_on_composer_activity() { assert!(app.sticky_status.is_none()); } -#[test] -fn pending_steer_seed_arms_resend_flag() { - let mut app = App::new(test_options(false), &Config::default()); - assert!(!app.submit_pending_steers_after_interrupt); - app.pending_steers - .push_back(QueuedMessage::new("steer me".to_string(), None)); - app.submit_pending_steers_after_interrupt = true; - 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 @@ -5385,18 +5366,6 @@ fn drain_pending_scenario() { } } -#[test] -fn double_steer_seed_is_idempotent_on_flag() { - let mut app = App::new(test_options(false), &Config::default()); - app.pending_steers - .push_back(QueuedMessage::new("a".to_string(), None)); - app.pending_steers - .push_back(QueuedMessage::new("b".to_string(), None)); - app.submit_pending_steers_after_interrupt = true; - 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 From 7bae0ff7be55fbdf422a688a36bf63d5e0c12e77 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 13:29:05 +0800 Subject: [PATCH 14/18] test: pin saturated backoff and replay-diverged wire spelling Two hardening pins for surviving behavior, replacing deleted tests that were the only checks of their invariants: - core: deterministic_backoff_exponential_growth gains an attempt-63 saturation canary - the exponent clamps at 20 and the multiply saturates, and the deleted no-assertion test was the only guard that the retry path stays finite at high exponents - workflow: control_node_result_serialization pins WorkflowRunStatus::ReplayDiverged's snake_case wire spelling in both directions, enforcing the wire-compatibility promise in the variant's doc (it has no in-repo producer since the replay executor was removed) Signed-off-by: asto --- crates/core/src/lib.rs | 13 +++++++++++++ crates/workflow/src/lib.rs | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 977d575432..39a6d93eae 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -2549,6 +2549,19 @@ mod tests { "attempt {attempt}" ); } + // 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() + }; + assert_eq!( + JobManager::deterministic_backoff_ms(&saturated), + 1000u64 << 20, + "attempt 63 must saturate, not overflow" + ); } // ── JobManager: history truncation ───────────────────────────────── diff --git a/crates/workflow/src/lib.rs b/crates/workflow/src/lib.rs index eb50ad6606..93bc57ad8b 100644 --- a/crates/workflow/src/lib.rs +++ b/crates/workflow/src/lib.rs @@ -1665,6 +1665,19 @@ mod tests { .expect("parse minimal control node result"); assert!(minimal.selected_children.is_empty()); assert_eq!(minimal.summary, None); + + // 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] From fe7113f46facf6a9303c36f2c74b3f7f928ce7c9 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 13:29:05 +0800 Subject: [PATCH 15/18] docs: correct integration row in tests README The integration row said 14 suites and omitted lifecycle_outbox_exec (a pre-existing omission the sweep's 17-3 arithmetic propagated); the harness declares 15. The "instead of 17 times" link-count sentence now matches the real suite count. Signed-off-by: asto --- crates/tui/tests/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tui/tests/README.md b/crates/tui/tests/README.md index dba781eedc..3393c84cc4 100644 --- a/crates/tui/tests/README.md +++ b/crates/tui/tests/README.md @@ -16,7 +16,7 @@ the `codewhale-tui` bin unit tests): | harness | binary | what lives there | why it stays separate | |---|---|---|---| -| `tests/integration/main.rs` | `integration` | 14 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`, `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 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/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 From 267c80d1e90807c86cd0106941a56b989f55f8e7 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 15:06:08 +0800 Subject: [PATCH 16/18] test: restore live-subject pins found by the fresh review Three deleted tests were not C1/C2/C3 under this PR's own taxonomy: their subjects are live, no surviving test covers them, and each fails exactly when the pinned contract regresses. All three are restored byte-identically to the base commit: - rlm_eval_schema_names_the_runtime_content_variable: the eval schema's model-facing `code` description (names `content`, teaches print(len(content)), rejects the old SOURCE spelling) had no surviving schema pin; the kept assertion covers only the missing-code error message. - default_threshold_is_32k_tokens: the only exact-value pin of DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS; the same canary class the backoff attempt-63 pin was added for. - test_show_settings_loads_from_file: the only smoke pin of the live /config show branch. Signed-off-by: asto --- crates/tui/src/commands/groups/config/config.rs | 9 +++++++++ crates/tui/src/tools/large_output_router.rs | 5 +++++ crates/tui/src/tools/rlm.rs | 12 ++++++++++++ 3 files changed, 26 insertions(+) diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 0d7d4d5207..b8476a6027 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -3717,6 +3717,15 @@ mod tests { assert!(result.message.unwrap().contains("Usage: /config")); } + #[test] + fn test_show_settings_loads_from_file() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = show_settings(&mut app); + // Settings should load (may use defaults if file doesn't exist) + assert!(result.message.is_some()); + } + #[test] fn settings_command_opens_typed_editor_and_preserves_text_mode() { let _lock = lock_test_env(); diff --git a/crates/tui/src/tools/large_output_router.rs b/crates/tui/src/tools/large_output_router.rs index 7f9f93725f..7e3b45b044 100644 --- a/crates/tui/src/tools/large_output_router.rs +++ b/crates/tui/src/tools/large_output_router.rs @@ -428,6 +428,11 @@ mod tests { ); } + #[test] + fn default_threshold_is_32k_tokens() { + assert_eq!(DEFAULT_LARGE_OUTPUT_THRESHOLD_TOKENS, 32_768); + } + #[test] fn synthesise_above_threshold() { let router = LargeOutputRouter::default(); diff --git a/crates/tui/src/tools/rlm.rs b/crates/tui/src/tools/rlm.rs index d546e87a90..ae76e39488 100644 --- a/crates/tui/src/tools/rlm.rs +++ b/crates/tui/src/tools/rlm.rs @@ -1316,6 +1316,18 @@ mod tests { ); } + #[test] + fn rlm_eval_schema_names_the_runtime_content_variable() { + let schema = RlmTool::alias("rlm_eval", "eval", None).input_schema(); + let description = schema["properties"]["code"]["description"] + .as_str() + .expect("rlm_eval code description"); + + assert!(description.contains("`content`")); + assert!(description.contains("print(len(content))")); + assert!(!description.contains("SOURCE")); + } + #[tokio::test] async fn rlm_session_open_eval_close_lifecycle() { let ctx = ctx(); From c22b3ecbd366f5a6e410250d2996b2c67e75ed3a Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 15:06:19 +0800 Subject: [PATCH 17/18] test: drop the twin replay-replay tautology reasoning_replay_required_on_subsequent_turn is assertion-equivalent to the deleted reasoning_content_replayed_after_tool_call.rs: it hand- builds the second request (Thinking block included), feeds it to the mock, and asserts the mock's verbatim capture. No production code composes the asserted request, so it cannot fail on a runtime regression; its in-file comment claiming "runtime composes the next request" was false. The real coverage is 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; tests/README.md now points the V4 section 5.1.1 bullet there instead of claiming this suite covers it. The now-unused assistant_thinking helper goes with it. Signed-off-by: asto --- crates/tui/tests/README.md | 7 +- .../tests/integration/integration_mock_llm.rs | 96 +------------------ 2 files changed, 6 insertions(+), 97 deletions(-) diff --git a/crates/tui/tests/README.md b/crates/tui/tests/README.md index 3393c84cc4..bb46d5f69d 100644 --- a/crates/tui/tests/README.md +++ b/crates/tui/tests/README.md @@ -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/integration/integration_mock_llm.rs b/crates/tui/tests/integration/integration_mock_llm.rs index 60d9c983c3..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() { From 2db56a5f5d8a36cea33b3998c00b95dec9cc2d5e Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 13 Oct 2026 15:06:19 +0800 Subject: [PATCH 18/18] docs: fix fresh-review residue - named_fleet: sha256_label's doc still cross-referenced the deleted replay.rs hex helper; state the formatting rationale without the dangling reference. - integration harness header: the stale "17 binaries" count predated the consolidated harness; describe it without a count so it cannot drift again (same treatment as the cucumber header fix). - truncate: the deleted apply_spillover wrapper's doc paragraph was left dangling above apply_spillover_with_artifact's own headline; drop it. Its legacy-path contracts stay pinned by the kept tests, and the artifact path deliberately does not skip error results, so the old "error results are skipped" sentence must not move onto it. Signed-off-by: asto --- crates/tui/src/tools/truncate.rs | 10 ---------- crates/tui/tests/integration/main.rs | 2 +- crates/workflow/src/named_fleet.rs | 4 ++-- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/crates/tui/src/tools/truncate.rs b/crates/tui/src/tools/truncate.rs index 2fce318275..3ad0be59be 100644 --- a/crates/tui/src/tools/truncate.rs +++ b/crates/tui/src/tools/truncate.rs @@ -442,16 +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). /// Apply adaptive routing and publish session-scoped exact evidence. /// /// The default path writes one immutable payload under the origin session and diff --git a/crates/tui/tests/integration/main.rs b/crates/tui/tests/integration/main.rs index 2a4d553ebb..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. 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 _;