//! conversation 域 IPC — 对话 CRUD / 切换 / 归档 / 置顶 / 导出
//!
//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 conversation 域 8 个 IPC。
//! 其他域(provider/skills/config)仍留在 `commands/mod.rs`,后续批拆分。
//!
//! re-export:由 `commands/mod.rs` 经 `pub use self::conversation::*;` 拉到 `commands::*`,
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
//! - create:不杀旧 loop(决策 e 真并发上线),仅切 active + 建独立 per_conv
//! - switch:删 readonly 分支(决策 e per_conv 隔离)

use std::sync::atomic::Ordering;

use tauri::{AppHandle, State};

use df_ai::provider::{ChatMessage, MessageRole, MessageStatus};
use df_storage::models::AiMessageRecord;
use df_types::types::new_id;

use crate::state::AppState;
use crate::commands::{err_str, now_millis};

// conversation.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::conversation::save_conversation;
use super::super::prompt::get_active_provider;
use super::super::title::spawn_ensure_title;

// ============================================================
// ChatMessage ↔ AiMessageRecord 映射(F-260619-03 消息拆分存储)
// ============================================================
//
// 读路径(批次 A)核心:`record_to_message` 把 AiMessageRecord(单行)还原成 ChatMessage。
// 写路径(批次 B)备好但本批次不调:`message_to_record`(save_conversation 写 ai_messages 用)。
//
// round-trip 严格性:
// - role 枚举 ↔ 小写字符串双向(serialize rename_all=lowercase)
// - parts / tool_calls:JSON 字符串 ↔ Option<Vec> 双向反序列化
// - status:None ↔ "active"(对齐 sanitize is_active 归一化)
// - id:record.id 必非空(主键),还原为 ChatMessage.id=Some;
//   反向 ChatMessage.id=None 时兜底 `msg_{conv}_{seq}`(save 写路径用,本批次不触发)
//
// 映射工具放 src-tauri(非 df-ai-core):df-ai-core 不依赖 df-storage(防循环依赖,
// df-storage 的迁移层注释明确不依赖 df-ai-core),只能在上层 src-tauri 桥接两者类型。

/// role 字符串 → MessageRole 枚举(小写,对齐 serialize rename_all="lowercase")。
///
/// 未知值兜底 User(防御性,实际 DB role 列由 message_to_record 写入,值域受控)。
fn role_from_str(s: &str) -> MessageRole {
    match s {
        "system" => MessageRole::System,
        "user" => MessageRole::User,
        "assistant" => MessageRole::Assistant,
        "tool" => MessageRole::Tool,
        _ => MessageRole::User,
    }
}

/// MessageRole 枚举 → role 字符串(小写)。
fn role_to_str(r: &MessageRole) -> &'static str {
    match r {
        MessageRole::System => "system",
        MessageRole::User => "user",
        MessageRole::Assistant => "assistant",
        MessageRole::Tool => "tool",
    }
}

/// AiMessageRecord → ChatMessage(读路径核心:list_by_conversation 还原消息)。
///
/// - role 字符串 → 枚举
/// - parts/tool_calls:JSON 字符串 → Vec 反序列化(空/解析失败 → None)
/// - status:"active" → None(对齐 sanitize is_active 归一化),其他原样保留
/// - id:record.id 必非空(主键)→ Some
pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
    let parts = rec
        .parts
        .as_deref()
        .filter(|s| !s.is_empty())
        .and_then(|s| serde_json::from_str(s).ok());
    let tool_calls = rec
        .tool_calls
        .as_deref()
        .filter(|s| !s.is_empty())
        .and_then(|s| serde_json::from_str(s).ok());
    // status "active" → None(归一化,对齐 ChatMessage 默认语义 None=正常可见)
    let status = if rec.status == "active" || rec.status.is_empty() {
        None
    } else {
        MessageStatus::from_db_str(&rec.status)
    };
    ChatMessage {
        id: Some(rec.id.clone()),
        role: role_from_str(&rec.role),
        content: rec.content.clone(),
        parts,
        tool_call_id: rec.tool_call_id.clone(),
        tool_calls,
        model: rec.model.clone(),
        status,
        reasoning_content: rec.reasoning_content.clone(),
        timestamp: rec.timestamp,
        prompt_tokens: rec.prompt_tokens,
        completion_tokens: rec.completion_tokens,
    }
}

/// ChatMessage → AiMessageRecord(写路径:save_conversation 全量重写 ai_messages 用)。
///
/// 调用方传入 conversation_id + seq(对话内序号,按索引)。
/// - id None 兜底:`msg_{conv}_{seq}`(老数据无 id 时,落库时补主键)
/// - role 枚举 → 小写字符串
/// - parts/tool_calls → JSON 字符串
/// - status None → "active"(归一化,对齐 record 默认列值)
/// - created_at 调用方传入(save_conversation 用对话 created_at 或 now)
pub fn message_to_record(
    msg: &ChatMessage,
    conversation_id: &str,
    seq: i64,
    created_at: &str,
) -> AiMessageRecord {
    let id = msg.id.clone().unwrap_or_else(|| format!("msg_{}_{}", conversation_id, seq));
    let parts = msg
        .parts
        .as_ref()
        .map(|p| serde_json::to_string(p).unwrap_or_default());
    let tool_calls = msg
        .tool_calls
        .as_ref()
        .map(|t| serde_json::to_string(t).unwrap_or_default());
    let status = msg.status
        .as_ref()
        .map(|s| s.as_db_str().to_string())
        .unwrap_or_else(|| "active".to_string());
    AiMessageRecord {
        id,
        conversation_id: conversation_id.to_string(),
        seq,
        role: role_to_str(&msg.role).to_string(),
        content: msg.content.clone(),
        parts,
        tool_call_id: msg.tool_call_id.clone(),
        tool_calls,
        model: msg.model.clone(),
        status,
        reasoning_content: msg.reasoning_content.clone(),
        timestamp: msg.timestamp,
        created_at: created_at.to_string(),
        prompt_tokens: msg.prompt_tokens,
        completion_tokens: msg.completion_tokens,
    }
}

// ============================================================
// 对话管理
// ============================================================

/// 创建新对话
#[tauri::command]
pub async fn ai_conversation_create(
    _app: AppHandle,
    state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
    // 懒创建:仅生成 id 存内存,不落库;避免新建后不发消息产生空记录。
    // 首条消息发送后由 save_conversation upsert 写入。
    let id = new_id();
    let now = now_millis();

    // F-260616-09 B 批4(决策 e 真并发上线):新建会话**不杀旧 loop**。
    //   旧实现(B-260615-10)生成中强制结束旧 conv 的 loop + clear 其 per_conv,在真并发下会误杀
    //   后台跑着的 conv。决策 e:旧 conv 的 per_conv 完整保留(后台 loop 继续跑自己的 conv),
    //   仅切换 active_conversation_id 到新会话 + 为新会话建独立 per_conv。
    //
    // B-260618-06 保留:切换 active 前先持久化旧 active conv(防其内存 messages 因后续操作丢失)。
    //   注意:旧 conv 若有后台 loop 在跑,loop 自身的 save_conversation 也会持久化,此处 save 幂等。
    let old_conv = {
        let session = state.ai_session.lock().await;
        session.active_conversation_id.clone()
    };
    let old_has_msgs = {
        let session = state.ai_session.lock().await;
        old_conv.as_deref()
            .and_then(|oc| session.conv_read(oc))
            .map(|c| !c.messages.is_empty())
            .unwrap_or(false)
    };
    if let Some(ref oc) = old_conv {
        if old_has_msgs {
            save_conversation(&state.ai_session, &state.db, oc.as_str(), None, None, true).await;
        }
    }

    let mut session = state.ai_session.lock().await;
    session.active_conversation_id = Some(id.clone());
    session.active_conv_created_at = Some(now);
    // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
    // "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
    drop(session);
    state.clear_session_allowed_dirs().await;
    let mut session = state.ai_session.lock().await;
    // 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
    // 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
    // (switch 路径会 retain 清目标 conv 的,create 不动他人)。
    let _ = session.conv(&id); // 惰性建立空 PerConvState(字段全默认值)

    Ok(serde_json::json!({ "id": id }))
}

/// 列出对话（仅摘要，不含 messages 全文）
///
/// limit 默认 50 防数据膨胀；include_archived 默认 false（归档对话默认隐藏）。
#[tauri::command]
pub async fn ai_conversation_list(
    state: State<'_, AppState>,
    limit: Option<usize>,
    include_archived: Option<bool>,
) -> Result<Vec<serde_json::Value>, String> {
    let limit = limit.unwrap_or(50);
    let include_archived = include_archived.unwrap_or(false);
    let records = state.ai_conversations.list_all().await.map_err(err_str)?;
    // list_all 已按 created_at DESC(最新在前）；默认排除归档 + 截断 limit
    let summaries: Vec<serde_json::Value> = records.iter()
        .filter(|r| include_archived || !r.archived)
        .take(limit)
        .map(|r| {
            // 修复 models 字段类型 bug:r.models 是 JSON 字符串,前端期望数组
            let models: Vec<String> = r.models.as_deref()
                .and_then(|s| serde_json::from_str(s).ok())
                .unwrap_or_default();
            let pinned_goals: Vec<String> = r.pinned_goals
                .as_deref()
                .and_then(|s| serde_json::from_str::<Vec<crate::commands::ai::GoalEntry>>(s).ok())
                .map(|entries| entries.into_iter().map(|e| e.text).collect())
                .unwrap_or_default();
            serde_json::json!({
                "id": r.id,
                "title": r.title,
                "provider_id": r.provider_id,
                "model": r.model,
                "models": models,
                "pinned_goals": pinned_goals,
                "archived": r.archived,
                "pinned": r.pinned,
                "prompt_tokens": r.prompt_tokens,
                "completion_tokens": r.completion_tokens,
                "created_at": r.created_at,
                "updated_at": r.updated_at,
            })
        }).collect();
    Ok(summaries)
}

/// 切换到指定对话（从 DB 加载 messages 到内存 + 返回 messages 给前端）
#[tauri::command]
pub async fn ai_conversation_switch(
    state: State<'_, AppState>,
    app_handle: AppHandle,
    conversation_id: String,
) -> Result<serde_json::Value, String> {
    let record = state.ai_conversations.get_by_id(&conversation_id).await
        .map_err(err_str)?
        .ok_or_else(|| format!("对话不存在: {}", conversation_id))?;

    // F-260619-03 消息拆分存储(批次 A 读路径):优先从 ai_messages 表加载(每条消息一行),
    // 替代旧 ai_conversations.messages 整对话 JSON 反序列化。映射 Vec<AiMessageRecord>
    // → Vec<ChatMessage> → restore_from_messages。
    //
    // fallback 兜底:ai_messages 表为空(返空 Vec)但旧 messages JSON 列非空 `[]`
    // (老库未迁移 / 坏数据 / 批次 B 写路径尚未上线时的新对话)→ 回退读 messages JSON + warn。
    // 双向兼容:批次 B 上线后写双轨,读永远先走 ai_messages;迁移未跑的老对话走 fallback。
    let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
        .map_err(err_str)?;
    let messages: Vec<ChatMessage> = if !all_records.is_empty() {
        all_records.iter().map(record_to_message).collect()
    } else {
        // 表空 → fallback 旧 messages JSON 列(若也空则空 Vec,空对话合法)
        let has_legacy = record.messages != "[]" && !record.messages.is_empty();
        if has_legacy {
            tracing::warn!(
                "ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={}): \
                 老库未迁移或批次 B 写路径未上线",
                conversation_id
            );
            serde_json::from_str(&record.messages)
                .map_err(|e| format!("解析消息失败: {}", e))?
        } else {
            Vec::new()
        }
    };

    // 后端 per_conv 存全量消息(LLM 上下文用),前端只渲染最近 N 条(分页懒加载,治长对话卡顿)。
    // PAGE_SIZE:首屏渲染条数,超出的历史消息由前端滚顶加载更多(ai_conversation_load_more IPC)。
    const PAGE_SIZE: usize = 50;
    let total_count = messages.len();
    let render_messages: Vec<ChatMessage> = if total_count > PAGE_SIZE {
        messages[total_count - PAGE_SIZE..].to_vec()
    } else {
        messages.clone()
    };
    let messages_json = serde_json::to_string(&render_messages)
        .map_err(|e| format!("序列化消息失败: {}", e))?;
    let has_more = total_count > PAGE_SIZE;
    let earliest_seq = if !all_records.is_empty() {
        // 返回首屏最早消息的 seq,前端滚顶加载时传此值作游标
        let page_start = total_count.saturating_sub(PAGE_SIZE);
        Some(all_records[page_start].seq)
    } else {
        None
    };
    let title = record.title.clone();
    // B-260617-17 续:历史会话 title 空(显"新对话")→ 切入后触发重新生成(用户诉求)。
    // 含 "新对话" 占位(Some 但未生成):title.rs ensure :40 同步排除"新对话"占位不跳过,
    // 否则 title 落"新对话"(LLM 返空兜底/默认)后 is_some()=true 永远跳过 → 标题卡死。
    let need_title_regen = record.title.is_none() || record.title.as_deref() == Some("新对话");

    let mut session = state.ai_session.lock().await;
    // F-260616-09 B 批4(决策 e 真并发上线):删除 readonly 分支。
    //   旧实现:active conv 生成中 → 只读切换(不改 session 状态),因单例 messages 会被新 conv 覆盖。
    //   决策 e:per_conv 已隔离,切走直接改 active_conversation_id + 新 conv per_conv 惰性建/从 DB reload。
    //   **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
    //   (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
    session.active_conversation_id = Some(conversation_id.clone());
    // F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
    // "当前活跃会话的临时授权",不跨会话继承)。
    drop(session);
    state.clear_session_allowed_dirs().await;
    let mut session = state.ai_session.lock().await;
    // 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否
    //   已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop
    //   仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。
    let already_live = state.conv_states.is_active(&conversation_id);
    if !already_live {
        // 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
        // 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
        let conv = session.conv(&conversation_id);
        conv.messages.restore_from_messages(messages);
        conv.model_override = None;
        conv.session_trust.clear();
        conv.agent_language = None;
        conv.iteration_used = 0;
        conv.stop_flag.store(false, Ordering::SeqCst);
        // 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
        // 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
        let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
            let raw: Option<serde_json::Value> = record.pinned_goals
                .as_deref()
                .and_then(|s| serde_json::from_str(s).ok());
            match raw {
                Some(serde_json::Value::Array(arr)) => {
                    arr.iter().filter_map(|v| {
                        // 新格式: {"text":"...","status":"active"}
                        if let Some(obj) = v.as_object() {
                            let text = obj.get("text")?.as_str()?.to_string();
                            let status = obj.get("status").and_then(|s| s.as_str())
                                .map(|s| if s == "completed" {
                                    crate::commands::ai::GoalStatus::Completed
                                } else {
                                    crate::commands::ai::GoalStatus::Active
                                }).unwrap_or(crate::commands::ai::GoalStatus::Active);
                            Some(crate::commands::ai::GoalEntry { text, status })
                        } else {
                            // 旧格式: "text" → 转 active
                            let text = v.as_str()?.to_string();
                            Some(crate::commands::ai::GoalEntry::new(text))
                        }
                    }).collect()
                }
                _ => vec![],
            }
        };
        conv.pinned_goals = pinned_goals;
    }
    // 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
    // 重启恢复链路:restore_pending_approvals(init 重建) → switchConversation(此处不清目标对话的)
    // → ai_pending_tool_calls 查询 → ai_approve 落库)。
    // SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):切走目标 conv 前,
    // 先把其 pending 审批占位 tool_result 终态化为"会话已切换",防占位残留下次发送喂给 LLM。
    // 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
    super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已切换");
    // 阶段3a 单真相源合并:单表 retain(kind 不区分,清本 conv 保留其他)。
    session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));

    // 从 DB 恢复本对话的挂起审批快照(重启/切回时,之前 save_conversation 持久化的 pending_approvals)
    // 反序列化后插入 session.pending_approvals,使 ai_pending_tool_calls 可查回、审批卡片恢复。
    //
    // 陈旧过滤根因修复(2026-08,治"审批待办关不了"):
    // 对话 pending_approvals JSON 列与 ai_tool_executions 审计表是**两份独立持久化**——
    // 审批处理时(ai_approve/ai_authorize_dir)若 remove 返 None(内存与 JSON 列不同步 / 恢复竞态),
    // 走 None 分支不 save → JSON 列不被修剪 → 残留陈旧条目(completed/rejected/failed 的)。
    // 切回此对话时本段会反复 restore 这些已落定条目,前端 ApprovalPopup/ToolCard 永久显挂起卡,
    // 用户拒绝又走 None 分支死循环。修复:恢复前用审计表核对,仅恢复审计 status='pending' 的条目,
    // 跳过已落定的陈旧条目(并即时修剪 JSON 列,防下次再恢复陈旧)。
    let restored_pending: std::collections::HashMap<String, crate::commands::ai::PendingApproval> =
        if let Some(pending_json) = &record.pending_approvals {
            if pending_json != "{}" && !pending_json.is_empty() {
                match serde_json::from_str::<
                    std::collections::HashMap<String, crate::commands::ai::PendingApproval>
                >(pending_json) {
                    Ok(m) => m,
                    Err(_) => {
                        tracing::warn!(
                            "切换对话 {} 反序列化 pending_approvals 失败,原始内容前 200 字: {:?}",
                            conversation_id, &pending_json.chars().take(200).collect::<String>()
                        );
                        std::collections::HashMap::new()
                    }
                }
            } else {
                std::collections::HashMap::new()
            }
        } else {
            std::collections::HashMap::new()
        };
    // 仅恢复本 conv 的条目(已按 conv_id 过滤写入,DB 快照天然是本 conv 的)
    session.pending_approvals.extend(restored_pending.clone());
    if !restored_pending.is_empty() {
        tracing::info!(
            "切换对话 {} 恢复 {} 条挂起审批(来自 DB pending_approvals 列,待审计核对)",
            conversation_id, restored_pending.len()
        );
    }
    // 释放 session lock 再做 async provider 查询 + spawn(避免持锁 await DB)
    drop(session);

    // 审计核对 + 即时修剪:对每条恢复的 tool_call_id 查 ai_tool_executions,
    // 审计已落定(completed/rejected/failed/interrupted/skipped_retry)→ 视为陈旧,从内存移除,
    // 并累积"存活"集合用于回写修剪后的 JSON 列(根治:下次切回不再恢复陈旧)。
    // 审计查询失败(DB 故障)保守信任内存已 extend 的条目(不因 DB 故障误删用户真挂起审批)。
    // 仅当确实有恢复条目时才跑核对(空 HashMap 跳过,无开销)。
    if !restored_pending.is_empty() {
        let mut stale_ids: Vec<String> = Vec::new();
        for tc_id in restored_pending.keys() {
            match state.ai_tool_executions.find_by_tool_call_id(tc_id).await {
                Ok(Some(rec)) => {
                    // pending = 真挂起(首次或重启恢复未决策),保留。其余落定态 = 陈旧。
                    if rec.status != "pending" {
                        stale_ids.push(tc_id.clone());
                    }
                }
                Ok(None) => {
                    // 审计无记录但 JSON 列有 —— 数据异常(老库 / 手改 DB)。保守保留(不删),
                    // 信任前端审批流会处理;若确属脏数据,用户拒绝后经修复1的 emit 路径收敛。
                }
                Err(e) => {
                    tracing::warn!(
                        "切换对话 {} 审计核对 tool_call_id={} 失败(保守保留该条): {}",
                        conversation_id, tc_id, e
                    );
                }
            }
        }
        if !stale_ids.is_empty() {
            // 从内存 pending_approvals 移除陈旧条目(短 lock retain)
            {
                let mut session = state.ai_session.lock().await;
                let to_remove: Vec<String> = stale_ids.clone();
                session.pending_approvals.retain(|k, _| !to_remove.contains(k));
            }
            tracing::info!(
                "切换对话 {} 审计核对剔除 {} 条陈旧审批(已落定,从 pending_approvals 内存+JSON 列移除)",
                conversation_id, stale_ids.len()
            );
            // 即时修剪 JSON 列:回写仅含存活条目的快照,根治下次切回再恢复陈旧。
            // 取存活集合(原恢复集合减陈旧)的当前内存态重新序列化(若期间被其他 IPC 改动,以内存为准)。
            let surviving_json = {
                let session = state.ai_session.lock().await;
                let conv_pending: std::collections::HashMap<&String, &crate::commands::ai::PendingApproval> = session
                    .pending_approvals
                    .iter()
                    .filter(|(_, a)| a.conversation_id.as_deref() == Some(&conversation_id))
                    .collect();
                serde_json::to_string(&conv_pending).unwrap_or_else(|_| "{}".to_string())
            };
            // update_full 需读现 record(防覆盖期间其他字段被改),仅改 pending_approvals 列。
            match state.ai_conversations.get_by_id(&conversation_id).await {
                Ok(Some(mut rec)) => {
                    rec.pending_approvals = Some(surviving_json);
                    if let Err(e) = state.ai_conversations.update_full(&rec).await {
                        tracing::warn!(
                            "切换对话 {} 修剪 pending_approvals JSON 列失败(非阻断,下次核对再修): {}",
                            conversation_id, e
                        );
                    }
                }
                Ok(None) => {
                    tracing::warn!(
                        "切换对话 {} 修剪 pending_approvals JSON 列时对话已不存在(跳过)",
                        conversation_id
                    );
                }
                Err(e) => {
                    tracing::warn!(
                        "切换对话 {} 修剪 pending_approvals JSON 列读对话失败(非阻断): {}",
                        conversation_id, e
                    );
                }
            }
        }
    }

    // 历史会话切入且 title 空 → 后台触发标题重新生成(extract 即时兜底 + LLM 优化)。
    // ensure 内 get_by_id title Some 判断防重复;无可用 provider 静默跳过(增强不阻塞切换)。
    if need_title_regen {
        match get_active_provider(&state).await {
            Ok(provider_config) => {
                spawn_ensure_title(
                    &provider_config,
                    &state.db,
                    &conversation_id,
                    &app_handle,
                    &state.ai_session,
                    &state.llm_concurrency,
                );
            }
            Err(e) => tracing::warn!(
                "切入历史会话触发标题重生成跳过(无可用 provider, conv_id={}): {}",
                conversation_id, e
            ),
        }
    }

    Ok(serde_json::json!({
        "id": record.id,
        "title": title,
        "messages": messages_json,
        "has_more": has_more,
        "earliest_seq": earliest_seq,
    }))
}

/// 加载更多历史消息(分页懒加载,滚顶触发)。
///
/// 前端 switchConversation 首次拿最近 50 条 + has_more=true + earliest_seq。
/// 滚顶时传 earliest_seq 调本命令,返回更早的 50 条 + 新 has_more + 新 earliest_seq。
/// per_conv 内存不受影响(后端始终持全量消息供 LLM 上下文用,分页仅影响前端渲染)。
#[tauri::command]
pub async fn ai_conversation_load_more(
    state: State<'_, AppState>,
    conversation_id: String,
    before_seq: i64,
) -> Result<serde_json::Value, String> {
    const PAGE_SIZE: usize = 50;
    let records = state.ai_messages.list_recent(&conversation_id, PAGE_SIZE, Some(before_seq))
        .await
        .map_err(err_str)?;
    let has_more = records.len() == PAGE_SIZE;
    let new_earliest_seq = records.first().map(|r| r.seq);
    let messages: Vec<ChatMessage> = records.iter().map(record_to_message).collect();
    let messages_json = serde_json::to_string(&messages)
        .map_err(|e| format!("序列化消息失败: {}", e))?;
    Ok(serde_json::json!({
        "messages": messages_json,
        "has_more": has_more,
        "earliest_seq": new_earliest_seq,
    }))
}

/// 删除对话
#[tauri::command]
pub async fn ai_conversation_delete(
    state: State<'_, AppState>,
    conversation_id: String,
) -> Result<(), String> {
    state.ai_conversations.delete(&conversation_id).await.map_err(err_str)?;

    let mut session = state.ai_session.lock().await;
    // 删除任意对话(含非活跃)都应清理其积压审批:挂起审批是会话级 HashMap,
    // 非活跃对话的恢复审批(recovered,conversation_id 指向被删对话)若不 retain 清理,
    // 会永久残留死审批条目。对齐 ai_conversation_switch 的 retain 口径(仅清目标对话,保留其他)。
    // SW-260618-02(7-ipc-approval 配套 stop/clear/force_send 终态化):删除目标 conv 前,
    // 先把其 pending 审批占位 tool_result 终态化为"会话已删除",防占位残留下次发送喂给 LLM。
    // 须在 retain 前(遍历即将被丢弃的条目)——chat.rs:44 硬契约,顺序不能反。
    super::chat::finalize_pending_placeholders(&mut *session, &conversation_id, "会话已删除");
    // 阶段3a 单真相源合并:单表 retain(kind 不区分)。
    session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(&conversation_id));
    // F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
    //   旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
    session.per_conv.remove(&conversation_id);
    // B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留
    // Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。
    state.conv_states.remove(&conversation_id);
    let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
    if was_active {
        session.active_conversation_id = None;
    }
    // F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
    //   conv 已删=LlmConcurrency 该条目不再被 acquire(无 conv 则无 loop/标题/提炼/压缩针对它)。
    //   已持 permit 不受影响(permit 绑旧 Arc,随 Drop 释放),仅阻止新条目累积。
    //   时机:conv 删除即清理(比"loop 结束 + 无 pending"更确定——conv 删了必无 pending,
    //   上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
    drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
    state.llm_concurrency.release_conv(&conversation_id).await;
    // F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
    // "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
    if was_active {
        state.clear_session_allowed_dirs().await;
    }
    Ok(())
}

/// 重命名对话标题
#[tauri::command]
pub async fn ai_conversation_rename(
    state: State<'_, AppState>,
    conversation_id: String,
    title: String,
) -> Result<(), String> {
    let title = title.trim().to_string();
    if title.is_empty() {
        return Err("标题不能为空".to_string());
    }
    state.ai_conversations.update_field(&conversation_id, "title", &title)
        .await.map_err(err_str)?;
    Ok(())
}

/// 归档/取消归档对话(归档后在侧栏折叠分组展示)
#[tauri::command]
pub async fn ai_conversation_archive(
    state: State<'_, AppState>,
    conversation_id: String,
    archived: bool,
) -> Result<(), String> {
    state.ai_conversations
        .set_archived(&conversation_id, archived)
        .await
        .map_err(err_str)?;
    Ok(())
}

/// 置顶/取消置顶对话(UX-17:对话置顶)
///
/// 置顶后侧栏排序置前(前端按 pinned DESC, updated_at DESC)。
/// 纯元数据标记(同归档),不改 updated_at(保持相对时间不变)。
#[tauri::command]
pub async fn ai_conversation_set_pinned(
    state: State<'_, AppState>,
    conversation_id: String,
    pinned: bool,
) -> Result<(), String> {
    state.ai_conversations
        .set_pinned(&conversation_id, pinned)
        .await
        .map_err(err_str)?;
    Ok(())
}

/// 更新对话目标钉扎列表(G1 目标钉扎持久化:对话透明化 L1)
///
/// 接收前端 UI 增删后的目标列表,持久化到 DB 并同步更新内存 per_conv 状态。
/// goals 是完整替换(非增量),前端增/删后传全量 new Vec。
#[tauri::command]
pub async fn ai_update_conversation_goals(
    state: State<'_, AppState>,
    conversation_id: String,
    goals: Vec<String>,
) -> Result<(), String> {
    // 前端传回的 string[] 转为 GoalEntry(假设全部 active)
    let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
        .map(|g| crate::commands::ai::GoalEntry::new(g))
        .collect();
    let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
    // 写 DB
    state.ai_conversations
        .update_field(&conversation_id, "pinned_goals", &goals_json)
        .await
        .map_err(err_str)?;
    // 同步内存 per_conv(若已加载)
    let mut session = state.ai_session.lock().await;
    if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
        conv.pinned_goals = goal_entries;
    }
    Ok(())
}

/// 导出对话为指定格式(UX-18:对话导出)
///
/// - 优先落库 messages(完整历史,与 switch 一致),内存 session 不读(可能被切走/未落库)
/// - markdown: `## 用户` / `## 助手` 交替标题 + content 原样输出
///   (content 内已有的三反引号代码块围栏原样保留,不做二次转义)
/// - json: 完整 messages 数组(serde 序列化 ChatMessage 列表)
/// - txt: `user: ...` / `assistant: ...` 纯文本拼接,system/tool 附注
///
/// 最小化:仅渲染 user/assistant 文本;tool_calls/tool_results 略过(导出给人看的对话)。
/// 空对话(无 messages)→ 空字符串(对应格式空体)。
#[tauri::command]
pub async fn ai_conversation_export(
    state: State<'_, AppState>,
    conversation_id: String,
    format: String,
) -> Result<String, String> {
    // format 校验:非法值 Err(不 panic),防止 format! 注入或未处理分支
    let fmt = format.as_str();
    if !matches!(fmt, "markdown" | "json" | "txt") {
        return Err(format!("不支持的导出格式: {}", format));
    }

    // 取落库对话(完整历史)
    let record = state.ai_conversations.get_by_id(&conversation_id).await
        .map_err(err_str)?
        .ok_or_else(|| format!("对话不存在: {}", conversation_id))?;

    // F-260619-03 批次 A:导出读路径同样切读 ai_messages 表(与 switch 一致),
    // fallback 旧 messages JSON 列(老库未迁移/坏数据)。空对话 → 空 messages。
    let records = state.ai_messages.list_by_conversation(&conversation_id).await
        .map_err(err_str)?;
    let messages: Vec<ChatMessage> = if !records.is_empty() {
        records.iter().map(record_to_message).collect()
    } else if record.messages != "[]" && !record.messages.is_empty() {
        tracing::warn!(
            "导出:ai_messages 表为空但旧 messages JSON 非空,回退读 JSON 列(conv_id={})",
            conversation_id
        );
        serde_json::from_str(&record.messages)
            .map_err(|e| format!("解析消息失败: {}", e))?
    } else {
        Vec::new()
    };

    let body = match fmt {
        "markdown" => {
            // user/assistant 各起一节标题;system/tool 跳过(导出是给人看的对话流)
            let mut parts: Vec<String> = Vec::new();
            for m in &messages {
                let title = match m.role {
                    df_ai::provider::MessageRole::User => Some("## 用户"),
                    df_ai::provider::MessageRole::Assistant => Some("## 助手"),
                    df_ai::provider::MessageRole::System => Some("## 系统"),
                    df_ai::provider::MessageRole::Tool => Some("## 工具结果"),
                };
                if let Some(t) = title {
                    // content 原样输出,内部三反引号围栏保留(Markdown 嵌套代码块,渲染器原生支持)
                    parts.push(format!("{}\n\n{}", t, m.content));
                }
            }
            parts.join("\n\n")
        }
        "json" => {
            serde_json::to_string_pretty(&messages)
                .map_err(|e| format!("序列化失败: {}", e))?
        }
        "txt" => {
            let mut parts: Vec<String> = Vec::new();
            for m in &messages {
                let role_name = match m.role {
                    df_ai::provider::MessageRole::System => "system",
                    df_ai::provider::MessageRole::User => "user",
                    df_ai::provider::MessageRole::Assistant => "assistant",
                    df_ai::provider::MessageRole::Tool => "tool",
                };
                parts.push(format!("{}: {}", role_name, m.content));
            }
            parts.join("\n")
        }
        // 上方 matches! 已校验,理论不可达
        _ => return Err(format!("不支持的导出格式: {}", format)),
    };

    Ok(body)
}

// ============================================================
// 测试:ChatMessage ↔ AiMessageRecord 映射 round-trip
// ============================================================
#[cfg(test)]
mod tests {
    use super::*;
    use df_ai::provider::{ChatMessage, ContentPart, MessageRole, MessageStatus, ToolCall, ToolCallFunction};

    fn base_msg() -> ChatMessage {
        ChatMessage {
            id: Some("msg_test_1".into()),
            role: MessageRole::User,
            content: "你好".into(),
            parts: None,
            tool_call_id: None,
            tool_calls: None,
            model: None,
            status: None,
            reasoning_content: None,
            timestamp: Some(1700000000000),
        }
    }

    /// 纯文本消息 round-trip:status None ↔ "active" 归一化
    #[test]
    fn roundtrip_plain_text_status_none() {
        let msg = base_msg();
        let rec = message_to_record(&msg, "conv_x", 0, "2026-01-01T00:00:00Z");
        assert_eq!(rec.id, "msg_test_1");
        assert_eq!(rec.conversation_id, "conv_x");
        assert_eq!(rec.seq, 0);
        assert_eq!(rec.role, "user");
        assert_eq!(rec.status, "active", "None 应归一化为 active");
        assert!(rec.parts.is_none());
        assert!(rec.tool_calls.is_none());

        let back = record_to_message(&rec);
        assert_eq!(back.id.as_deref(), Some("msg_test_1"));
        assert_eq!(back.status, None, "active 应还原为 None");
        assert_eq!(back.content, "你好");
        assert_eq!(back.timestamp, Some(1700000000000));
        assert!(matches!(back.role, MessageRole::User));
    }

    /// 多模态消息(parts 含 Image base64)round-trip
    #[test]
    fn roundtrip_multimodal_parts() {
        let mut msg = base_msg();
        msg.parts = Some(vec![
            ContentPart::Text { text: "看这张图".into() },
            ContentPart::Image {
                url: None,
                base64: Some("iVBORw0KGgo=".into()),
                media_type: Some("image/png".into()),
                alt: None,
            },
        ]);
        let rec = message_to_record(&msg, "conv_m", 1, "ts");
        // parts 应序列化成 JSON 字符串
        assert!(rec.parts.is_some());
        let back = record_to_message(&rec);
        assert_eq!(back.parts, msg.parts, "parts Image base64 双向一致");
    }

    /// assistant 工具调用消息(tool_calls 多条)round-trip
    #[test]
    fn roundtrip_tool_calls() {
        let mut msg = base_msg();
        msg.role = MessageRole::Assistant;
        msg.model = Some("deepseek-chat".into());
        msg.tool_calls = Some(vec![
            ToolCall {
                id: "call_1".into(),
                call_type: "function".into(),
                function: ToolCallFunction {
                    name: "read_file".into(),
                    arguments: r#"{"path":"a.rs"}"#.into(),
                },
            },
            ToolCall {
                id: "call_2".into(),
                call_type: "function".into(),
                function: ToolCallFunction {
                    name: "write_file".into(),
                    arguments: r#"{"path":"b.rs"}"#.into(),
                },
            },
        ]);
        let rec = message_to_record(&msg, "conv_t", 2, "ts");
        assert_eq!(rec.role, "assistant");
        assert_eq!(rec.model.as_deref(), Some("deepseek-chat"));
        assert!(rec.tool_calls.is_some());
        let back = record_to_message(&rec);
        assert_eq!(back.tool_calls.as_ref().map(|v| v.len()), Some(2));
        // ToolCall 无 PartialEq,经 JSON 字符串比对(round-trip 一致)
        assert_eq!(
            serde_json::to_string(&back.tool_calls).unwrap(),
            serde_json::to_string(&msg.tool_calls).unwrap(),
            "tool_calls 多条双向一致"
        );
        assert_eq!(back.model.as_deref(), Some("deepseek-chat"));
    }

    /// tool 消息(tool_call_id)round-trip
    #[test]
    fn roundtrip_tool_message() {
        let mut msg = base_msg();
        msg.role = MessageRole::Tool;
        msg.content = "文件内容...".into();
        msg.tool_call_id = Some("call_abc".into());
        let rec = message_to_record(&msg, "conv_tool", 3, "ts");
        assert_eq!(rec.role, "tool");
        assert_eq!(rec.tool_call_id.as_deref(), Some("call_abc"));
        let back = record_to_message(&rec);
        assert!(matches!(back.role, MessageRole::Tool));
        assert_eq!(back.tool_call_id.as_deref(), Some("call_abc"));
    }

    /// status 各值(Truncated/Compressed)round-trip
    #[test]
    fn roundtrip_status_non_active_preserved() {
        let mut msg = base_msg();
        msg.status = Some(MessageStatus::Truncated);
        let rec = message_to_record(&msg, "c", 0, "ts");
        assert_eq!(rec.status, "truncated");
        let back = record_to_message(&rec);
        assert_eq!(back.status, Some(MessageStatus::Truncated));

        // compressed
        msg.status = Some(MessageStatus::Compressed);
        let rec = message_to_record(&msg, "c", 0, "ts");
        assert_eq!(rec.status, "compressed");
        let back = record_to_message(&rec);
        assert_eq!(back.status, Some(MessageStatus::Compressed));
    }

    /// id=None 兜底:`msg_{conv}_{seq}`
    #[test]
    fn roundtrip_id_none_fallback() {
        let mut msg = base_msg();
        msg.id = None;
        let rec = message_to_record(&msg, "conv_y", 5, "ts");
        assert_eq!(rec.id, "msg_conv_y_5", "id None 应兜底 msg_conv_seq");
        // 读回:id 来自 record.id(非空)→ Some
        let back = record_to_message(&rec);
        assert_eq!(back.id.as_deref(), Some("msg_conv_y_5"));
    }

    /// reasoning_content round-trip
    #[test]
    fn roundtrip_reasoning_content() {
        let mut msg = base_msg();
        msg.role = MessageRole::Assistant;
        msg.reasoning_content = Some("思考过程...".into());
        let rec = message_to_record(&msg, "c", 0, "ts");
        assert_eq!(rec.reasoning_content.as_deref(), Some("思考过程..."));
        let back = record_to_message(&rec);
        assert_eq!(back.reasoning_content.as_deref(), Some("思考过程..."));
    }

    /// 完整多字段混合 round-trip(覆盖全字段一致性)
    #[test]
    fn roundtrip_full_fields() {
        let mut msg = base_msg();
        msg.role = MessageRole::Assistant;
        msg.content = "结果".into();
        msg.parts = Some(vec![ContentPart::Text { text: "t".into() }]);
        msg.tool_calls = Some(vec![ToolCall {
            id: "c1".into(),
            call_type: "function".into(),
            function: ToolCallFunction {
                name: "f".into(),
                arguments: "{}".into(),
            },
        }]);
        msg.model = Some("m".into());
        msg.status = Some(MessageStatus::Compressed);
        msg.reasoning_content = Some("r".into());
        msg.timestamp = Some(123);
        let rec = message_to_record(&msg, "conv_full", 7, "ts_full");
        let back = record_to_message(&rec);
        assert_eq!(back.id.as_deref(), Some("msg_test_1"));
        assert!(matches!(back.role, MessageRole::Assistant));
        assert_eq!(back.content, "结果");
        assert_eq!(back.parts, msg.parts);
        assert_eq!(
            serde_json::to_string(&back.tool_calls).unwrap(),
            serde_json::to_string(&msg.tool_calls).unwrap()
        );
        assert_eq!(back.model.as_deref(), Some("m"));
        assert_eq!(back.status.as_ref(), Some(&MessageStatus::Compressed));
        assert_eq!(back.reasoning_content.as_deref(), Some("r"));
        assert_eq!(back.timestamp, Some(123));
    }

    // ============================================================
    // F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
    // ============================================================
    //
    // 覆盖 save_conversation 写路径核心链路(不依赖 AiSession 夹具):
    //   Vec<ChatMessage> --message_to_record--> Vec<AiMessageRecord>
    //   --replace_conversation--> ai_messages 表
    //   --list_by_conversation--> Vec<AiMessageRecord>
    //   --record_to_message--> Vec<ChatMessage>
    // 验证:长对话 50+ 轮全字段(parts/tool_calls/status/id/timestamp/reasoning_content)一致。

    #[tokio::test]
    async fn batch_b_save_load_roundtrip_50_rounds_full_fields() {
        use df_storage::crud::AiMessageRepo;
        use df_storage::db::Database;

        let db = Database::open_in_memory().await.expect("open_in_memory");
        let repo = AiMessageRepo::new(&db);

        // 构造 50 轮 user/assistant/tool 混合消息(共 150 条),覆盖全字段
        let conv_id = "conv_rt";
        let created_at = "2026-06-20T00:00:00Z";
        let original: Vec<ChatMessage> = (0..150)
            .map(|i| {
                let role = i % 3;
                let mut m = match role {
                    0 => ChatMessage {
                        id: Some(format!("u_{i}")),
                        role: MessageRole::User,
                        content: format!("用户提问 {i},带中文"),
                        parts: Some(vec![ContentPart::Text { text: format!("片 {i}") }]),
                        timestamp: Some(1_700_000_000_000 + i),
                        ..base_msg()
                    },
                    1 => ChatMessage {
                        id: Some(format!("a_{i}")),
                        role: MessageRole::Assistant,
                        content: format!("助手回答 {i}"),
                        model: Some("deepseek-chat".into()),
                        reasoning_content: Some(format!("思考 {i}")),
                        tool_calls: Some(vec![ToolCall {
                            id: format!("call_{i}"),
                            call_type: "function".into(),
                            function: ToolCallFunction {
                                name: "read_file".into(),
                                arguments: format!(r#"{{"path":"{i}.rs"}}"#),
                            },
                        }]),
                        status: if i % 6 == 0 {
                            Some(MessageStatus::Compressed)
                        } else {
                            None
                        },
                        timestamp: Some(1_700_000_000_000 + i),
                        ..base_msg()
                    },
                    _ => ChatMessage {
                        id: Some(format!("t_{i}")),
                        role: MessageRole::Tool,
                        content: format!("工具结果 {i}:大段内容"),
                        tool_call_id: Some(format!("call_{}", i - 1)),
                        timestamp: Some(1_700_000_000_000 + i),
                        ..base_msg()
                    },
                };
                m.id = Some(format!("msg_{conv_id}_{i}"));
                m
            })
            .collect();

        // 写路径:映射 + replace_conversation(全量重写)
        let records: Vec<_> = original
            .iter()
            .enumerate()
            .map(|(seq, m)| message_to_record(m, conv_id, seq as i64, created_at))
            .collect();
        repo.replace_conversation(conv_id, records)
            .await
            .expect("replace");

        // 读路径:list + 还原
        let got_records = repo.list_by_conversation(conv_id).await.expect("list");
        assert_eq!(got_records.len(), 150, "应读回全部 150 条");
        let restored: Vec<ChatMessage> =
            got_records.iter().map(record_to_message).collect();

        // 全字段逐一比对
        assert_eq!(restored.len(), original.len());
        for (i, (orig, back)) in original.iter().zip(restored.iter()).enumerate() {
            assert_eq!(back.id, orig.id, "id 不一致 @ {i}");
            // role 枚举比对(MessageRole 非 Copy,用 std::mem::discriminant 判变体相等)
            assert_eq!(
                std::mem::discriminant(&back.role),
                std::mem::discriminant(&orig.role),
                "role 不一致 @ {i}"
            );
            assert_eq!(back.content, orig.content, "content 不一致 @ {i}");
            assert_eq!(back.parts, orig.parts, "parts 不一致 @ {i}");
            assert_eq!(back.tool_call_id, orig.tool_call_id, "tool_call_id 不一致 @ {i}");
            // tool_calls 无 PartialEq → JSON 比对
            assert_eq!(
                serde_json::to_string(&back.tool_calls).unwrap(),
                serde_json::to_string(&orig.tool_calls).unwrap(),
                "tool_calls 不一致 @ {i}"
            );
            assert_eq!(back.model, orig.model, "model 不一致 @ {i}");
            assert_eq!(back.status, orig.status, "status 不一致 @ {i}");
            assert_eq!(
                back.reasoning_content, orig.reasoning_content,
                "reasoning_content 不一致 @ {i}"
            );
            assert_eq!(back.timestamp, orig.timestamp, "timestamp 不一致 @ {i}");
        }

        // seq 顺序校验
        for (i, rec) in got_records.iter().enumerate() {
            assert_eq!(rec.seq, i as i64, "seq 应连续递增 @ {i}");
            assert_eq!(rec.conversation_id, conv_id, "conversation_id 应一致 @ {i}");
        }
    }

    /// save 全量重写后再次 save 变更内容(模拟 compress/edit 后下轮 save 覆盖)
    #[tokio::test]
    async fn batch_b_save_overwrite_reflects_inmemory_change() {
        use df_storage::crud::AiMessageRepo;
        use df_storage::db::Database;

        let db = Database::open_in_memory().await.expect("open_in_memory");
        let repo = AiMessageRepo::new(&db);
        let conv_id = "conv_overwrite";
        let created_at = "ts";

        // 第一轮:3 条消息
        let v1: Vec<ChatMessage> = (0..3)
            .map(|i| ChatMessage {
                id: Some(format!("m_{i}")),
                role: MessageRole::User,
                content: format!("v1_{i}"),
                ..base_msg()
            })
            .collect();
        let recs: Vec<_> = v1
            .iter()
            .enumerate()
            .map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at))
            .collect();
        repo.replace_conversation(conv_id, recs).await.expect("save v1");

        // 第二轮:内存变化——压缩成 1 条(status=compressed)+ 删除 2 条 + 新增 1 条
        let v2: Vec<ChatMessage> = vec![
            ChatMessage {
                id: Some("m_summary".into()),
                role: MessageRole::Assistant,
                content: "压缩摘要".into(),
                status: Some(MessageStatus::Compressed),
                ..base_msg()
            },
            ChatMessage {
                id: Some("m_new".into()),
                role: MessageRole::User,
                content: "压缩后新提问".into(),
                ..base_msg()
            },
        ];
        let recs2: Vec<_> = v2
            .iter()
            .enumerate()
            .map(|(s, m)| message_to_record(m, conv_id, s as i64, created_at))
            .collect();
        repo.replace_conversation(conv_id, recs2).await.expect("save v2");

        // 读回:应完全是 v2,v1 的 3 条已删
        let got = repo.list_by_conversation(conv_id).await.expect("list");
        assert_eq!(got.len(), 2, "v2 全量重写后应只剩 2 条");
        assert_eq!(got[0].id, "m_summary");
        assert_eq!(got[0].status, "compressed");
        assert_eq!(got[1].id, "m_new");
        assert_eq!(got[1].content, "压缩后新提问");
    }
}
