//! 上下文管理器 — 管理对话上下文和 token 预算
//!
//! 职责：
//! - 维护消息历史及其 token 计数缓存
//! - 提供预算感知的消息裁剪（保护工具调用三元组）
//! - 为 run_agentic_loop 提供受控的消息视图
//!
//! 裁剪策略与模型选择是正交维度：本模块只管「窗口多大、怎么裁」，
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
//!
//! 纯函数 / 数据类型 / 常量（TokenEstimator / ContextConfig / MessageGroup /
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
//! TOOL_MISSING_PREFIX）已抽至 [`crate::context_helpers`]，本模块 `use` 复用，
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见（零调用方变更）。
//! 本文件仅保留 `ContextManager` 结构体及其 `impl`（Rust impl 块不可跨文件）。

use crate::context_helpers::{
    classify_group, is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
    TOOL_MISSING_PREFIX,
};
// 重导出：保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
// 历史路径对外可见（agentic.rs / commands/ai/mod.rs 等调用方零变更）。
// `pub use` 同时把类型带入本模块命名空间，供 ContextManager 结构体字段与 impl 直接引用。
pub use crate::context_helpers::{
    EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
};

use crate::provider::{ChatMessage, MessageRole, ToolCall};

// ============================================================
// 上下文管理器
// ============================================================

/// 上下文管理器
///
/// 唯一的消息真相来源（替代原来的 `Vec<ChatMessage>`）。
/// 裁剪仅影响发送视图（`build_for_request`），不影响持久化（`all_messages_clone`）。
pub struct ContextManager {
    messages: Vec<TrackedMessage>,
    /// 当前历史总 token 数（不含 system prompt）
    history_tokens: u32,
    config: ContextConfig,
    estimator: TokenEstimator,
    /// 压缩重入标志（F-15 §4.3）：true 表示一次 LLM 压缩正在进行中。
    /// agentic loop 顶部检测，防同一轮内多次触发压缩互相覆盖。纯内存态，不落库。
    is_compressing: bool,
    /// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
    /// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
    /// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
    pending_topic_marker: Option<String>,
}

impl ContextManager {
    pub fn new(config: ContextConfig) -> Self {
        Self {
            messages: Vec::new(),
            history_tokens: 0,
            config,
            estimator: TokenEstimator::default(),
            is_compressing: false,
            pending_topic_marker: None,
        }
    }

    /// 追加消息（自动计算 token 并更新缓存）
    ///
    /// 不在此处淘汰——push 可能发生在 agentic loop 中间（追加 tool_result），
    /// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
    pub fn push(&mut self, message: ChatMessage) {
        let tokens = self.estimator.estimate_message(&message);
        let group = classify_group(&message);
        // 仅 active 消息计入 token 预算（F-15 §3.3）：truncated / archived_segment /
        // compressed 不进 LLM 上下文，token 虚高会致 build_for_request 误判超预算
        // 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留（持久化不受影响），
        // sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
        if message.is_active() {
            self.history_tokens += tokens;
        }
        // [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
        // 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
        // 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
        let topic: Option<String> = if matches!(message.role, MessageRole::User) {
            let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
            if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
                Some(intent.as_str().to_string())
            } else {
                None
            }
        } else {
            None
        };
        // [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
        // 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
        // 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
        if matches!(message.role, MessageRole::User) {
            if let Some(prev_topic) = self.last_user_topic() {
                if let Some(this_topic) = &topic {
                    if prev_topic != *this_topic {
                        self.pending_topic_marker =
                            Some(format!("{}|{}", prev_topic, this_topic));
                    }
                }
            }
        }
        self.messages.push(TrackedMessage {
            message,
            token_count: tokens,
            group,
            topic,
        });
    }

    /// 清空所有消息
    pub fn clear(&mut self) {
        self.messages.clear();
        self.history_tokens = 0;
        self.is_compressing = false;
        self.pending_topic_marker = None;
    }

    /// 消息数量
    pub fn len(&self) -> usize {
        self.messages.len()
    }

    pub fn is_empty(&self) -> bool {
        self.messages.is_empty()
    }

    /// 当前历史占用的 token 数（不含 system prompt）
    pub fn history_tokens(&self) -> u32 {
        self.history_tokens
    }

    /// 预算上限
    pub fn budget_limit(&self) -> u32 {
        self.config.budget_limit()
    }

    // ── 核心：构建请求消息（受控裁剪版本）──

    /// 构建发送给 LLM 的消息列表
    ///
    /// `sys_tokens` 为调用方已估算好的 system prompt token 数。
    /// 超预算时自动裁剪旧消息（保护工具调用三元组 + 最近 PROTECT_COUNT 条）。
    /// 返回 (消息列表, 是否发生了裁剪)。
    pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
        let budget = self.budget_limit();
        let available = budget.saturating_sub(sys_tokens);

        // system prompt 自身超预算：裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
        if sys_tokens > budget {
            tracing::warn!(
                "system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
                sys_tokens, budget
            );
        }

        // 未超预算 → 直接返回全量（仍做畸形配对自愈，防历史中毒触发 provider 500 死循环）
        if self.history_tokens <= available {
            let sanitized = Self::sanitize_messages(self.all_messages_clone());
            // 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
            return (
                Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
                false,
            );
        }

        // 超预算 → 视图裁剪（不修改 self.messages，保证 all_messages_clone 仍返回全量）
        let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
        let units = self.build_eviction_units(protect_start);

        let mut removed: u64 = 0;
        let mut trim_end = 0;
        for unit in &units {
            if self.history_tokens.saturating_sub(removed as u32) <= available {
                break;
            }
            removed += unit.token_sum as u64;
            trim_end = unit.end;
        }

        if trim_end == 0 {
            tracing::warn!(
                "history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
                self.history_tokens, available, PROTECT_COUNT
            );
            return (self.all_messages_clone(), false);
        }

        let msgs: Vec<ChatMessage> = self.messages[trim_end..]
            .iter()
            .map(|t| t.message.clone())
            .collect();

        tracing::info!(
            "context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
            trim_end, removed
        );
        let sanitized = Self::sanitize_messages(msgs);
        // 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
        (
            Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
            true,
        )
    }

    /// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
    ///
    /// 根因：Anthropic 流式 `tool_use` 块缺 id 时（anthropic_compat.rs 170-176）
    /// 生成 `tool_missing_{idx}` 占位 id；对应的 `tool_result` 一旦推入历史，
    /// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
    /// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对，
    /// 不改持久化（self.messages 全量保留），让卡死的会话能自愈继续。
    ///
    /// provider 协议铁律：assistant 头里每个 tool_call.id 都必须在后续有对应的
    /// tool_result，否则服务端 400/500。本函数按此自愈，分三档处理每个头：
    ///
    /// - 全闭合：所有 tool_call.id 都有匹配 tool_result → 原样保留。
    /// - 全未闭合（含占位 id 必然无匹配）：整头丢弃，其无主 tool_result 一并丢弃。
    /// - 部分闭合：保留头但只留已闭合的 tool_call，丢弃未闭合的 tool_call 及其
    ///   无主 tool_result（「不能整条删」——保住已发生的合法工具交互历史）。
    ///
    /// 单次遍历、保序过滤、不重排（user/assistant/tool 角色交替不被打乱）。
    pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
        use std::collections::HashSet;

        // step 0（UX-09）：过滤 truncated 软删消息，不进 LLM 上下文。
        // 编辑某条 user 消息后其后续消息标 truncated（保留 DB 可追溯），发送视图必须剔除，
        // 否则被编辑前的旧回复仍进入 LLM 历史，污染重生成语义。落库全量保留不受影响。
        let messages: Vec<ChatMessage> = messages
            .into_iter()
            .filter(|m| m.is_active())
            .collect();

        // step 1：已闭合的 tool_call_id 集合（role=Tool 消息全部提供过的 id）
        let resolved_ids: HashSet<&str> = messages
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Tool))
            .filter_map(|m| m.tool_call_id.as_deref())
            .collect();

        // step 2：判定每个 assistant 头的闭合状态，产出需丢弃的 id 集合
        //   orphaned_ids：未闭合的 tool_call.id（含占位 id），其 tool_result 要丢
        //   partial_heads：部分闭合的头——保留但需重写 tool_calls（只留已闭合的）
        //   full_drop_heads：全未闭合的头——整条丢弃
        let mut orphaned_ids: HashSet<String> = HashSet::new();
        let mut full_drop_heads = 0u32;
        let mut partial_heads = 0u32;
        // 记录部分闭合头中「应保留的 id」，用于 step3 精确重写
        let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
            std::collections::HashMap::new();

        for (i, m) in messages.iter().enumerate() {
            if !matches!(m.role, MessageRole::Assistant) {
                continue;
            }
            let Some(calls) = m.tool_calls.as_ref() else { continue };
            if calls.is_empty() {
                continue;
            }
            let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
                .iter()
                .partition(|c| {
                    !c.id.starts_with(TOOL_MISSING_PREFIX)
                        && resolved_ids.contains(c.id.as_str())
                });

            match (resolved.is_empty(), unresolved.is_empty()) {
                // 全未闭合 → 整头丢弃，未闭合 id 入 orphaned_ids
                (true, false) => {
                    full_drop_heads += 1;
                    for c in &unresolved {
                        orphaned_ids.insert(c.id.clone());
                    }
                }
                // 全闭合 → 原样保留
                (false, true) => {}
                // 部分闭合 → 保留头，只留 resolved 的 tool_call，unresolved 入 orphaned_ids
                (false, false) => {
                    partial_heads += 1;
                    for c in &unresolved {
                        orphaned_ids.insert(c.id.clone());
                    }
                    partial_keep.insert(i, resolved.into_iter().cloned().collect());
                }
                // resolved/unresolved 都空不可能（calls 非空已保证）
                (true, true) => {}
            }
        }

        let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
            messages
        } else {
            // step 3：保序过滤 + 部分闭合头重写 tool_calls
            let sanitized: Vec<ChatMessage> = messages
                .into_iter()
                .enumerate()
                .filter_map(|(i, mut m)| match m.role {
                    MessageRole::Assistant => {
                        let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
                        if calls.is_empty() {
                            return Some(m);
                        }
                        if let Some(keep) = partial_keep.get(&i) {
                            // 部分闭合：重写 tool_calls 为仅已闭合子集
                            m.tool_calls = Some(keep.clone());
                            Some(m)
                        } else {
                            // 全闭合：保留；全未闭合（id 全在 orphaned_ids）：丢弃
                            let all_orphaned =
                                calls.iter().all(|c| orphaned_ids.contains(&c.id));
                            if all_orphaned {
                                None
                            } else {
                                Some(m)
                            }
                        }
                    }
                    MessageRole::Tool => {
                        // 无主 tool_result（其 id 命中 orphaned_ids）丢弃，其余保留
                        if m
                            .tool_call_id
                            .as_deref()
                            .is_some_and(|id| orphaned_ids.contains(id))
                        {
                            None
                        } else {
                            Some(m)
                        }
                    }
                    _ => Some(m),
                })
                .collect();

            tracing::warn!(
                full_drop_heads,
                partial_rewrite_heads = partial_heads,
                orphaned_tool_results = orphaned_ids.len(),
                "history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
            );
            sanitized
        };

        // step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
        //
        // 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
        // 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
        // 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
        // deepseek-v4-pro 等端点对此严格校验 → 400。
        //
        // 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
        // head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
        //
        // **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
        // 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
        // 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
        // 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
        // (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
        // 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
        let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
            Self::drop_reverse_orphans(after_triplet)
        } else {
            after_triplet
        };

        // step 4：序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
        Self::ensure_sequence_legal(after_reverse_orphan)
    }

    /// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
    ///
    /// 详见 sanitize_messages step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
    /// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
    fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
        // 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
        use std::collections::HashSet;
        let head_ids: HashSet<String> = messages
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .filter_map(|m| m.tool_calls.as_ref())
            .flatten()
            .map(|c| c.id.clone())
            .collect();

        let mut dropped = 0u32;
        let mut placeholder_kept = 0u32;
        let filtered: Vec<ChatMessage> = messages
            .into_iter()
            .filter(|m| {
                if !matches!(m.role, MessageRole::Tool) {
                    return true;
                }
                let Some(id) = m.tool_call_id.as_deref() else {
                    return true; // 无 id 的 tool_result(异常数据),不在此处处理
                };
                if head_ids.contains(id) {
                    return true; // 有对应头 → 保留
                }
                // 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
                if is_pending_placeholder(&m.content) {
                    placeholder_kept += 1;
                    true
                } else {
                    dropped += 1;
                    false
                }
            })
            .collect();

        if dropped > 0 || placeholder_kept > 0 {
            tracing::warn!(
                dropped_reverse_orphan_tool_results = dropped,
                placeholder_kept_without_head = placeholder_kept,
                "history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
            );
        }
        filtered
    }

    /// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
    /// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
    ///
    /// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
    /// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
    /// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
    ///
    /// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
    /// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
    /// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
    ///
    /// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
    /// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
    /// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
    /// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
    ///
    /// **view-only**:本函数不改 self.messages,仅修改传入 Vec(clone),持久化全量保留。
    /// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
    /// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
    ///
    /// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
    pub fn assert_placeholder_pairing(
        messages: Vec<ChatMessage>,
        enabled: bool,
    ) -> Vec<ChatMessage> {
        if !enabled {
            return messages;
        }
        // 收集现有头的 tool_call.id(已配对的)
        use std::collections::HashSet;
        let head_ids: HashSet<String> = messages
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .filter_map(|m| m.tool_calls.as_ref())
            .flatten()
            .map(|c| c.id.clone())
            .collect();

        // 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
        // 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
        let mut healed = 0u32;
        let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
        for m in messages.into_iter() {
            if matches!(m.role, MessageRole::Tool) {
                if let Some(id) = m.tool_call_id.as_deref() {
                    if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
                        // orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
                        let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
                        let head = ChatMessage::assistant_with_tools(
                            String::new(),
                            vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
                        );
                        out.push(head);
                        // 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
                        let mut result = m;
                        result.tool_call_id = Some(head_id);
                        out.push(result);
                        healed += 1;
                        continue;
                    }
                }
            }
            out.push(m);
        }
        if healed > 0 {
            tracing::warn!(
                healed_orphan_tool_results = healed,
                "send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
            );
            // 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
            // (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
            // 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
            // 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
            // tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
            // 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
            //
            // view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改 self.messages 持久化。
            return Self::ensure_sequence_legal(out);
        }
        out
    }


    /// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
    ///
    /// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
    /// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
    /// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
    ///
    /// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
    /// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
    /// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
    /// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
    fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
        let mut skipped = 0u32;
        let mut merged = 0u32;
        let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
        for m in messages {
            // 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
            if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
                skipped += 1;
                continue;
            }
            // 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
            // 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
            if let Some(last) = fixed.last_mut() {
                let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
                if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
                    if !m.content.is_empty() {
                        if !last.content.is_empty() {
                            last.content.push('\n');
                        }
                        last.content.push_str(&m.content);
                    }
                    if matches!(m.role, MessageRole::Assistant) {
                        if let Some(calls) = m.tool_calls {
                            last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
                        }
                    }
                    merged += 1;
                    continue;
                }
            }
            fixed.push(m);
        }
        if skipped > 0 || merged > 0 {
            tracing::warn!(
                skipped,
                merged,
                "序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
            );
        }
        fixed
    }

    /// 全量克隆（持久化 save_conversation / build_for_request 未裁剪分支，不受裁剪影响）
    pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
        self.messages.iter().map(|t| t.message.clone()).collect()
    }

    /// 从 Vec 恢复（兼容从 DB 加载）
    pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
        self.clear();
        for msg in messages {
            self.push(msg);
        }
    }

    /// 就地替换某条 tool_result 的内容（兼容审批 replace_tool_result）
    /// 返回 true 如果找到并替换了
    ///
    /// 反向遍历：tool_result 由 append 进入历史，被替换的通常是最近的审批占位，
    /// 从尾部查找命中即停，避免对长历史做正向 O(n) 累积扫描。
    pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::Tool)
                && t.message.tool_call_id.as_deref() == Some(tool_call_id)
        });

        let Some(i) = pos else { return false };

        // 先更新 content，再重估 token 并校正总量
        let old_tokens = self.messages[i].token_count;
        self.messages[i].message.content = new_content.to_string();
        let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
        self.messages[i].token_count = new_tokens;
        self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
        true
    }

    /// 弹出末尾连续的 assistant 消息（含其 tool_calls 三元组尾随 tool_result）
    ///
    /// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
    /// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
    ///
    /// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
    /// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
    /// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
    pub fn pop_last_assistant_round(&mut self) -> bool {
        if self.messages.is_empty() {
            return false;
        }
        let mut popped_any = false;
        // 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
        while let Some(last) = self.messages.last() {
            if matches!(last.message.role, MessageRole::User) {
                break;
            }
            let removed = self.messages.pop().expect("just checked non-empty");
            self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
            if matches!(removed.message.role, MessageRole::Assistant) {
                popped_any = true;
            }
        }
        popped_any
    }

    /// 编辑某条 user 消息后，将其后所有消息标记为 truncated（UX-09 编辑重生成）。
    ///
    /// 软删语义：保留在内存真相源 + DB（可追溯），但 sanitize_messages 过滤后不进 LLM 上下文，
    /// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数（0 表示该 user 消息已是末尾，无后续）。
    ///
    /// `target_content` 为该 user 消息的预期内容（用于反向唯一定位：末条 user 消息可能内容相同，
    /// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息）。
    /// 找不到返回 Err(())，调用方据此报错。
    pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
        // 反向找末条 active user 消息且 content 匹配
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::User)
                && t.message.content == target_content
                && t.message.is_active()
        });
        let Some(i) = pos else { return Err(()) };
        // i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
        let mut count = 0usize;
        for t in self.messages[i + 1..].iter_mut() {
            if t.message.is_active() {
                t.message.status = Some("truncated".to_string());
                count += 1;
            }
        }
        Ok(count)
    }

    /// 替换末条 active user 消息的 content（UX-09 编辑重生成）。
    ///
    /// 编辑语义：只能编辑最后一条 user 消息（中间编辑语义复杂，拒绝）。返回 Err(()) 表示无 active user 消息。
    /// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
    pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
        let pos = self.messages.iter().rposition(|t| {
            matches!(t.message.role, MessageRole::User) && t.message.is_active()
        });
        let Some(i) = pos else { return Err(()) };
        let old_tokens = self.messages[i].token_count;
        self.messages[i].message.content = new_content.to_string();
        let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
        self.messages[i].token_count = new_tokens;
        self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
        Ok(())
    }

    /// 只读迭代（兼容 ensure_conversation_title 的 .iter().filter() 等）
    pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
        self.messages.iter().map(|t| &t.message)
    }

    /// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
    ///
    /// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
    /// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
    /// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
    ///
    /// 用途:
    /// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
    ///   (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
    /// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
    fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
        self.messages
            .iter()
            .rev()
            .find(|t| {
                std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
            })
            .and_then(|t| t.message.id.clone())
    }

    /// 末条 assistant 消息的 id(消息级溯源用)。
    pub fn last_assistant_message_id(&self) -> Option<String> {
        self.last_message_id_by_role(MessageRole::Assistant)
    }

    /// 末条 user 消息的 id(消息级溯源用)。
    pub fn last_user_message_id(&self) -> Option<String> {
        self.last_message_id_by_role(MessageRole::User)
    }

    // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──

    /// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
    ///
    /// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
    /// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
    fn last_user_topic(&self) -> Option<String> {
        self.messages
            .iter()
            .rev()
            .find(|t| matches!(t.message.role, MessageRole::User))
            .and_then(|t| t.topic.clone())
    }

    /// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
    ///
    /// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
    /// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
    pub fn take_topic_marker(&mut self) -> Option<String> {
        self.pending_topic_marker.take()
    }

    // ── F-15 上下文管理增强辅助方法（压缩链路核心 API）──
    //
    // 已全量接入压缩链路:
    // - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
    //   set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
    //   messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
    //   insert_at(摘要/续接锚点 system 消息插入首位)
    // - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
    // - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。

    /// 配置（只读视图，供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`）
    pub fn config(&self) -> &ContextConfig {
        &self.config
    }

    /// 可变消息切片（供阶段2 标记 status="compressed"/"archived_segment" + 调整 token）
    ///
    /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
    /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
    pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
        &mut self.messages
    }

    /// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
    ///
    /// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
    /// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
    pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
        let tokens = self.estimator.estimate_message(&message);
        let group = classify_group(&message);
        if message.is_active() {
            self.history_tokens += tokens;
        }
        self.messages.insert(index, TrackedMessage {
            message,
            token_count: tokens,
            group,
            topic: None,
        });
    }

    /// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
    ///
    /// 返回每个单元的右开区间 end + token 总和,保证:
    /// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
    /// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
    ///
    /// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
    pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
        let mut units = Vec::new();
        let mut i = 0usize;

        while i < protect_start {
            let mut token_sum = 0u32;

            if self.messages[i].group == MessageGroup::ToolCallHead {
                token_sum += self.messages[i].token_count;
                i += 1;
                while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
                    token_sum += self.messages[i].token_count;
                    i += 1;
                }
                if i < protect_start
                    && self.messages[i].group == MessageGroup::Standalone
                    && matches!(self.messages[i].message.role, MessageRole::Assistant)
                {
                    token_sum += self.messages[i].token_count;
                    i += 1;
                }
            } else {
                token_sum += self.messages[i].token_count;
                i += 1;
            }

            units.push(EvictionUnit { end: i, token_sum });
        }

        units
    }

    /// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
    ///
    /// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
    /// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
    pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
        let end = protect_start.min(self.messages.len());
        // BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
        // 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
        // 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
        // compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
        self.messages[..end]
            .iter()
            .any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
    }

    /// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
    /// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
    ///
    /// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
    /// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
    ///
    /// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
    /// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
    ///
    /// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
    pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
        let end = compress_end.min(self.messages.len());
        let mut newly_compressed = Vec::new();
        for t in self.messages[..end].iter_mut() {
            if t.message.is_active() {
                newly_compressed.push(t.message.clone());
                t.message.status = Some("compressed".to_string());
                self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
            }
        }
        newly_compressed
    }

    /// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
    pub fn is_compressing(&self) -> bool {
        self.is_compressing
    }

    /// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
    /// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
    pub fn set_compressing(&mut self, v: bool) {
        self.is_compressing = v;
    }

    // build_eviction_units / classify_group 等已在上方公开或文件级定义。
}

#[cfg(test)]
mod tests {
    use super::*;

    fn cfg(max_tokens: u32) -> ContextConfig {
        ContextConfig {
            max_tokens,
            output_reserve: 0,
            safety_ratio: 1.0,
        }
    }

    #[test]
    fn sanitize_drops_placeholder_id_triplet() {
        // 中毒场景：assistant 头带 tool_missing_ 占位 id + 无主 tool_result
        // → build_for_request 必须剔除两者，否则下次回传触发 provider 500 死循环
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("调用工具"));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
        let (msgs, _trimmed) = mgr.build_for_request(0);
        // 中毒的 head + 无主 tool_result 全部剔除，只剩首条 user
        assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
        assert_eq!(msgs[0].content, "调用工具");
        // 持久化全量保留（自愈只改发送视图）
        assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
    }

    #[test]
    fn sanitize_drops_unresolved_tool_call_head() {
        // 真实 id 但全无 tool_result（流式中断/异常）→ 全未闭合档，整头丢弃
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
        ));
        // 两个 call 都无 result → 整头丢弃
        let (msgs, _trimmed) = mgr.build_for_request(0);
        assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
            || m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
            "全未闭合的 assistant 头应被剔除");
        assert!(msgs.is_empty(), "全未闭合应整条删");
        assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
    }

    #[test]
    fn sanitize_partial_resolve_rewrites_head() {
        // 多 tool_call，部分有匹配部分无 → 保留头但只留已闭合的，丢弃无主 result
        // provider 协议铁律：head 的每个 tool_call.id 必须都有 tool_result。
        // 「不能整条删」——保住已发生的合法工具交互（call_b）历史。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("触发工具"));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
        ));
        // 只有 call_b 有 result → call_a 未闭合
        mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
        let (msgs, _trimmed) = mgr.build_for_request(0);

        // 头被保留（不是整条删），但 tool_calls 重写为只剩已闭合的 call_b
        let assistant_heads: Vec<_> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
            .collect();
        assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留（重写不删）");
        let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
        assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
        assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
        assert!(
            head_calls.iter().all(|c| c.id != "call_a"),
            "未闭合的 call_a 应从重写后的 tool_calls 中移除"
        );

        // tool_result 只剩 call_b 的，user 前置保留
        let tool_msgs: Vec<_> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Tool))
            .collect();
        assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
        assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
        assert!(
            msgs.iter()
                .any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
            "无关 user 消息不应被误删"
        );
        assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
    }

    #[test]
    fn sanitize_keeps_well_formed_triplet() {
        // 正常三元组：user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
        // 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("问题"));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("call_a", "fn_a", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("call_a", "结果"));
        let (msgs, _trimmed) = mgr.build_for_request(0);
        assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
    }

    // ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──

    #[test]
    fn sanitize_drops_reverse_orphan_tool_result() {
        // 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
        // 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
        let msgs = vec![
            ChatMessage::user("问题"),
            ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
        ];
        let sanitized = ContextManager::sanitize_messages(msgs);
        assert!(
            sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
            "无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
            sanitized
        );
    }

    #[test]
    fn sanitize_keeps_reverse_orphan_pending_placeholder() {
        // 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
        // (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
        let placeholder_content = "需要用户审批，等待确认__PENDING__:call_pending_1";
        let msgs = vec![
            ChatMessage::user("问题"),
            ChatMessage::tool_result("call_pending_1", placeholder_content),
        ];
        let sanitized = ContextManager::sanitize_messages(msgs);
        let kept: Vec<_> = sanitized
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Tool))
            .collect();
        assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
        assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
    }

    #[test]
    fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
        // 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
        // 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
        let placeholder_content = "需要用户审批，等待确认__PENDING__:call_pending_2";
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("问题"));
        mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
        let (msgs, _trimmed) = mgr.build_for_request(0);

        // 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
        let healed_heads: Vec<_> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .filter_map(|m| m.tool_calls.as_ref())
            .flatten()
            .filter(|c| c.id.starts_with("tool_missing_"))
            .collect();
        assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
        // 占位头 id 应含原 tool_call_id(精确配对)
        assert!(
            healed_heads[0].id.contains("call_pending_2"),
            "补的头 id 应含原 tool_call_id, 实际 {}",
            healed_heads[0].id
        );
        // result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
        let result_id = msgs
            .iter()
            .find(|m| matches!(m.role, MessageRole::Tool))
            .and_then(|m| m.tool_call_id.clone())
            .expect("应有 tool_result");
        assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
        // 持久化全量保留(view-only)
        assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
    }

    #[test]
    fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
        // 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("问题"));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("call_ok", "fn", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
        let (msgs, _trimmed) = mgr.build_for_request(0);
        // 无 tool_missing_ 头(正常三元组不需自愈)
        let missing_heads = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .filter_map(|m| m.tool_calls.as_ref())
            .flatten()
            .filter(|c| c.id.starts_with("tool_missing_"))
            .count();
        assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
    }

    #[test]
    fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
        // 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
        // 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
        // (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
        // 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
        //
        // 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
        // orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
        // 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
        // → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
        //
        // 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
        // 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
        // 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
        let placeholder_content = "需要用户审批，等待确认__PENDING__:call_pending_cc";
        let msgs = vec![
            ChatMessage::user("问题"),
            ChatMessage::assistant("纯文本回复(无 tool_calls)"),
            ChatMessage::tool_result("call_pending_cc", placeholder_content),
        ];
        let sanitized = ContextManager::sanitize_messages(msgs.clone());
        // 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
        assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
        assert!(sanitized.iter().any(|m|
            matches!(m.role, MessageRole::Tool)
            && m.tool_call_id.as_deref() == Some("call_pending_cc")
        ), "占位 tool_result 应保留");

        let healed = ContextManager::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);

        // 断言:补了占位头(tool_missing_ 前缀 id)
        let missing_heads: Vec<_> = healed
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .filter_map(|m| m.tool_calls.as_ref())
            .flatten()
            .filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
            .collect();
        assert_eq!(
            missing_heads.len(),
            1,
            "应补 1 个占位头, 实际 {:?}",
            missing_heads
        );

        // 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
        // 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
        // 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
        let mut prev_is_assistant = false;
        let mut consecutive = 0u32;
        for m in &healed {
            let is_assistant = matches!(m.role, MessageRole::Assistant);
            if is_assistant && prev_is_assistant {
                consecutive += 1;
            }
            prev_is_assistant = is_assistant;
        }
        assert_eq!(
            consecutive, 0,
            "补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
            healed
                .iter()
                .map(|m| format!("{:?}", m.role))
                .collect::<Vec<_>>()
        );

        // 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
        // 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
        // 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
        let assistant_msgs: Vec<_> = healed
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant))
            .collect();
        assert_eq!(
            assistant_msgs.len(),
            1,
            "连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
            assistant_msgs.len(),
            healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
        );
        // 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
        let merged = &assistant_msgs[0];
        assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
        let merged_call_ids: Vec<&str> = merged
            .tool_calls
            .as_ref()
            .map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
            .unwrap_or_default();
        assert!(
            merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
                && id.contains("call_pending_cc")),
            "合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
            merged_call_ids
        );

        // 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
        let result_id = healed
            .iter()
            .find(|m| matches!(m.role, MessageRole::Tool))
            .and_then(|m| m.tool_call_id.clone())
            .expect("应有 tool_result");
        assert!(
            merged_call_ids.contains(&result_id.as_str()),
            "result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
            result_id,
            merged_call_ids
        );
    }

    #[test]
    fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
        // 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
        // (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
        let msgs = vec![
            ChatMessage::user("问题"),
            ChatMessage::tool_result("legacy_pending", "需要用户审批，等待确认"),
        ];
        let sanitized = ContextManager::sanitize_messages(msgs);
        let kept = sanitized
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Tool))
            .count();
        assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
    }

    #[test]
    fn sanitize_mixed_multi_message_sequence() {
        // 场景 4：连续多条消息混合——
        // [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
        // 预期：全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("开场"));
        // 全闭合三元组
        mgr.push(ChatMessage::assistant_with_tools(
            "调1",
            vec![ToolCall::new("ok_1", "fn", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
        // 中毒占位 id 三元组（全未闭合）→ 整删
        mgr.push(ChatMessage::assistant_with_tools(
            "调2",
            vec![ToolCall::new("tool_missing_0", "fn", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
        // 部分闭合头：keep_1 闭合，drop_1 未闭合 → 重写为只剩 keep_1
        mgr.push(ChatMessage::assistant_with_tools(
            "调3",
            vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
        mgr.push(ChatMessage::user("收尾"));
        let (msgs, _trimmed) = mgr.build_for_request(0);

        // 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
        let head_ids: Vec<Vec<String>> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
            .map(|m| {
                m.tool_calls
                    .as_ref()
                    .unwrap()
                    .iter()
                    .map(|c| c.id.clone())
                    .collect()
            })
            .collect();
        assert!(
            head_ids.iter().all(|ids| {
                ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
            }),
            "保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
        );
        let tool_ids: Vec<String> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::Tool))
            .filter_map(|m| m.tool_call_id.clone())
            .collect();
        // ok_1（全闭合保留）、keep_1（部分闭合重写后保留）应在；占位/drop_1 不在
        assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
        assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
        assert!(
            tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
            "无主 tool_result 应被剔除, 实际 {:?}", tool_ids
        );
        // 两条 user 消息原样保留
        let user_contents: Vec<&str> = msgs
            .iter()
            .filter(|m| matches!(m.role, MessageRole::User))
            .map(|m| m.content.as_str())
            .collect();
        assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
        assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
    }

    #[test]
    fn estimate_message_counts_parts_tokens() {
        // F-260614-05 多模态回归：含图消息的大段 base64 必须计入 token 预算，
        // 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
        let est = TokenEstimator::default();

        // 纯文本基线
        let text_msg = ChatMessage::user("短文本");
        let text_tokens = est.estimate_message(&text_msg);

        // 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
        let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
        let multimodal = ChatMessage::user_parts(
            "短文本",
            vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
        );
        let mm_tokens = est.estimate_message(&multimodal);

        assert!(
            mm_tokens > text_tokens,
            "含图消息 token({}) 应高于纯文本({})",
            mm_tokens,
            text_tokens
        );
        // base64 字符按 0.35 粗估，约 1.7k * 0.35 ≈ 595 tokens 量级
        assert!(
            mm_tokens > 500,
            "大 base64 应贡献可观 token，实际 {}",
            mm_tokens
        );

        // url 模式（无字节）也按 URL 长度估算，不爆
        let url_msg = ChatMessage::user_parts(
            "t",
            vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
        );
        let url_tokens = est.estimate_message(&url_msg);
        assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
    }

    #[test]
    fn short_history_no_trim() {
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("你好"));
        mgr.push(ChatMessage::assistant("你好啊"));
        let (msgs, trimmed) = mgr.build_for_request(10);
        assert!(!trimmed);
        assert_eq!(msgs.len(), 2);
    }

    #[test]
    fn over_budget_trims_old() {
        // 小预算强制裁剪：20 条超预算，触发裁剪且保留保护区
        let mut mgr = ContextManager::new(cfg(200));
        // user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
        for i in 0..20 {
            if i % 2 == 0 {
                mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
            } else {
                mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
            }
        }
        let (msgs, trimmed) = mgr.build_for_request(0);
        assert!(trimmed, "超预算应触发裁剪");
        assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());

        // 保护区:最新一条必保留(末条 i=19 是 assistant)
        assert_eq!(
            msgs.last().unwrap().content,
            "第 19 条较长的回复用于撑爆预算",
            "保护区最新消息被误裁"
        );

        // 裁剪是视图：内存全量不变
        assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
    }

    #[test]
    fn tool_triplet_kept_atomic() {
        // 三元组不可分离：Head 与 Tail 同进同出，永不从中间切断
        // 布局：6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
        let mut mgr = ContextManager::new(cfg(95));
        for i in 0..6 {
            mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
        }
        mgr.push(ChatMessage::assistant_with_tools(
            "调工具",
            vec![ToolCall::new("tc1", "read_file", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
        mgr.push(ChatMessage::assistant("完成"));
        for i in 0..6 {
            mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
        }

        // 分支一：预算宽松，三元组整体保留 → Head 在则 Tail 在
        let (msgs_keep, trimmed1) = mgr.build_for_request(0);
        assert!(trimmed1, "分支一应触发裁剪");
        assert_eq!(
            has_head(&msgs_keep),
            has_tail(&msgs_keep),
            "分支一三元组被切断: head={} tail={}",
            has_head(&msgs_keep),
            has_tail(&msgs_keep)
        );

        // 分支二：预算紧张，三元组整体丢弃 → Head 不在则 Tail 也不在
        let (msgs_drop, trimmed2) = mgr.build_for_request(40);
        assert!(trimmed2, "分支二应触发裁剪");
        assert_eq!(
            has_head(&msgs_drop),
            has_tail(&msgs_drop),
            "分支二三元组被切断: head={} tail={}",
            has_head(&msgs_drop),
            has_tail(&msgs_drop)
        );

        // 裁剪是视图：两次 build 都不应改变内存全量
        assert_eq!(
            mgr.all_messages_clone().len(),
            15,
            "裁剪污染了内存全量"
        );
    }

    fn has_head(msgs: &[ChatMessage]) -> bool {
        msgs.iter()
            .any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
    }

    fn has_tail(msgs: &[ChatMessage]) -> bool {
        msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
    }

    #[test]
    fn replace_tool_result_updates_tokens() {
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::tool_result("tc1", "短"));
        let before = mgr.history_tokens();
        assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
        let after = mgr.history_tokens();
        assert!(after > before);
    }

    #[test]
    fn restore_rebuilds_token_cache() {
        let mut mgr = ContextManager::new(cfg(100_000));
        let src = vec![
            ChatMessage::user("测试消息一"),
            ChatMessage::assistant("回复一"),
            ChatMessage::user("测试消息二"),
        ];
        mgr.restore_from_messages(src);
        assert!(mgr.history_tokens() > 0);
        assert_eq!(mgr.len(), 3);
    }

    #[test]
    fn push_token_only_active() {
        // F-15 §3.3：!active 消息（truncated / archived_segment / compressed）仍 push
        // 到 self.messages（全量保留，持久化/前端视图自管），但不计入 history_tokens，
        // 避免 build_for_request 误判超预算触发不必要裁剪。

        // 1) 直接 push 路径
        let mut mgr = ContextManager::new(cfg(100_000));
        let active_msg = ChatMessage::user("这条是 active 的");
        let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
        let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
        inactive_msg.status = Some("truncated".to_string());
        let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);

        mgr.push(active_msg);
        mgr.push(inactive_msg);

        // 全量保留（两条都在内存）
        assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
        assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
        // token 预算只含 active
        assert_eq!(
            mgr.history_tokens(),
            active_tokens,
            "history_tokens 应只含 active，多算了 {}（inactive 应被忽略）",
            mgr.history_tokens().saturating_sub(active_tokens)
        );
        assert!(
            inactive_tokens > 0,
            "前提：inactive 消息本身确有 token，否则无法证明它被排除"
        );

        // 2) restore_from_messages 路径（调 push，token 同步仅 active）
        let mut mgr2 = ContextManager::new(cfg(100_000));
        let mut a = ChatMessage::user("active 一");
        a.status = Some("active".to_string());
        let mut b = ChatMessage::user("archived 一");
        b.status = Some("archived_segment".to_string());
        let mut c = ChatMessage::user("compressed 一");
        c.status = Some("compressed".to_string());
        mgr2.restore_from_messages(vec![a, b, c]);

        assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
        // 只 active 一条计 token（b/c 是白名单外状态，is_active 返回 false）
        let only_active_tokens = TokenEstimator::default()
            .estimate_message(&ChatMessage::user("active 一"));
        assert_eq!(
            mgr2.history_tokens(),
            only_active_tokens,
            "restore 后 history_tokens 应只含 active 一条，archived/compressed 不计"
        );
    }

    #[test]
    fn empty_history_returns_empty() {
        let mgr = ContextManager::new(cfg(100_000));
        let (msgs, trimmed) = mgr.build_for_request(10);
        assert!(!trimmed);
        assert!(msgs.is_empty(), "空历史应返回空列表");
    }

    #[test]
    fn protect_zone_returns_full_when_untrimmable() {
        // 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
        let mut mgr = ContextManager::new(cfg(10));
        mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
        mgr.push(ChatMessage::user("第二条撑爆预算的长消息"));
        let (msgs, trimmed) = mgr.build_for_request(0);
        assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
        assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息");
    }

    #[test]
    fn system_over_budget_trims_to_protect_zone() {
        // system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
        let mut mgr = ContextManager::new(cfg(200));
        for i in 0..10 {
            mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
        }
        let (msgs, _trimmed) = mgr.build_for_request(195);
        assert!(
            msgs.len() <= PROTECT_COUNT,
            "system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
            PROTECT_COUNT,
            msgs.len()
        );
    }

    // ── F-15 阶段1 辅助方法单测 ──

    #[test]
    fn compress_old_messages_marks_compressed_and_returns_refs() {
        // F-15 §4.2/§4.3：compress_old_messages 把 [0, end) 内 active 消息标 compressed,
        // 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("旧消息1"));
        mgr.push(ChatMessage::assistant("旧回复1"));
        mgr.push(ChatMessage::user("新消息2"));
        let tokens_before = mgr.history_tokens();
        assert!(tokens_before > 0);

        let compressed = mgr.compress_old_messages(2);
        assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
        assert_eq!(compressed[0].content, "旧消息1");
        assert_eq!(compressed[1].content, "旧回复1");

        // status 已改 compressed
        assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
        assert_eq!(mgr.messages_mut()[1].message.status.as_deref(), Some("compressed"));
        // 保护区外(本例 index 2)仍 active
        assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");

        // 持久化全量不变
        assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");

        // token 已扣(剩第 3 条的)
        let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
        assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
    }

    #[test]
    fn compress_old_messages_is_idempotent() {
        // 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("a"));
        mgr.push(ChatMessage::user("b"));

        let first = mgr.compress_old_messages(2);
        assert_eq!(first.len(), 2);
        let tokens_after_first = mgr.history_tokens();

        let second = mgr.compress_old_messages(2);
        assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
        assert_eq!(
            mgr.history_tokens(),
            tokens_after_first,
            "二次压缩 history_tokens 不应再变(幂等)"
        );
    }

    #[test]
    fn compress_old_messages_clamps_oversized_end() {
        // compress_end 越界自动 clamp 到 len,不 panic。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("唯一"));
        let compressed = mgr.compress_old_messages(999);
        assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
        assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
    }

    #[test]
    fn compress_old_messages_skips_already_inactive() {
        // 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
        let mut mgr = ContextManager::new(cfg(100_000));
        let mut truncated = ChatMessage::user("被截断");
        truncated.status = Some("truncated".to_string());
        mgr.push(truncated);
        mgr.push(ChatMessage::user("active 一条"));
        let tokens_before = mgr.history_tokens();
        // truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条

        let compressed = mgr.compress_old_messages(2);
        assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
        assert_eq!(mgr.history_tokens(), 0);
        assert_eq!(
            mgr.history_tokens(),
            tokens_before.saturating_sub(tokens_before),
            "幂等扣除:truncated 本就没计 token,active 扣光"
        );
        // truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
        assert_eq!(
            mgr.messages_mut()[0].message.status.as_deref(),
            Some("truncated"),
            "已 truncated 不应被改写为 compressed"
        );
    }

    #[test]
    fn has_compressible_messages_respects_protect_zone() {
        let mut mgr = ContextManager::new(cfg(100_000));
        for i in 0..8 {
            mgr.push(ChatMessage::user(&format!("消息 {}", i)));
        }
        // protect_start=6 → [0,6) 内有 active → true
        assert!(mgr.has_compressible_messages(6));
        // protect_start=0 → 空范围 → false
        assert!(!mgr.has_compressible_messages(0));
        // 全部压缩后 → false
        mgr.compress_old_messages(6);
        assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
    }

    #[test]
    fn is_compressing_flag_round_trip() {
        // 标志位读写 round-trip;clear() 复位。
        let mut mgr = ContextManager::new(cfg(100_000));
        assert!(!mgr.is_compressing(), "默认 false");
        mgr.set_compressing(true);
        assert!(mgr.is_compressing(), "set true 后应读到 true");
        mgr.set_compressing(false);
        assert!(!mgr.is_compressing(), "set false 后复位");
        // clear 复位
        mgr.set_compressing(true);
        mgr.clear();
        assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
    }

    #[test]
    fn insert_at_adds_to_budget_when_active() {
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("a"));
        let tokens_before = mgr.history_tokens();

        // 插入 active system 消息 → 计入 token
        mgr.insert_at(0, ChatMessage::system("## 摘要"));
        assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
        assert_eq!(mgr.len(), 2);
        assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");

        // 插入 !active 消息 → 不计入 token
        let tokens_before_inactive = mgr.history_tokens();
        let mut inactive = ChatMessage::user("x");
        inactive.status = Some("truncated".to_string());
        mgr.insert_at(0, inactive);
        assert_eq!(
            mgr.history_tokens(),
            tokens_before_inactive,
            "!active 消息插入不应计 token"
        );
    }

    #[test]
    fn build_eviction_units_keeps_triplet_atomic_public() {
        // 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("前置"));
        mgr.push(ChatMessage::assistant_with_tools(
            "调",
            vec![ToolCall::new("c1", "fn", "{}")],
        ));
        mgr.push(ChatMessage::tool_result("c1", "结果"));
        mgr.push(ChatMessage::assistant("完成"));
        mgr.push(ChatMessage::user("后置"));
        // protect_start=5(全部纳入)
        let units = mgr.build_eviction_units(5);
        // 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
        // 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
        let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
        assert!(
            unit2.end >= 4,
            "三元组三件套应在同一淘汰单元, end={}",
            unit2.end
        );
    }

    // ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──

    #[test]
    fn last_assistant_message_id_returns_latest() {
        // 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("问1"));
        let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
        mgr.push(ChatMessage::user("问2"));
        let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
        // 末条 assistant id 应是 last(非 first)
        assert_eq!(
            mgr.last_assistant_message_id().as_deref(),
            Some(last.as_str()),
            "应取末条 assistant id, 而非首条"
        );
        assert_ne!(
            mgr.last_assistant_message_id().as_deref(),
            Some(first.as_str())
        );
    }

    #[test]
    fn last_user_message_id_returns_latest() {
        // 多条 user,反向扫描取末条 id(触发本轮检索的 user)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("问1"));
        mgr.push(ChatMessage::assistant("答1"));
        let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
        assert_eq!(
            mgr.last_user_message_id().as_deref(),
            Some(last_user.as_str())
        );
    }

    #[test]
    fn last_message_id_none_when_no_such_role() {
        // 无 assistant → None;无 user → None
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("只有 user"));
        assert!(
            mgr.last_assistant_message_id().is_none(),
            "无 assistant 消息应返 None"
        );

        let mut mgr2 = ContextManager::new(cfg(100_000));
        mgr2.push(ChatMessage::assistant("只有 assistant"));
        assert!(
            mgr2.last_user_message_id().is_none(),
            "无 user 消息应返 None"
        );
    }

    #[test]
    fn last_message_id_none_for_legacy_no_id() {
        // 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
        let mut mgr = ContextManager::new(cfg(100_000));
        let mut legacy = ChatMessage::assistant("老消息无 id");
        legacy.id = None;
        mgr.push(legacy);
        assert!(
            mgr.last_assistant_message_id().is_none(),
            "老消息无 id 应返 None(向前兼容)"
        );
    }

    /// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
    fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
        let id = message.id.clone();
        mgr.push(message);
        id.expect("新构造消息必有 id")
    }

    // ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──

    #[test]
    fn topic_marker_triggers_on_two_different_high_conf_topics() {
        // 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
        // "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("帮我重构这段代码"));
        // 此时只有一条 user,无 marker
        assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
        mgr.push(ChatMessage::user("创建项目并绑定目录"));
        let marker = mgr.take_topic_marker()
            .expect("双不同 topic 应置位 marker");
        assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
        assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
        // take 后清空(幂等)
        assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
    }

    #[test]
    fn topic_marker_not_triggered_on_same_topic() {
        // 同 topic(code/code)→ 不置位(非切换)。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码"));
        mgr.push(ChatMessage::user("再重构另一段代码"));
        assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
    }

    #[test]
    fn topic_marker_not_triggered_when_either_topic_none() {
        // 保守:任一 topic 为 None(低置信未标)不置位。
        // "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("今天的天气不错啊"));
        mgr.push(ChatMessage::user("帮我重构这段代码"));
        assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");

        // 反向:前一条高置信 + 后一条低置信
        let mut mgr2 = ContextManager::new(cfg(100_000));
        mgr2.push(ChatMessage::user("帮我重构这段代码"));
        mgr2.push(ChatMessage::user("嗯嗯好的"));
        assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
    }

    #[test]
    fn topic_marker_not_polluting_compression_or_trim() {
        // topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
        // topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
        let mut mgr = ContextManager::new(cfg(80));
        for i in 0..10 {
            mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
        }
        mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
        let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
        // 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
        let (msgs, trimmed) = mgr.build_for_request(0);
        assert!(trimmed);
        assert!(!msgs.is_empty());
    }

    #[test]
    fn topic_field_default_none_in_constructors() {
        // TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
        // insert_at 始终 None——insert_at 不做主题推断,保守)。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.insert_at(0, ChatMessage::system("摘要"));
        // insert_at 的 system 消息 topic=None(role 非 User)
        assert_eq!(mgr.messages_mut()[0].topic, None);
    }

    // ===== 苁刻测:主题切换检测对抗/边界/链式 =====

    #[test]
    fn topic_marker_chained_three_switches_each_triggers() {
        // 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
        // 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
        let mut mgr = ContextManager::new(cfg(100_000));

        // 1. Code
        mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
        assert!(mgr.take_topic_marker().is_none(), "首条不触发");

        // 2. Code → Project(切换)
        mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
        let m1 = mgr.take_topic_marker().expect("code→project 应触发");
        assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
        assert!(mgr.take_topic_marker().is_none(), "take 后清空");

        // 3. Project → Task(切换)
        mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
        let m2 = mgr.take_topic_marker().expect("project→task 应触发");
        assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);

        // 4. Task → Http(切换,证链式不因中间穿插断)
        mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
        let m3 = mgr.take_topic_marker().expect("task→http 应触发");
        assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
    }

    #[test]
    fn topic_marker_low_confidence_chain_never_triggers() {
        // 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
        assert!(mgr.take_topic_marker().is_none());
        mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
        assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
        // 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
        mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
        assert!(
            mgr.take_topic_marker().is_none(),
            "前一条 topic None,即使本条高置信也不触发(双高置信约束)"
        );
    }

    #[test]
    fn topic_marker_high_then_low_confidence_never_triggers() {
        // 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
        assert!(mgr.take_topic_marker().is_none());
        mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
        assert!(
            mgr.take_topic_marker().is_none(),
            "后一条 topic None,即使前一条高置信也不触发"
        );
    }

    #[test]
    fn topic_marker_switch_back_to_same_after_different_not_triggered() {
        // 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
        // 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码")); // code
        mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
        let _ = mgr.take_topic_marker();
        mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
        let m = mgr.take_topic_marker().expect("project→code 切回应触发");
        assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
    }

    #[test]
    fn topic_field_survives_compress_old_messages() {
        // 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
        // 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
        mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
        mgr.push(ChatMessage::assistant("ok")); // idx 2

        // 压缩 [0,2):idx 0/1 标 compressed
        let compressed = mgr.compress_old_messages(2);
        assert_eq!(compressed.len(), 2);
        // topic 字段保留(compress 不触碰)
        assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
        assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
        // status 改为 compressed
        assert_eq!(mgr.messages_mut()[0].message.status.as_deref(), Some("compressed"));
    }

    #[test]
    fn topic_marker_system_message_in_send_view_under_adequate_budget() {
        // 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
        // build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
        // 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
        // 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
        // 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码")); // code
        mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
        let marker = mgr.take_topic_marker().expect("应触发 marker");
        assert!(marker.contains("code") && marker.contains("project"));

        // 调用方据 marker insert system 标记(agentic loop 实际行为)
        mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
        let (msgs, trimmed) = mgr.build_for_request(0);
        assert!(!trimmed, "充足预算不应裁剪");
        assert!(
            msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
            "marker system 应进发送视图: {:?}",
            msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
        );
    }

    #[test]
    fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
        // 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码")); // code
        assert!(mgr.take_topic_marker().is_none());
        // assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
        mgr.push(ChatMessage::assistant("编译 error 出现了"));
        assert!(
            mgr.take_topic_marker().is_none(),
            "assistant 消息不参与主题推断,不触发 marker"
        );
        // 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
        mgr.push(ChatMessage::user("继续重构"));
        assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
        // 此时末条 user 是 code,切到 project 应触发
        mgr.push(ChatMessage::user("创建项目并绑定目录"));
        assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
    }

    #[test]
    fn topic_marker_take_is_idempotent_and_clears() {
        // 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
        let mut mgr = ContextManager::new(cfg(100_000));
        mgr.push(ChatMessage::user("重构这段代码"));
        mgr.push(ChatMessage::user("创建项目并绑定目录"));
        let _ = mgr.take_topic_marker().expect("应有 marker");
        // 第二次 take 必 None
        assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
    }
}
