Ctrl K
ComunidadGuías…

crates/tui/src/context_report.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! Diagnostic prompt source map for context pressure reports.
//!
//! The report is approximate and describes the runtime sources CodeWhale
//! already tracks, without claiming provider-tokenizer parity. Its headline
//! (`active_context_estimated_tokens`) is the pressure estimate the context
//! meter and the auto-compaction gate read
//! (`compaction::estimate_input_tokens_for_pressure`, lifted to the last
//! provider-billed prompt). The 1.5x-inflated conservative estimate that
//! request-overflow protection uses is reported separately as the overflow
//! guard, never as the headline. Per-source entries keep the conservative
//! per-text heuristic.

mod portable_projection;
pub(crate) use portable_projection::source_map as project_source_map;

use std::path::Path;

use chrono::{SecondsFormat, Utc};
use serde::Serialize;

use crate::tui::app::App;
use codewhale_config::AppMode;
use codewhale_engine::compaction::{
    estimate_input_tokens_conservative, estimate_input_tokens_for_pressure,
    estimate_text_tokens_conservative,
};
use codewhale_engine::prompts::CORE_EXECUTION_PROFILE_PROMPT;
use codewhale_models::{CacheControl, ContentBlock, Message, SystemPrompt, Tool};
use codewhale_runtime::config::Config;
#[cfg(test)]
use codewhale_runtime::context_budget::PressureLevel;

#[derive(Debug, Clone, Serialize)]
pub struct PromptSourceMap {
    pub entries: Vec<SourceEntry>,
    pub total_estimated_tokens: usize,
    /// Headline: the same pressure estimate the context meter and the
    /// auto-compaction gate read, so `/context` never disagrees with them.
    pub active_context_estimated_tokens: usize,
    /// Secondary: the 1.5x-inflated conservative estimate request-overflow
    /// protection guards with. `None` when there is no live conversation to
    /// measure (headless doctor reports).
    pub overflow_guard_estimated_tokens: Option<usize>,
    pub context_window_tokens: Option<u32>,
    /// Non-secret receipt for the effective context-window value.
    pub context_window_source: Option<String>,
    pub budget_used_percent: Option<f64>,
    pub generated_at: String,
    pub note: String,
}

/// Inspectable request-prefix context for the current session.
///
/// `PromptSourceMap` explains provenance and estimated pressure. This sibling
/// type exposes the current assembled system-prompt sections and most recently
/// sent model tool catalog so users can audit the prompt plumbing as JSON.
#[derive(Debug, Clone, Serialize)]
pub struct PromptContext {
    pub schema_version: u8,
    pub provider: String,
    pub model: String,
    pub system_prompt_state: &'static str,
    pub tool_catalog_state: &'static str,
    pub sections: Vec<PromptContextSection>,
    pub tools: Vec<Tool>,
    pub source_map: PromptSourceMap,
}

#[derive(Debug, Clone, Serialize)]
pub struct PromptContextSection {
    pub index: usize,
    pub block_type: String,
    pub cache_control: Option<CacheControl>,
    pub estimated_tokens: usize,
    pub text: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct SourceEntry {
    pub source_kind: SourceKind,
    pub label: String,
    pub source_path: Option<String>,
    pub activation_reason: ActivationReason,
    pub estimated_tokens: usize,
    pub counting_confidence: CountingConfidence,
    pub authority_tier: Option<u8>,
    pub truncation_reason: Option<String>,
}

impl SourceEntry {
    fn text(
        source_kind: SourceKind,
        label: impl Into<String>,
        source_path: Option<String>,
        activation_reason: ActivationReason,
        text: &str,
        counting_confidence: CountingConfidence,
        authority_tier: Option<u8>,
    ) -> Self {
        Self::estimate(
            source_kind,
            label,
            source_path,
            activation_reason,
            estimate_text_tokens_conservative(text),
            counting_confidence,
            authority_tier,
        )
    }

    fn estimate(
        source_kind: SourceKind,
        label: impl Into<String>,
        source_path: Option<String>,
        activation_reason: ActivationReason,
        estimated_tokens: usize,
        counting_confidence: CountingConfidence,
        authority_tier: Option<u8>,
    ) -> Self {
        Self {
            source_kind,
            label: label.into(),
            source_path,
            activation_reason,
            estimated_tokens,
            counting_confidence,
            authority_tier,
            truncation_reason: None,
        }
    }

    fn omitted(
        source_kind: SourceKind,
        label: impl Into<String>,
        source_path: Option<String>,
        authority_tier: Option<u8>,
        reason: impl Into<String>,
    ) -> Self {
        Self {
            source_kind,
            label: label.into(),
            source_path,
            activation_reason: ActivationReason::Omitted,
            estimated_tokens: 0,
            counting_confidence: CountingConfidence::High,
            authority_tier,
            truncation_reason: Some(reason.into()),
        }
    }

    fn diagnostic(
        source_kind: SourceKind,
        label: impl Into<String>,
        source_path: Option<String>,
        activation_reason: ActivationReason,
        detail: impl Into<String>,
        estimated_tokens: usize,
        authority_tier: Option<u8>,
    ) -> Self {
        Self {
            source_kind,
            label: label.into(),
            source_path,
            activation_reason,
            estimated_tokens,
            counting_confidence: CountingConfidence::High,
            authority_tier,
            truncation_reason: Some(detail.into()),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
    Constitution,
    UserConstitution,
    RepoConstitution,
    ProjectContext,
    ProjectContextWarning,
    ProjectContextPack,
    SkillsBlock,
    ContextManagement,
    CompactionRelayTemplate,
    RuntimePolicy,
    AuthorityRecap,
    EnvironmentBlock,
    UserMemory,
    SessionGoal,
    HandoffRelay,
    ToolSchemas,
    UserRequest,
    ConversationHistory,
    ToolResult,
    ModelProviderFact,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ActivationReason {
    AlwaysOn,
    FilePresent,
    ConfigEnabled,
    RuntimeState,
    PerRequest,
    Omitted,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CountingConfidence {
    High,
    Approximate,
}

struct ReportBuilder {
    entries: Vec<SourceEntry>,
}

impl ReportBuilder {
    fn new() -> Self {
        Self {
            entries: Vec::new(),
        }
    }

    fn push(&mut self, entry: SourceEntry) {
        self.entries.push(entry);
    }

    /// The window arrives as one resolution rather than a number plus a
    /// separately chosen label, so the report can never attribute one rung's
    /// tokens to another rung.
    fn finish(
        self,
        context_window: codewhale_engine::route_runtime::ContextWindowResolution,
        active_context_estimated_tokens: usize,
        overflow_guard_estimated_tokens: Option<usize>,
        note: impl Into<String>,
    ) -> PromptSourceMap {
        let total_estimated_tokens = self
            .entries
            .iter()
            .map(|entry| entry.estimated_tokens)
            .sum();
        let budget_used_percent =
            ((active_context_estimated_tokens as f64 / f64::from(context_window.tokens)) * 100.0)
                .clamp(0.0, 100.0);
        PromptSourceMap {
            entries: self.entries,
            total_estimated_tokens,
            active_context_estimated_tokens,
            overflow_guard_estimated_tokens,
            context_window_tokens: Some(context_window.tokens),
            context_window_source: Some(context_window.source.label().to_string()),
            budget_used_percent: Some(budget_used_percent),
            generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
            note: note.into(),
        }
    }
}

pub fn build_context_report(app: &App) -> PromptSourceMap {
    let context_window = codewhale_engine::route_runtime::ContextWindowResolution {
        tokens: app.effective_context_window(),
        source: app.effective_context_window_source(),
    };
    let mut builder = base_source_entries(
        &app.model,
        &app.workspace,
        Some(&app.skills_dir),
        app.project_context_pack_enabled,
        &app.skills_config,
        app.ui_locale.tag(),
        app.mode,
        Some(app.plugin_registry.as_ref()),
        Some(context_window.tokens),
        app.instruction_assembly.as_deref(),
        false,
    );
    add_app_runtime_entries(&mut builder, app);
    builder.finish(
        context_window,
        pressure_estimated_tokens(app),
        Some(estimate_input_tokens_conservative(
            &app.api_messages,
            app.system_prompt.as_ref(),
        )),
        "Diagnostic source map. The headline is the pressure estimate the context meter and auto-compaction gate use; per-source counts are conservative estimates. All counts may differ from provider billing.",
    )
}

/// The one pressure number the context meter and the auto-compaction gate
/// decide on: the un-inflated estimate over the live request, lifted to the
/// provider's last billed prompt when that is higher (#5577).
fn pressure_estimated_tokens(app: &App) -> usize {
    let estimated =
        estimate_input_tokens_for_pressure(&app.api_messages, app.system_prompt.as_ref());
    let billed = app
        .last_billed_input_tokens
        .and_then(|tokens| usize::try_from(tokens).ok())
        .unwrap_or(0);
    estimated.max(billed)
}

#[must_use]
pub fn build_prompt_context(app: &App) -> PromptContext {
    let tool_catalog_state = if app.session.last_tool_catalog.is_some() {
        "last_sent"
    } else {
        "not_yet_sent"
    };
    let sections = match app.system_prompt.as_ref() {
        Some(SystemPrompt::Text(text)) => vec![PromptContextSection {
            index: 0,
            block_type: "text".to_string(),
            cache_control: None,
            estimated_tokens: estimate_text_tokens_conservative(text),
            text: text.clone(),
        }],
        Some(SystemPrompt::Blocks(blocks)) => blocks
            .iter()
            .enumerate()
            .map(|(index, block)| PromptContextSection {
                index,
                block_type: block.block_type.clone(),
                cache_control: block.cache_control.clone(),
                estimated_tokens: estimate_text_tokens_conservative(&block.text),
                text: block.text.clone(),
            })
            .collect(),
        None => Vec::new(),
    };
    PromptContext {
        schema_version: 1,
        provider: app.api_provider.as_str().to_string(),
        model: app.model.clone(),
        system_prompt_state: "current_session",
        tool_catalog_state,
        sections,
        tools: app.session.last_tool_catalog.clone().unwrap_or_default(),
        source_map: build_context_report(app),
    }
}

pub fn build_headless_context_report(config: &Config, workspace: &Path) -> PromptSourceMap {
    let model = config.default_model();
    let identity = config.active_provider_identity().ok();
    let provider = identity
        .as_ref()
        .map_or(codewhale_config::ProviderKind::Custom, |identity| {
            identity.provider
        });
    let provider_identity = identity
        .as_ref()
        .map(|identity| identity.key.to_string())
        .unwrap_or_else(|| "unavailable".to_string());
    let route = identity.as_ref().and_then(|identity| {
        codewhale_engine::route_runtime::resolve_runtime_route_for_identity(
            config,
            identity,
            Some(&model),
        )
        .ok()
    });
    // A route we could not resolve does not erase an operator-configured
    // window: doctor must report the same number the session would use.
    let context_window = route.as_ref().map_or_else(
        || {
            codewhale_engine::route_runtime::resolve_context_window(
                provider,
                &model,
                None,
                identity
                    .as_ref()
                    .and_then(|identity| config.context_window_for_provider_config(identity)),
                identity
                    .as_ref()
                    .and_then(|identity| config.model_context_windows_for(identity)),
            )
        },
        |route| route.context_window,
    );
    let global_skills_dir = config.skills_dir();
    let selected_skills_dir =
        crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
    let settings = codewhale_engine::project_context::InstructionSettings::from_config(config);
    let configured: Vec<_> = config
        .instructions_paths()
        .into_iter()
        .map(Into::into)
        .collect();
    let mut raw = codewhale_engine::project_context::capture_instruction_context(
        workspace,
        &configured,
        &settings,
    );
    let limits = route.as_ref().and_then(|route| {
        codewhale_engine::route_budget::known_route_limits(route.candidate.limits())
    });
    let budgets = codewhale_engine::project_context::InstructionBudgets::resolve_or_default(
        &settings.budget,
        context_window.instruction_budget_tokens(),
        Some(
            codewhale_engine::route_budget::effective_max_output_tokens_for_turn_with_window(
                provider,
                &model,
                limits,
                Some(context_window.tokens),
                None,
                codewhale_engine::core::engine::turn_loop::resolve_auto_effort(
                    config.reasoning_effort(),
                    provider,
                    &config.active_route_base_url(),
                    &model,
                )
                .as_deref(),
            ) as usize,
        ),
    );
    let assembly =
        codewhale_engine::project_context::assemble_instruction_block(&mut raw, &[], &budgets);
    let mut builder = base_source_entries(
        &model,
        workspace,
        Some(&selected_skills_dir),
        config.project_context_pack_enabled(),
        &config.skills_config(),
        "en",
        AppMode::Agent,
        None,
        Some(context_window.tokens),
        Some(&assembly),
        true,
    );
    let memory_path = config.memory_path();
    let memory_enabled = config.memory_enabled();

    if let Some(memory_block) = codewhale_runtime::native_memory::native_prompt_block(
        memory_enabled,
        &memory_path,
        workspace,
    ) {
        builder.push(SourceEntry::text(
            SourceKind::UserMemory,
            "User memory",
            Some(memory_path.display().to_string()),
            ActivationReason::ConfigEnabled,
            &memory_block,
            CountingConfidence::High,
            Some(6),
        ));
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::UserMemory,
            "User memory",
            Some(memory_path.display().to_string()),
            Some(6),
            "disabled, missing, or empty",
        ));
    }

    builder.push(SourceEntry::text(
        SourceKind::ModelProviderFact,
        format!("Provider facts ({provider_identity})"),
        None,
        ActivationReason::RuntimeState,
        &format!(
            "provider: {}\nmodel: {}\ncontext_window: {}\ncontext_window_source: {}",
            provider_identity,
            model,
            context_window.tokens,
            context_window.source.label()
        ),
        CountingConfidence::Approximate,
        None,
    ));

    let active_context_estimated_tokens = builder
        .entries
        .iter()
        .map(|entry| entry.estimated_tokens)
        .sum();
    builder.finish(
        context_window,
        active_context_estimated_tokens,
        None,
        "Headless diagnostic source map. Conversation, tool results, and live TUI state are unavailable in doctor mode.",
    )
}

fn known_tokens(value: Option<usize>) -> String {
    value.map_or_else(
        || "unknown".to_string(),
        |tokens| format!("{tokens} tokens"),
    )
}

#[allow(clippy::too_many_arguments)]
fn base_source_entries(
    model: &str,
    workspace: &Path,
    skills_dir: Option<&Path>,
    project_pack_enabled: bool,
    skills_config: &codewhale_runtime::config::SkillsConfig,
    locale_tag: &str,
    mode: AppMode,
    plugin_registry: Option<&codewhale_plugins::PluginRegistry>,
    context_window_tokens: Option<u32>,
    instruction_assembly: Option<&codewhale_engine::project_context::InstructionAssembly>,
    instruction_preview: bool,
) -> ReportBuilder {
    let mut builder = ReportBuilder::new();

    let constitution = codewhale_engine::prompts::compose_default_static_layers(model);
    builder.push(SourceEntry::text(
        SourceKind::Constitution,
        "Bundled constitution, language policy, and output policy",
        Some(
            codewhale_engine::prompts::base_prompt_origin()
                .label()
                .to_string(),
        ),
        ActivationReason::AlwaysOn,
        &constitution,
        CountingConfidence::High,
        Some(1),
    ));

    if let Some(block) = codewhale_engine::prompts::load_user_constitution_block() {
        builder.push(SourceEntry::text(
            SourceKind::UserConstitution,
            "User-global constitution",
            codewhale_config::UserConstitution::path()
                .ok()
                .map(|path| path.display().to_string()),
            ActivationReason::FilePresent,
            &block,
            CountingConfidence::High,
            Some(2),
        ));
    }

    match instruction_assembly {
        Some(assembly) => {
            if assembly.constitution_tokens > 0 {
                let mut entry = SourceEntry::diagnostic(
                    SourceKind::RepoConstitution,
                    "Repository constitution",
                    assembly
                        .constitution_source_path
                        .as_ref()
                        .map(|path| path.display().to_string()),
                    ActivationReason::FilePresent,
                    "included in installed instruction block",
                    assembly.constitution_tokens,
                    Some(4),
                );
                entry.truncation_reason = None;
                builder.push(entry);
            }
            match assembly.block.as_deref() {
                Some(block) => {
                    let mut entry = SourceEntry::text(
                        SourceKind::ProjectContext,
                        "Project instructions",
                        assembly
                            .source_path
                            .as_ref()
                            .map(|path| path.display().to_string()),
                        ActivationReason::FilePresent,
                        block,
                        CountingConfidence::High,
                        Some(5),
                    );
                    entry.estimated_tokens = entry
                        .estimated_tokens
                        .saturating_sub(assembly.constitution_tokens);
                    builder.push(entry);
                }
                None => builder.push(SourceEntry::omitted(
                    SourceKind::ProjectContext,
                    "Project context and repository instructions",
                    Some(workspace.display().to_string()),
                    Some(5),
                    "no project context block available",
                )),
            }
            let budget_label = if instruction_preview {
                "Instruction budget (preview)"
            } else {
                "Instruction budget"
            };
            let basis = if instruction_preview {
                "preview from current files and config; no installed receipt and no tool schemas measured"
            } else {
                "installed receipt"
            };
            builder.push(SourceEntry::diagnostic(
                SourceKind::ProjectContext,
                budget_label,
                None::<String>,
                ActivationReason::ConfigEnabled,
                format!(
                    "{basis}; global {} / {} estimated tokens; project {} / {} estimated tokens; policy {}; usable window {}; static-prefix guard {}",
                    assembly.global_tokens,
                    assembly.budgets.global_tokens,
                    assembly.project_tokens,
                    assembly.budgets.project_tokens,
                    assembly.budgets.policy.as_str(),
                    known_tokens(assembly.budgets.usable_window_tokens),
                    known_tokens(assembly.budgets.static_guard_tokens),
                ),
                0,
                Some(5),
            ));
            let locale = codewhale_localization::resolve_locale(locale_tag);
            for notice in assembly.notices.iter().filter(|notice| notice.is_loss()) {
                builder.push(SourceEntry::diagnostic(
                    SourceKind::ProjectContextWarning,
                    format!("Instruction source: {}", notice.label()),
                    notice.path.as_ref().map(|path| path.display().to_string()),
                    ActivationReason::RuntimeState,
                    notice.render(locale),
                    0,
                    Some(5),
                ));
            }
            if !assembly.warnings.is_empty() {
                builder.push(SourceEntry::diagnostic(
                    SourceKind::ProjectContextWarning,
                    "Project context warnings",
                    Some(workspace.display().to_string()),
                    ActivationReason::RuntimeState,
                    assembly.warnings.join("\n"),
                    0,
                    Some(4),
                ));
            }
        }
        None => builder.push(SourceEntry::omitted(
            SourceKind::ProjectContext,
            "Project instructions",
            None::<String>,
            Some(5),
            "installed instruction receipt unavailable",
        )),
    }

    if project_pack_enabled {
        if let Some(pack) =
            codewhale_engine::project_context::generate_project_context_pack(workspace)
        {
            builder.push(SourceEntry::text(
                SourceKind::ProjectContextPack,
                "Project context pack",
                Some(workspace.display().to_string()),
                ActivationReason::ConfigEnabled,
                &pack,
                CountingConfidence::Approximate,
                Some(5),
            ));
        }
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::ProjectContextPack,
            "Project context pack",
            Some(workspace.display().to_string()),
            Some(5),
            "disabled; project_map provides this information on demand",
        ));
    }

    let skills_block = codewhale_skills::render_index(
        workspace,
        skills_dir,
        skills_config,
        locale_tag,
        plugin_registry,
        context_window_tokens,
    );
    if let Some(block) = skills_block {
        let mut entry = SourceEntry::text(
            SourceKind::SkillsBlock,
            "Available skills",
            skills_dir.map(|path| path.display().to_string()),
            ActivationReason::FilePresent,
            &block,
            CountingConfidence::High,
            Some(5),
        );
        entry.truncation_reason = codewhale_skills::shortened_skills_note(&block);
        builder.push(entry);
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::SkillsBlock,
            "Available skills",
            skills_dir.map(|path| path.display().to_string()),
            Some(5),
            "no skills discovered",
        ));
    }

    builder.push(SourceEntry::omitted(
        SourceKind::ContextManagement,
        format!("{} runtime mode", mode.label()),
        None,
        Some(3),
        "mode enforced by runtime policy and the live tool catalog; no prompt doctrine",
    ));
    builder.push(SourceEntry::omitted(
        SourceKind::CompactionRelayTemplate,
        "Session relay template",
        Some("bundled in this codewhale-engine build (COMPACT_TEMPLATE, compiled in)".to_string()),
        Some(3),
        "loaded only when /relay is requested; automatic compaction owns its successor brief",
    ));
    builder.push(SourceEntry::text(
        SourceKind::RuntimePolicy,
        "Core execution discipline",
        None,
        ActivationReason::AlwaysOn,
        CORE_EXECUTION_PROFILE_PROMPT,
        CountingConfidence::High,
        Some(3),
    ));
    builder.push(SourceEntry::text(
        SourceKind::AuthorityRecap,
        "Authority recap",
        None,
        ActivationReason::AlwaysOn,
        codewhale_engine::prompts::effective_authority_recap(),
        CountingConfidence::High,
        Some(1),
    ));
    builder.push(SourceEntry::text(
        SourceKind::EnvironmentBlock,
        "Runtime environment",
        Some(workspace.display().to_string()),
        ActivationReason::AlwaysOn,
        &codewhale_engine::prompts::render_environment_block(workspace, locale_tag),
        CountingConfidence::High,
        Some(4),
    ));

    add_handoff_entry(&mut builder, workspace);
    builder
}

fn add_app_runtime_entries(builder: &mut ReportBuilder, app: &App) {
    if let Some(memory_block) = codewhale_runtime::native_memory::native_prompt_block(
        app.use_memory,
        &app.memory_path,
        &app.workspace,
    ) {
        builder.push(SourceEntry::text(
            SourceKind::UserMemory,
            "User memory",
            Some(app.memory_path.display().to_string()),
            ActivationReason::ConfigEnabled,
            &memory_block,
            CountingConfidence::High,
            Some(6),
        ));
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::UserMemory,
            "User memory",
            Some(app.memory_path.display().to_string()),
            Some(6),
            "disabled, missing, or empty",
        ));
    }

    if let Some(goal) = app
        .goal
        .objective
        .as_deref()
        .filter(|goal| !goal.trim().is_empty())
    {
        builder.push(SourceEntry::text(
            SourceKind::SessionGoal,
            "Session goal",
            None,
            ActivationReason::RuntimeState,
            goal,
            CountingConfidence::High,
            Some(6),
        ));
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::SessionGoal,
            "Session goal",
            None,
            Some(6),
            "no active /goal objective",
        ));
    }

    if let Some(tools) = app.session.last_tool_catalog.as_ref() {
        let rendered = serde_json::to_string(tools).unwrap_or_default();
        builder.push(SourceEntry::text(
            SourceKind::ToolSchemas,
            format!("Tool schemas ({} tools)", tools.len()),
            None,
            ActivationReason::PerRequest,
            &rendered,
            CountingConfidence::Approximate,
            Some(3),
        ));
    } else {
        builder.push(SourceEntry::omitted(
            SourceKind::ToolSchemas,
            "Tool schemas",
            None,
            Some(3),
            "no tool catalog has been sent yet",
        ));
    }

    add_message_entries(builder, &app.api_messages);
}

fn add_handoff_entry(builder: &mut ReportBuilder, workspace: &Path) {
    let (path, _) = codewhale_engine::prompts::resolve_handoff_path(workspace);
    let Some(raw) = std::fs::read_to_string(&path)
        .ok()
        .filter(|raw| !raw.trim().is_empty())
    else {
        builder.push(SourceEntry::omitted(
            SourceKind::HandoffRelay,
            "Previous session relay",
            Some(
                workspace
                    .join(codewhale_engine::prompts::HANDOFF_RELATIVE_PATH)
                    .display()
                    .to_string(),
            ),
            Some(6),
            "no relay artifact found",
        ));
        return;
    };

    builder.push(SourceEntry::text(
        SourceKind::HandoffRelay,
        "Previous session relay",
        Some(path.display().to_string()),
        ActivationReason::FilePresent,
        &raw,
        CountingConfidence::High,
        Some(6),
    ));
}

fn add_message_entries(builder: &mut ReportBuilder, messages: &[Message]) {
    if messages.is_empty() {
        builder.push(SourceEntry::omitted(
            SourceKind::ConversationHistory,
            "Conversation history",
            None,
            None,
            "no API messages yet",
        ));
        return;
    }

    let latest_user = messages.iter().rposition(|message| message.role == "user");
    let mut latest_user_tokens = 0usize;
    let mut conversation_tokens = 0usize;
    let mut tool_result_tokens = 0usize;
    let mut tool_result_count = 0usize;

    for (index, message) in messages.iter().enumerate() {
        for block in &message.content {
            let tokens = estimate_text_tokens_conservative(&content_block_text(block));
            match block {
                ContentBlock::ToolResult { .. }
                | ContentBlock::ToolSearchToolResult { .. }
                | ContentBlock::CodeExecutionToolResult { .. } => {
                    tool_result_tokens += tokens;
                    tool_result_count += 1;
                }
                ContentBlock::Text { .. } if Some(index) == latest_user => {
                    latest_user_tokens += tokens;
                }
                _ => {
                    conversation_tokens += tokens;
                }
            }
        }
    }

    if latest_user_tokens > 0 {
        builder.push(SourceEntry::estimate(
            SourceKind::UserRequest,
            "Latest user request",
            None,
            ActivationReason::PerRequest,
            latest_user_tokens,
            CountingConfidence::High,
            Some(7),
        ));
    }
    if conversation_tokens > 0 {
        builder.push(SourceEntry::estimate(
            SourceKind::ConversationHistory,
            "Conversation history",
            None,
            ActivationReason::RuntimeState,
            conversation_tokens,
            CountingConfidence::High,
            None,
        ));
    }
    if tool_result_count > 0 {
        builder.push(SourceEntry::estimate(
            SourceKind::ToolResult,
            format!("Tool results ({tool_result_count})"),
            None,
            ActivationReason::RuntimeState,
            tool_result_tokens,
            CountingConfidence::High,
            None,
        ));
    }
}

fn content_block_text(block: &ContentBlock) -> String {
    match block {
        ContentBlock::Text { text, .. } => text.clone(),
        ContentBlock::Thinking { thinking, .. } => thinking.clone(),
        ContentBlock::ToolResult { content, .. } => content.clone(),
        ContentBlock::ToolSearchToolResult { content, .. }
        | ContentBlock::CodeExecutionToolResult { content, .. } => content.to_string(),
        ContentBlock::ToolUse { input, .. } | ContentBlock::ServerToolUse { input, .. } => {
            input.to_string()
        }
        ContentBlock::ImageUrl { image_url } => image_url.url.clone(),
    }
}

#[cfg(test)]
fn pressure_label(percent: Option<f64>) -> &'static str {
    // Delegate to the unified pressure thresholds so this diagnostic label can't
    // drift from `context_budget::PressureLevel`. `None` (unknown window) keeps
    // its own sentinel since a level requires a usage percentage.
    match percent {
        Some(value) => PressureLevel::from_usage_percent(value).label(),
        None => "unknown",
    }
}

#[cfg(test)]
pub fn format_context_report(report: &PromptSourceMap) -> String {
    codewhale_commands::diagnostics_reports::format_context_report(&project_source_map(
        report.clone(),
    ))
}

#[cfg(test)]
pub fn format_context_summary(report: &PromptSourceMap) -> String {
    codewhale_commands::diagnostics_reports::format_context_summary(&project_source_map(
        report.clone(),
    ))
}

pub fn context_report_json(report: &PromptSourceMap) -> String {
    codewhale_commands::diagnostics_reports::context_report_json(&project_source_map(
        report.clone(),
    ))
}

#[cfg(test)]
mod pressure_fixture_tests;

#[cfg(test)]
mod tests {
    use super::*;
    use codewhale_config::ProviderKind;
    use codewhale_config::route::RouteLimits;
    use codewhale_engine::route_runtime::{ContextWindowResolution, ContextWindowSource};
    use codewhale_models::Role;
    use codewhale_models::Tool;
    use codewhale_runtime::config::Config;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn context_report_json_contains_sources_and_tool_results() {
        let messages = vec![
            Message {
                role: Role::User,
                content: vec![ContentBlock::Text {
                    text: "read src/lib.rs".to_string(),
                    cache_control: None,
                }],
            },
            Message {
                role: Role::Assistant,
                content: vec![ContentBlock::ToolResult {
                    execution_id: None,
                    tool_use_id: "call_1".to_string(),
                    content: "large tool output".repeat(40),
                    is_error: None,
                    content_blocks: None,
                }],
            },
        ];
        let mut builder = ReportBuilder::new();
        builder.push(SourceEntry::text(
            SourceKind::Constitution,
            "Test static",
            None,
            ActivationReason::AlwaysOn,
            "static",
            CountingConfidence::High,
            Some(1),
        ));
        add_message_entries(&mut builder, &messages);
        let report = builder.finish(
            ContextWindowResolution {
                tokens: 128_000,
                source: ContextWindowSource::Fallback,
            },
            123,
            Some(185),
            "test",
        );
        let json = context_report_json(&report);

        assert!(json.contains("\"source_kind\": \"tool_result\""));
        assert!(json.contains("\"active_context_estimated_tokens\": 123"));
        assert!(json.contains("\"overflow_guard_estimated_tokens\": 185"));
    }

    #[test]
    fn context_report_surfaces_repo_constitution_source_and_warnings() {
        let tmp = tempdir().expect("tempdir");
        fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
        fs::create_dir(tmp.path().join(".codewhale")).expect("mkdir .codewhale");
        fs::write(
            tmp.path().join(".codewhale").join("constitution.json"),
            r#"{
                "schema_version": 1,
                "authority": ["current user request"],
                "branch_policy": "v0.8.53 work targets the codex/v0.8.53 integration branch, not main"
            }"#,
        )
        .expect("write constitution");

        let report = build_headless_context_report(&Config::default(), tmp.path());
        assert!(
            report.entries.iter().any(|entry| {
                entry.source_kind == SourceKind::RepoConstitution
                    && entry.source_path.as_deref().is_some_and(|path| {
                        path.replace('\\', "/")
                            .ends_with(".codewhale/constitution.json")
                    })
            }),
            "repo constitution source should be an explicit source-map entry: {:?}",
            report.entries
        );
        assert!(
            report.entries.iter().any(|entry| {
                entry.source_kind == SourceKind::ProjectContextWarning
                    && entry
                        .truncation_reason
                        .as_deref()
                        .is_some_and(|reason| reason.contains("branch_policy appears stale"))
                    && entry.estimated_tokens == 0
            }),
            "repo constitution warnings should be explicit source-map entries: {:?}",
            report.entries
        );

        let formatted = format_context_report(&report);
        assert!(formatted.contains("Repository constitution"));
        assert!(formatted.contains("Project context warnings"));
        assert!(formatted.contains("/constitution"));
        assert!(formatted.contains("/setup report"));
        let json = context_report_json(&report);
        assert!(json.contains("\"repo_constitution\""));
        assert!(json.contains("branch_policy appears stale"));
    }

    #[test]
    fn context_report_shows_actions_and_budgets() {
        let tmp = tempdir().expect("workspace");
        std::fs::write(tmp.path().join("AGENTS.md"), "T".repeat(20_000)).expect("write agents");
        let config = {
            let __cw_config_init_0_0 =
                Some(codewhale_engine::project_context::InstructionBudgetConfig {
                    project_budget_tokens: Some(50),
                    ..Default::default()
                });
            let mut __cw_config_init_0_value: Config = Default::default();
            __cw_config_init_0_value.instruction_budget = __cw_config_init_0_0;
            __cw_config_init_0_value
        };
        let report = build_headless_context_report(&config, tmp.path());
        assert!(
            report
                .entries
                .iter()
                .any(|entry| entry.label == "Instruction budget (preview)")
        );
        assert!(report.entries.iter().any(|entry| {
            entry.label.starts_with("Instruction source:")
                && entry
                    .truncation_reason
                    .as_deref()
                    .is_some_and(|reason| reason.contains("scope_budget"))
        }));
    }

    #[test]
    fn headless_context_report_uses_kimi_code_k3_route_context() {
        let tmp = tempdir().expect("workspace");
        let config = {
            let __cw_config_init_1_0 = Some("moonshot".to_string());
            let __cw_config_init_1_1 = Some(codewhale_runtime::config::ProvidersConfig {
                moonshot: codewhale_runtime::config::ProviderConfig {
                    api_key: Some("test-kimi-key".to_string()),
                    base_url: Some(
                        codewhale_config::descriptors::defaults::DEFAULT_KIMI_CODE_BASE_URL
                            .to_string(),
                    ),
                    model: Some(
                        codewhale_config::catalog::reviewed::constants::KIMI_CODE_K3_MODEL
                            .to_string(),
                    ),
                    ..Default::default()
                },
                ..Default::default()
            });
            let mut __cw_config_init_1_value: Config = Default::default();
            __cw_config_init_1_value.provider = __cw_config_init_1_0;
            __cw_config_init_1_value.providers = __cw_config_init_1_1;
            __cw_config_init_1_value
        };

        let report = build_headless_context_report(&config, tmp.path());

        assert_eq!(report.context_window_tokens, Some(262_144));
        assert_eq!(
            report.context_window_source.as_deref(),
            Some("static Kimi Code safe floor")
        );
        assert!(context_report_json(&report).contains("\"context_window_tokens\": 262144"));
    }

    #[test]
    fn headless_context_report_honors_kimi_code_k3_context_override() {
        let tmp = tempdir().expect("workspace");
        let config = {
            let __cw_config_init_2_0 = Some("moonshot".to_string());
            let __cw_config_init_2_1 = Some(codewhale_runtime::config::ProvidersConfig {
                moonshot: codewhale_runtime::config::ProviderConfig {
                    api_key: Some("test-kimi-key".to_string()),
                    base_url: Some(
                        codewhale_config::descriptors::defaults::DEFAULT_KIMI_CODE_BASE_URL
                            .to_string(),
                    ),
                    model: Some(
                        codewhale_config::catalog::reviewed::constants::KIMI_CODE_K3_MODEL
                            .to_string(),
                    ),
                    context_window: Some(1_048_576),
                    ..Default::default()
                },
                ..Default::default()
            });
            let mut __cw_config_init_2_value: Config = Default::default();
            __cw_config_init_2_value.provider = __cw_config_init_2_0;
            __cw_config_init_2_value.providers = __cw_config_init_2_1;
            __cw_config_init_2_value
        };

        let report = build_headless_context_report(&config, tmp.path());

        assert_eq!(report.context_window_tokens, Some(1_048_576));
        assert_eq!(report.context_window_source.as_deref(), Some("configured"));
    }

    fn private_deployment_config(context_window: Option<u32>) -> Config {
        {
            let __cw_config_init_3_0 = Some("custom".to_string());
            let __cw_config_init_3_1 = Some(codewhale_runtime::config::ProvidersConfig {
                custom: std::collections::HashMap::from([(
                    "custom".to_string(),
                    codewhale_runtime::config::ProviderConfig {
                        kind: Some("openai-compatible".to_string()),
                        api_key: Some("test-private-key".to_string()),
                        base_url: Some("https://private.test/v1".to_string()),
                        model: Some("private-1m-deployment-v9".to_string()),
                        context_window,
                        ..Default::default()
                    },
                )]),
                ..Default::default()
            });
            let mut __cw_config_init_3_value: Config = Default::default();
            __cw_config_init_3_value.provider = __cw_config_init_3_0;
            __cw_config_init_3_value.providers = __cw_config_init_3_1;
            __cw_config_init_3_value
        }
    }

    /// #5239: a privately deployed id nobody catalogs, with an operator
    /// override, is a 1M route in the report — no route-resolution outcome may
    /// silently substitute the legacy window.
    #[test]
    fn headless_context_report_honors_a_private_model_context_override() {
        let tmp = tempdir().expect("workspace");

        let report =
            build_headless_context_report(&private_deployment_config(Some(1_048_576)), tmp.path());

        assert_eq!(report.context_window_tokens, Some(1_048_576));
        assert_eq!(report.context_window_source.as_deref(), Some("configured"));
    }

    /// The same id without an override is a guess, and the report must say so
    /// against the window it actually used.
    #[test]
    fn headless_context_report_marks_an_unknown_private_model_unverified() {
        let tmp = tempdir().expect("workspace");

        let report = build_headless_context_report(&private_deployment_config(None), tmp.path());

        assert_eq!(report.context_window_source.as_deref(), Some("fallback"));
        let formatted = format_context_report(&report);
        assert!(formatted.contains("this window is a guess"), "{formatted}");
        assert!(
            !formatted.contains("128K"),
            "the fallback rung must not assert a window it did not read: {formatted}"
        );
    }

    #[test]
    fn context_report_marks_whale_md_ignored_without_loading_body() {
        let tmp = tempdir().expect("tempdir");
        fs::write(tmp.path().join("WHALE.md"), "SECRET_LEGACY_WHALE_BODY").expect("write whale");

        let report = build_headless_context_report(&Config::default(), tmp.path());
        assert!(
            report.entries.iter().any(|entry| {
                entry.source_kind == SourceKind::ProjectContextWarning
                    && entry
                        .truncation_reason
                        .as_deref()
                        .is_some_and(|reason| reason.contains("WHALE.md is ignored"))
            }),
            "ignored WHALE.md should be visible as a migration warning: {:?}",
            report.entries
        );
        assert!(
            !context_report_json(&report).contains("SECRET_LEGACY_WHALE_BODY"),
            "ignored WHALE.md body must not enter context report"
        );
    }

    #[test]
    fn app_context_report_omits_legacy_plain_file_memory() {
        // The legacy single-file memory path (`~/.deepseek/memory.md` and
        // friends) was deleted for v0.9.4: only the native
        // `memory/global/MEMORY.md` store injects.
        let tmp = tempdir().expect("tempdir");
        let memory_path = tmp.path().join("memory.md");
        fs::write(&memory_path, "private legacy memory").expect("write memory");
        let config: Config = toml::from_str(
            r#"
            [memory]
            enabled = true
            "#,
        )
        .expect("parse config");
        let app = App::new(
            crate::tui::app::TuiOptions {
                screen_mode: crate::tui::app::ScreenMode::Inline,
                use_bracketed_paste: false,
                memory_path: memory_path.clone(),
                notes_path: tmp.path().join("notes.txt"),
                mcp_config_path: tmp.path().join("mcp.json"),
                use_memory: true,
                start_in_agent_mode: true,
                ..crate::tui::test_support::test_tui_options(tmp.path())
            },
            &config,
        );

        let report = build_context_report(&app);
        let memory_entry = report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::UserMemory)
            .expect("user memory source entry");

        assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
        assert!(!context_report_json(&report).contains("private legacy memory"));
    }

    #[test]
    fn headless_report_counts_project_pack_only_when_configured() {
        let tmp = tempdir().expect("tempdir");
        fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
        fs::create_dir(tmp.path().join("src")).expect("mkdir src");
        fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");

        let default_report = build_headless_context_report(&Config::default(), tmp.path());
        let default_pack = default_report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
            .expect("project pack entry");
        assert_eq!(default_pack.activation_reason, ActivationReason::Omitted);
        assert_eq!(default_pack.estimated_tokens, 0);
        assert_eq!(
            default_pack.truncation_reason.as_deref(),
            Some("disabled; project_map provides this information on demand")
        );

        let relay = default_report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::CompactionRelayTemplate)
            .expect("relay template entry");
        assert_eq!(relay.activation_reason, ActivationReason::Omitted);
        assert_eq!(relay.estimated_tokens, 0);

        let mut configured = Config::default();
        configured.context.project_pack = Some(true);
        let configured_report = build_headless_context_report(&configured, tmp.path());
        let configured_pack = configured_report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
            .expect("configured project pack entry");
        assert_eq!(
            configured_pack.activation_reason,
            ActivationReason::ConfigEnabled
        );
        assert!(
            configured_pack.estimated_tokens > 0,
            "configured project pack must be counted"
        );

        let environment = configured_report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::EnvironmentBlock)
            .expect("runtime environment entry");
        assert_eq!(environment.activation_reason, ActivationReason::AlwaysOn);
    }

    #[test]
    fn app_context_report_counts_configured_project_pack_before_first_turn() {
        let tmp = tempdir().expect("tempdir");
        fs::create_dir(tmp.path().join(".git")).expect("mkdir .git");
        fs::create_dir(tmp.path().join("src")).expect("mkdir src");
        fs::write(tmp.path().join("src/lib.rs"), "pub fn fixture() {}\n").expect("write fixture");
        let mut config = Config::default();
        config.context.project_pack = Some(true);
        let app = App::new(
            crate::tui::app::TuiOptions {
                screen_mode: crate::tui::app::ScreenMode::Inline,
                use_bracketed_paste: false,
                notes_path: tmp.path().join("notes.txt"),
                mcp_config_path: tmp.path().join("mcp.json"),
                start_in_agent_mode: true,
                ..crate::tui::test_support::test_tui_options(tmp.path())
            },
            &config,
        );

        assert!(
            app.system_prompt.is_none(),
            "fixture must be pre-first-turn"
        );
        let report = build_context_report(&app);
        let project_pack = report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::ProjectContextPack)
            .expect("project pack entry");
        assert_eq!(
            project_pack.activation_reason,
            ActivationReason::ConfigEnabled
        );
        assert!(project_pack.estimated_tokens > 0);
    }

    #[test]
    fn headless_context_report_omits_legacy_plain_file_memory() {
        let tmp = tempdir().expect("tempdir");
        let memory_path = tmp.path().join("memory.md");
        fs::write(&memory_path, "private legacy memory").expect("write memory");
        let mut config: Config = toml::from_str(
            r#"
            [memory]
            enabled = true
            "#,
        )
        .expect("parse config");
        config.memory_path = Some(memory_path.to_string_lossy().into_owned());

        let report = build_headless_context_report(&config, tmp.path());
        let memory_entry = report
            .entries
            .iter()
            .find(|entry| entry.source_kind == SourceKind::UserMemory)
            .expect("user memory source entry");

        assert_eq!(memory_entry.activation_reason, ActivationReason::Omitted);
        assert!(!context_report_json(&report).contains("private legacy memory"));
    }

    #[test]
    fn format_summary_lists_largest_sources() {
        let mut builder = ReportBuilder::new();
        builder.push(SourceEntry::estimate(
            SourceKind::ToolSchemas,
            "Tool schemas",
            None,
            ActivationReason::PerRequest,
            500,
            CountingConfidence::Approximate,
            Some(3),
        ));
        builder.push(SourceEntry::estimate(
            SourceKind::UserRequest,
            "Latest user request",
            None,
            ActivationReason::PerRequest,
            25,
            CountingConfidence::High,
            Some(7),
        ));
        let report = builder.finish(
            ContextWindowResolution {
                tokens: 128_000,
                source: ContextWindowSource::Fallback,
            },
            525,
            Some(800),
            "test",
        );
        let summary = format_context_summary(&report);

        assert!(summary.contains("Context Summary"));
        assert!(summary.contains("Tool schemas (500)"));
        // The headline is the pressure number; the inflated figure is only
        // ever the labeled secondary overflow-guard line.
        assert!(summary.contains("Estimated active context: 525 tokens"));
        assert!(summary.contains("Overflow guard: 800 tokens"));
        let full = format_context_report(&report);
        let headline = full
            .find("Estimated active context: 525 tokens")
            .expect("pressure headline");
        let guard = full.find("Overflow guard: 800 tokens").expect("guard line");
        assert!(headline < guard, "{full}");
    }

    #[test]
    fn finish_reflects_route_context_window_over_model_default() {
        // deepseek-v4-pro defaults to a 1M window; a resolved route advertising a
        // smaller window must win in the report's context_window_tokens.
        let route_window = 128_000u64;
        let model_default = codewhale_models::context_window_for_model("deepseek-v4-pro")
            .expect("model has a default window");
        assert_ne!(
            u64::from(model_default),
            route_window,
            "test fixture must differ from the model default to be meaningful"
        );

        let limits = RouteLimits {
            context_tokens: Some(route_window),
            input_tokens: None,
            output_tokens: None,
            output_declared: false,
        };
        let resolved = codewhale_engine::route_runtime::resolve_context_window(
            ProviderKind::Deepseek,
            "deepseek-v4-pro",
            Some(limits),
            None,
            None,
        );
        assert_eq!(resolved.source, ContextWindowSource::Catalog);

        let builder = ReportBuilder::new();
        let report = builder.finish(resolved, 10_000, None, "test");

        assert_eq!(report.context_window_tokens, Some(route_window as u32));
        assert_eq!(report.context_window_source.as_deref(), Some("catalog"));
        // Budget percent is computed against the route window, not the default.
        let expected = (10_000.0 / route_window as f64) * 100.0;
        let actual = report.budget_used_percent.expect("window known");
        assert!(
            (actual - expected).abs() < 1e-6,
            "got {actual}, want {expected}"
        );
    }

    #[test]
    fn pressure_label_matches_unified_pressure_levels() {
        // Boundaries mirror context_budget::PressureLevel.
        assert_eq!(pressure_label(None), "unknown");
        assert_eq!(pressure_label(Some(0.0)), "low");
        assert_eq!(pressure_label(Some(39.9)), "low");
        assert_eq!(pressure_label(Some(40.0)), "moderate");
        assert_eq!(pressure_label(Some(74.9)), "moderate");
        assert_eq!(pressure_label(Some(75.0)), "high");
        assert_eq!(pressure_label(Some(89.9)), "high");
        assert_eq!(pressure_label(Some(90.0)), "critical");
        assert_eq!(pressure_label(Some(100.0)), "critical");
    }

    #[test]
    fn tool_schema_entry_serializes_like_runtime_catalog() {
        let tool = Tool {
            tool_type: Some("function".to_string()),
            name: "read_file".to_string(),
            description: "read a file".to_string(),
            input_schema: serde_json::json!({"type": "object"}),
            allowed_callers: None,
            defer_loading: None,
            input_examples: None,
            strict: Some(true),
            cache_control: None,
        };
        let rendered = serde_json::to_string(&vec![tool]).expect("serialize tool");

        assert!(rendered.contains("read_file"));
    }
}