//! chat 域 IPC — 发送 / 重新生成 / 编辑 / 强制发送 / 停止 / 审批 / 上下文分段与压缩 / 循环控制
//!
//! 由原 `commands.rs`(单文件 1829 行 God 文件)按域拆分,本文件聚焦 chat 域 13 个 IPC +
//! chat 专用 helper(`finalize_pending_placeholders`)与 `PendingToolCallInfo`。
//! 其他域(provider/conversation/skills/config)仍留在 `commands/mod.rs`,后续批拆分。
//!
//! re-export:由 `commands/mod.rs` 经 `pub use self::chat::*;` 拉到 `commands::*`,
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。

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

use serde::Serialize;
use tauri::{AppHandle, Emitter, Manager, State};

use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
use df_storage::models::AiProviderRecord;
use df_types::augmentation::{MentionRef, MentionSpanDto};
use df_types::types::new_id;

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

// chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
#[cfg(test)]
use super::super::agentic::GOAL_MAX_CHARS;
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。
use super::super::agentic::conv_state::ConvState;
use super::super::audit::{audit_finalize, emit_data_changed};
use super::super::augmentation::build_augmentation_segment;
use super::super::augmentation::sanitize;
use super::super::conversation::save_conversation;
use super::super::knowledge_inject::inject_knowledge_into_prompt;
use super::super::prompt::{build_system_prompt, build_system_prompt_with_excluded};

use super::super::{AiChatEvent, ApprovalKind, SessionState};

/// 把当前所有挂起审批的占位 tool_result(内容为 audit.rs:PENDING_APPROVAL_PLACEHOLDER
/// "需要用户审批，等待确认")就地替换为终态文本。
///
/// 背景(SW-260618-02):审批占位文本在 stop/clear/create/delete/force_send 等
/// 清 pending_approvals 时若不替换,会残留在 messages 里,下次发送把占位喂给 LLM。
/// 占位内容非工具结果语义,污染上下文 + 误导模型继续等待。
///
/// 调用时机:在 `pending_approvals.clear()` / `retain` 之前调用(遍历即将被丢弃的条目)。
/// 在 messages 已 clear 的命令(如 ai_chat_clear)里该方法为 no-op
/// (replace_tool_result_content 找不到 tool_result 返回 false),不报错、无副作用。
///
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
///
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
///   终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
///   conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
///   入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
    // SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
    // 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
    // &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
    // 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
    let entries: Vec<(String, Option<String>)> = session
        .pending_approvals
        .values()
        .map(|a| (a.tool_call_id.clone(), a.conversation_id.clone()))
        .collect();
    for (id, approval_conv) in entries {
        // replace_tool_result_content 反向遍历,找不到对应 tool_call_id 时返回 false,无副作用
        let target = approval_conv.as_deref().filter(|s| !s.is_empty()).unwrap_or(conv_id);
        if !target.is_empty() {
            session.conv(target).messages.replace_tool_result_content(&id, final_text);
        }
    }
}

/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现仅测试用例使用。
#[cfg(test)]
pub(crate) fn extract_pinned_goal(text: &str) -> String {
    // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
    // 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
    const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
    let chars: Vec<char> = text.chars().collect();
    let n = chars.len();
    let mut out = String::with_capacity(text.len());
    let mut i = 0;
    while i < n {
        if chars[i] == '[' {
            // 探测 [kind:...],命中则跳过整段(含 ])。
            if let Some(close) = find_mention_close(&chars, i, KINDS) {
                i = close + 1;
                continue;
            }
        }
        out.push(chars[i]);
        i += 1;
    }
    let cleaned = out.trim();
    // 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好)
    cleaned.chars().take(GOAL_MAX_CHARS).collect()
}

/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。
///
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角：/半角:;kind 后允许空白;
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
#[allow(dead_code)]
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
    let after = open + 1;
    for k in kinds {
        let kc: Vec<char> = k.chars().collect();
        if chars.len() < after + kc.len() {
            continue;
        }
        let slice = &chars[after..after + kc.len()];
        if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) {
            continue;
        }
        let mut j = after + kc.len();
        while j < chars.len() && chars[j].is_whitespace() {
            j += 1;
        }
        if j >= chars.len() || (chars[j] != ':' && chars[j] != '：') {
            continue;
        }
        j += 1;
        while j < chars.len() {
            if chars[j] == ']' {
                return Some(j);
            }
            j += 1;
        }
        return None; // 未闭合 ] → 不当 mention
    }
    None
}

/// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。
///
/// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此,
/// 解决「@ 与 / 两条增强通道分别注入」的结构性问题(plan mighty-wibbling-papert.md 核心设计)。
///
/// - `skill`(若 `Some`)转 [`MentionRef::Skill { name }`],由 SkillResolver 走剥 frontmatter 的读取
///   (取代旧实现直接 `read_skill_content` 灌全文含 frontmatter)。
/// - `spans` 按 `kind` 转 [`MentionRef`]:project/task/idea 用 `ref_id` + `label`,skill 用 `label` 作 name。
/// - locality 取当前激活 provider(远程 provider path 仅 basename / 本地全路径,防裸泄)。
/// - 单条 resolve 失败由 `resolve_all` 收集进 errors 不中断整批(整批注入最大化保留可用上下文)。
///
/// 空结果返 `""`,调用方据之跳过拼接(不污染 prompt)。
///
/// 注:regenerate / edit 路径当前传 `skill=&None, spans=&None`(无新注入诉求),
/// 若后续需从历史末条 user 消息的 mentionSpans resolve,改这两处调用即可。
async fn resolve_and_inject(
    state: &AppState,
    provider: &AiProviderRecord,
    skill: &Option<String>,
    spans: &Option<Vec<MentionSpanDto>>,
    lang: &str,
) -> String {
    let mut refs: Vec<MentionRef> = Vec::new();
    if let Some(name) = skill.as_ref().filter(|s| !s.trim().is_empty()) {
        refs.push(MentionRef::Skill { name: name.clone() });
    }
    if let Some(spans) = spans.as_ref() {
        for span in spans {
            if let Some(rf) = span_to_mention_ref(span) {
                refs.push(rf);
            }
        }
    }
    if refs.is_empty() {
        return String::new();
    }
    let loc = sanitize::locality_of(provider);
    let (augs, _errors) = state.resolvers.resolve_all(&refs, loc).await;
    build_augmentation_segment(&augs, lang)
}

/// 单条 [`MentionSpanDto`] → [`MentionRef`] 转换(kind 驱动)。
///
/// 未知 kind / 空 ref_id 返 None(静默跳过,不中断整批注入——与 resolve_all 失败收集语义一致)。
/// skill kind 用 `label` 作 name(skill 以 name 为唯一键,DTO 的 ref_id 也即 name,这里取 label
/// 保证展示一致;二者应等价)。
fn span_to_mention_ref(span: &MentionSpanDto) -> Option<MentionRef> {
    match span.kind.as_str() {
        "project" => Some(MentionRef::Project {
            id: span.ref_id.clone(),
            name: span.label.clone(),
        }),
        "task" => Some(MentionRef::Task {
            id: span.ref_id.clone(),
            name: span.label.clone(),
        }),
        "idea" => Some(MentionRef::Idea {
            id: span.ref_id.clone(),
            name: span.label.clone(),
        }),
        "skill" => Some(MentionRef::Skill {
            name: span.label.clone(),
        }),
        _ => None,
    }
}

/// 从 mention_spans 提取 (被@的项目 id 列表, 被@的任务 id 列表)。
///
/// 用于 [`build_system_prompt_with_excluded`] 的去重入参:被@的 project/task 已有 augmentation
/// 精准投影,需从全局清单中排除以免同一实体两次入 prompt(skill/idea 不在清单内,无需排除)。
///
/// - `None` / 空 spans → 两个空 vec(行为退化为无去重的全貌清单,等同旧 build_system_prompt)。
/// - 仅收集 kind == "project" / "task" 的 `ref_id`(idea/skill 不进项目/任务清单)。
/// - 不去重(同实体被@多次理论上不会出现,即便出现 excluded 列表多次命中也只是 continue,无副作用)。
fn excluded_ids_from_mentions(spans: &Option<Vec<MentionSpanDto>>) -> (Vec<String>, Vec<String>) {
    let mut proj = Vec::new();
    let mut task = Vec::new();
    if let Some(spans) = spans.as_ref() {
        for span in spans {
            match span.kind.as_str() {
                "project" => proj.push(span.ref_id.clone()),
                "task" => task.push(span.ref_id.clone()),
                _ => {}
            }
        }
    }
    (proj, task)
}

// ============================================================
// 发送 / 审批 / 控制
// ============================================================

/// 重新生成最后一条 AI 回复(UX-02:消息操作栏「重新生成」)
///
/// 流程:占用 generating → 弹出末尾 AI 回复(pop_last_assistant_round,保留触发它的
/// user 消息)→ save 落库(避免前端切走时残留旧回复)→ spawn run_agentic_loop 重跑
/// (历史末尾是该 user 消息,LLM 据此再生成)。
///
/// 与 ai_chat_send 的区别:不 push 新 user 消息(用户消息已在历史末尾),仅清旧 AI 回复后
/// 复用同一 agentic loop。生成中拦截,与 send 一致防并发双发。
#[tauri::command]
pub async fn ai_regenerate(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
    language: Option<String>,
    model_override: Option<String>,
) -> Result<String, String> {
    let provider_config = super::super::prompt::get_active_provider(&state).await?;

    // 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
    // conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
    //   (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
    // ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
    if !state.conv_states.can_accept_request(&conversation_id) {
        return Err("AI 正在生成中，请等待完成".to_string());
    }
    {
        let mut session = state.ai_session.lock().await;
        let conv = session.conv(&conversation_id);
        // generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
        // 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
        conv.stop_flag.store(false, Ordering::SeqCst);
        conv.agent_language = language.clone();
        // 重生成 = 新生命周期起点,iteration 从头计数。
        conv.iteration_used = 0;
        // 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
        conv.model_override = model_override.clone();
        let popped = conv.messages.pop_last_assistant_round();
        if !popped {
            // 末尾无 AI 回复可弹。失败重试场景(流式中途失败,这轮 assistant 未持久化)
            // 末尾是触发它的 user 消息 → 直接从该 user 重跑(等价重发),不报错。
            // 仅空对话/末尾非 user 异常态才真无回复可重生成。
            let last_is_user = conv
                .messages
                .all_messages_clone()
                .last()
                .map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
                .unwrap_or(false);
            if !last_is_user {
                // generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
                // 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
                return Err("没有可重新生成的回复".to_string());
            }
        }
    }

    let _tool_defs = state.ai_tools.tool_definitions();
    let lang = language.unwrap_or_else(|| "zh-CN".to_string());
    let system_prompt = build_system_prompt(&state, &lang).await;

    // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
    let conv_id = conversation_id.clone();
    // 知识注入:取末条 active user 消息文本做检索(与 send 同款,语义命中刷新上下文)。
    // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复:
    // 原 last_user_text 不过滤 is_active / user_message_id 走 last_user_message_id 不过滤 is_active,
    // 两值可能取自不同消息;helper 单次反向扫描同一条消息取两值)。
    let mut system_prompt = {
        let config = state.knowledge_config.lock().await.clone();
        inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
    };
    // Augmentation 注入:regenerate 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
    // 若未来需从末条 user 消息的 mentionSpans resolve(验证 #8),改此处传入即可。
    let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
    if !aug_seg.is_empty() {
        system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
    }

    // 落库:弹出后的历史先持久化(前端立即反映已删旧回复;loop 内再 save 覆盖)
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;

    let session_arc = state.ai_session.clone();
    let tools_arc = state.ai_tools.clone();
    let db = state.db.clone();
    let app_handle = app.clone();
    let knowledge_config = state.knowledge_config.lock().await.clone();
    let llm_concurrency = state.llm_concurrency.clone();
    // F-260616-01: loop 入口 load 快照，当前 loop 锁定边界（热改下次发消息生效）
    let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
    // F-260616-07: 流式失败重试次数快照
    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);

    tauri::async_runtime::spawn(async move {
        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
    });

    Ok("ok".to_string())
}

/// 查询后端真实 generating 状态(B-260615-22:方案 A 发送前 IPC 查后端真值)
///
/// 前端 `state.streaming` 与后端 `AiSession.generating` 各自维护:
/// 后端 loop 异常退出 / AiError 已复位 generating=false 时,前端若仅查本地 streaming
/// 可能仍为 true(或反之),预检放行后撞后端 `ai_chat_send` 的 generating 拦截。
/// 发送前调此命令取后端真值,据之与本地 streaming 对齐,消除状态不同步。
#[tauri::command]
pub async fn ai_is_generating(
    state: State<'_, AppState>,
    // F-260616-09 B 批4:加 conv_id 参数(决策 e 真并发上线),精确查指定 conv 的 generating,
    // 不依赖 active_conversation_id 单值(否则多会话下查 A 的生成态会读到 B 的值)。
    // conv_id 为空(前端未传/分离窗口无活跃)时 fallback active conv 兼容旧调用方。
    conversation_id: Option<String>,
) -> Result<bool, String> {
    // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。target 解析仍需 active_conversation_id
    // (单值读),仅此处轻量 lock 取 active,ConvState 读完全脱离 session lock。
    let target = {
        let session = state.ai_session.lock().await;
        conversation_id.filter(|s| !s.is_empty())
            .or_else(|| session.active_conversation_id.clone())
            .unwrap_or_default()
    };
    if target.is_empty() {
        // 无目标 conv 且无 active:无任何 conv 在跑,返 false。
        return Ok(false);
    }
    // L2 读侧:统一读 conv_state.is_active()(Generating/Compressed)。
    // B-Phase2:切 conv_states(无锁),语义等价(同一 ConvState 值)。
    Ok(state.conv_states.is_active(&target))
}

/// 发送消息并获取流式 AI 响应
///
/// 非阻塞：立即返回 "ok"，通过 ai-chat-event 事件流式推送
#[tauri::command]
pub async fn ai_chat_send(
    app: AppHandle,
    state: State<'_, AppState>,
    message: String,
    language: Option<String>,
    skill: Option<String>,
    model_override: Option<String>,
    // F-260616-09 B 批4:加 conversation_id 参数(决策 e 真并发上线),操作指定 conv 的 per_conv,
    // 不依赖 active_conversation_id 单值。前端传 activeConversationId 或目标 conv_id。
    // None/空 → 沿用旧逻辑(取 active,首次发送懒创建)。
    conversation_id: Option<String>,
    // F-260614-05 Phase 2c: 多模态 parts(前端 invoke 透传,serde 反序列化为 ContentPart 数组)。
    // None/空 → 等价 user()(纯文本,向后兼容);Some(含 Image 片) → user_parts() + provider vision 端点。
    // FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面
    // (不进 NodeContext.config/NodeOutput/tool_call schema/IPC 返回值/日志/错误),
    // 仅经 ai_chat_send 落 session.messages + 经 provider 请求(波20 truncate_parts 防 DB 撑爆)。
    parts: Option<Vec<ContentPart>>,
    // Input Augmentation:用户消息内 @ mention 区间元数据(chip 精确替换 + 后端 resolve 注入)。
    // None/空 → 无 @ mention(向后兼容,仅 / 技能走 skill 参数);非空 → 按 kind 投影成 Augmentation。
    mention_spans: Option<Vec<MentionSpanDto>>,
) -> Result<String, String> {
    // 获取活跃提供商（只读，失败可直接返回，不影响生成标志）
    let provider_config = super::super::prompt::get_active_provider(&state).await?;

    // 原子检查并占用生成标志，防止并发双发；同步追加用户消息，按需自动创建对话
    // F-260616-09 B 批4(决策 e 真并发上线):
    //   - 目标 conv 取入参 conversation_id(前端传 activeConversationId),None/空时 fallback
    //     active(旧调用方兼容);若 active 也为 None(首次发送),懒创建新 conv id。
    //   - per_conv 是唯一真相源,删除顶层双写(批2 桥接已退役)。
    let (conv_id, _user_message_id) = {
        let mut session = state.ai_session.lock().await;
        // 目标 conv:入参优先 → active → 懒创建。
        let target = conversation_id.clone().filter(|s| !s.is_empty())
            .or_else(|| session.active_conversation_id.clone());
        let target = match target {
            Some(id) => id,
            None => {
                // 首次发送懒创建新 conv id(不立即落库,避免空对话残留;
                // 实际记录由 save_conversation 在生成内容后 upsert 写入)。
                let id = new_id();
                session.active_conversation_id = Some(id.clone());
                session.active_conv_created_at = Some(now_millis());
                id
            }
        };
        // active_conversation_id 同步到 target(用户当前面板即 target,路由键跟随)。
        // 决策 e:旧 conv 的后台 loop 不受 active 变更影响(写自己的 per_conv)。
        if session.active_conversation_id.as_deref() != Some(target.as_str()) {
            session.active_conversation_id = Some(target.clone());
            // 切到已有 conv(非本次懒建)时,确保其 active_conv_created_at 已设(switch 路径已设)。
            if session.active_conv_created_at.is_none() {
                session.active_conv_created_at = Some(now_millis());
            }
        }
        // 入口拦截(双轨收口批1):拦 Stopping/Generating/Compressed,放行 Idle+Error。
        // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。原 c.generating 漏拦 Stopping 态,
        // can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
        if !state.conv_states.can_accept_request(&target) {
            return Err("AI 正在生成中，请等待完成".to_string());
        }
        let conv = session.conv(&target);
        // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
        conv.stop_flag.store(false, Ordering::SeqCst);
        conv.agent_language = language.clone();
        // F-260616-11: 新对话生命周期 iteration 从头计数(累计计数器复位)。
        conv.iteration_used = 0;
        // F-01 阶段6: 记录用户指定模型 override(主对话专用)。run_agentic_loop 兜底校验
        // (override 非空且在 provider model_configs 池中才用,否则落回路由结果)。
        conv.model_override = model_override.clone();
        // F-260614-02 §5.2:纯技能调用(用户未填文本)时,落库 user content 改 /{skillname}
        // 作为技能调用标记(非伪造用户文本),让 title.rs summary_msgs 取到非空素材生成标题。
        // 非空 message 原样落库。
        let user_content = if message.trim().is_empty() {
            if let Some(ref skill_name) = skill {
                format!("/{}", skill_name)
            } else {
                message.clone()
            }
        } else {
            message.clone()
        };
        // F-260614-05 Phase 2c: parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)。
        // user_parts 内部把 parts 挂到 ChatMessage.parts,provider 在 has_image() 为真时转 vision 端点。
        if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
            conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
        } else {
            conv.messages.push(ChatMessage::user(&user_content));
        }
        // F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。
        // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
        // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
        let user_msg_id = conv.messages.last_user_message_id();
        (target, user_msg_id)
    };

    // 获取工具定义(预取仅用于触发注册表初始化,实际 tool_defs 在 agentic loop 内部按需获取)
    let _tool_defs = state.ai_tools.tool_definitions();
    let lang = language.unwrap_or_else(|| "zh-CN".to_string());
    // 去重:从 mention_spans 提取被@的 project/task id,传给 prompt 构建器从全局清单中排除
    // (被@实体已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)。
    // mention_spans 为 None/空时返回两个空 vec(行为退化为无去重的全貌清单)。
    let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
    let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
    // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影后拼到 system prompt 前。
    // 隔离标注(build_augmentation_segment 头尾包裹,FR-S4 风格)防 prompt injection 与用户指令/行为准则混淆。
    let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
    if !aug_seg.is_empty() {
        system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
    }
    // conv_id 已在上方状态占用块得出(入参/active/懒创建),供知识注入溯源 + spawn 后台 loop。

    // 知识注入：检索相关知识拼到 system prompt 前(可配置开关 auto_inject,默认开)
    // 最终顺序:[知识库上下文] --- [技能指令] --- [原始 system prompt]
    // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
    // 注:此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
    {
        let config = state.knowledge_config.lock().await.clone();
        system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
    }

    // 在后台任务中执行流式调用
    let session_arc = state.ai_session.clone();
    let tools_arc = state.ai_tools.clone();
    let db = state.db.clone();
    let app_handle = app.clone();
    let knowledge_config = state.knowledge_config.lock().await.clone();
    let llm_concurrency = state.llm_concurrency.clone();
    // F-260616-01: loop 入口 load 快照，当前 loop 锁定边界（热改下次发消息生效）
    let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
    // F-260616-07: 流式失败重试次数快照
    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);

    tauri::async_runtime::spawn(async move {
        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
    });

    Ok("ok".to_string())
}

/// 批准/拒绝挂起的工具调用
#[tauri::command]
pub async fn ai_approve(
    app: AppHandle,
    state: State<'_, AppState>,
    tool_call_id: String,
    approved: bool,
) -> Result<String, String> {
    let __lock_t534 = std::time::Instant::now();
    let mut session = state.ai_session.lock().await;

    let approval = match session.pending_approvals.remove(&tool_call_id) {
        Some(a) => a,
        None => {
            // 阶段3a 单真相源合并:ai_approve 从单 pending_approvals remove(原 risk_pending 合一)。
            // 若前端误调 ai_approve 消费 path_auth 挂起 → 这里拿不到该 id(kind=Path 的挂起也走同一表,
            // 但下方 kind 校验会拦截 Err)。无挂起则查审计表(幂等,已处理返成功)。
            // F-260616-06: 幂等——内存无挂起审批时查审计表，若已处理则返回成功（非报错）
            // BUG-260618-11: 区分 Err(DB 故障)与 Ok(None)(真无记录),原 unwrap_or_default 把
            // Err 压成 None,DB 故障被「未找到」误导(实为可重试故障),对齐 audit.rs audit_finalize 三路分流。
            let audit_status = match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
                Ok(Some(rec)) => {
                    if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" {
                        // BUG-260618-11 + 关闭/拒绝无效根因修复(2026-08):
                        // 内存无此 pending(remove 返 None)且审计表已落定(completed/rejected/failed)时,
                        // 说明该审批**已处理但前端状态未收敛**——典型路径:对话 pending_approvals JSON 列
                        // 残留陈旧条目(recovered 后内存与 DB 不同步 / 跨会话恢复竞态),前端 ApprovalPopup
                        // /ToolCard 显示挂起卡,用户点拒绝 → 本分支原 return Ok 不 emit 任何事件 →
                        // 主窗口 pendingApprovals 永不清(useAiEvents 仅 AiApprovalResult 事件驱动清)→
                        // 卡死"关不了"。修复:即便已处理也 emit AiApprovalResult{approved:false},
                        // 前端 useAiEvents:556 filter p.id !== event.id 收敛清掉(幂等,重复 emit 无害)。
                        // conv_id 取审计记录的 conversation_id(无则 None,前端按 id 过滤不依赖 conv_id)。
                        let ev = AiChatEvent::AiApprovalResult {
                            id: tool_call_id.clone(),
                            approved: false,
                            conversation_id: rec.conversation_id.clone(),
                        };
                        let _ = app.emit("ai-chat-event", ev.clone());
                        let _ = state.ai_event_bus.publish_event(ev);
                        return Ok(format!("已处理({})", rec.status));
                    }
                    rec.status // 其他状态(如 pending/interrupted/skipped_retry)继续走未找到报错
                }
                Ok(None) => { String::new() /* 真无记录,落到下方 return Err 未找到挂起审批 */ }
                Err(e) => {
                    tracing::error!("ai_approve: 查询 tool_call_id={} 审计记录失败(DB 故障): {}", tool_call_id, e);
                    return Err(format!("查询审批记录失败(DB 故障),请重试: {}", e));
                }
            };
            return Err(format!("未找到挂起的审批: {} ({})", tool_call_id, audit_status));
        }
    };
    // 阶段3a 决策层分离:ai_approve 只消费 kind==Risk(普通 Med/High 审批,一次性 approve/reject)。
    // 若前端误调 ai_approve 消费 kind==Path 挂起(应走 ai_authorize_dir)→ Err 防误调,
    // 避免把路径授权挂起当 Risk 审批执行(会跳过白名单写入,语义错乱)。
    // 兜底/回退:误调返回 Err 前不回滚 insert(前端拿 Err 自行刷新 pending,等价原「未找到」语义)。
    if !matches!(approval.kind, ApprovalKind::Risk { .. }) {
        return Err(format!(
            "tool_call_id={} 非普通审批(路径授权请用 ai_authorize_dir)",
            tool_call_id
        ));
    }
    // recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
    let _recovered = approval.recovered;

    if !approved {
        // 替换占位 tool_result 为拒绝结果
        // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
        //   无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
        //   (审计仍记 rejected),避免写错 conv。
        if let Some(ref cid) = approval.conversation_id {
            // BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
            // (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
            let reject_payload = serde_json::json!({
                "status": "rejected",
                "reason": "user_denied",
                "message": "用户拒绝了此操作"
            }).to_string();
            session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &reject_payload);
        }
        let conv_id = approval.conversation_id.clone();
        let ev = AiChatEvent::AiApprovalResult {
            id: tool_call_id.clone(),
            approved: false,
            conversation_id: conv_id.clone(),
        };
        let _ = app.emit("ai-chat-event", ev.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp;publish 内 EVENT_BUS_ENABLED 门控,
        // 无消费者空转。app.emit 前端 + publish_event tunnel 两路独立,emit 保留不删。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
        drop(session);
        // 拒绝结果立即落库(含 recovered 积压审批)——switch 时已 restore_from_messages 载完整历史,
        // messages 非空,save 不会污染老对话;原 if !recovered 守卫前提不成立已移除。
        if let Some(ref cid) = conv_id {
            save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
        }
        // 审计：拒绝（决策者=human）
        audit_finalize(&state, &tool_call_id, "rejected", None).await;
        // F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
        // 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
        // F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
        let start_iter = {
            let __lock_t610 = std::time::Instant::now();
            let session = state.ai_session.lock().await;
            let v = conv_id.as_deref()
                .and_then(|c| session.conv_read(c))
                .map(|c| c.iteration_used)
                .unwrap_or(0);
            let __hold610 = __lock_t610.elapsed();
            if __hold610 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
            }
            v
        };
        // 所有待审批处理完毕后恢复 agentic 循环
        // F-09 B 批2:传 conv_id(approval.conversation_id),try_continue 按 conv 过滤 pending/读 per_conv。
        let cont_conv_id = conv_id.clone().unwrap_or_default();
        try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
        return Ok("rejected".to_string());
    }

    // 执行工具（通过真实 repo 调用）
    let args = approval.arguments.clone();
    let id = tool_call_id.clone();
    let conv_id = approval.conversation_id.clone();
    // AE-2025-04 会话级信任：用户人工批准 write_file / run_command 时,把该工具+目录写入
    // session_trust —— 下次同会话同类操作(同工具+同目录 TrustKey)自动放行,跳过 pending + 二次确认。
    // 仅首批工具(write_file/run_command)命中 trust_key_for 的 Some,其余工具 noop(None 不写)。
    // F-260616-09 B 批4:per_conv.session_trust 唯一真相源(conv_id 来源 approval.conversation_id)。
    //   无 conv_id(无主审批 R-9 异常数据)时无 per_conv 可写,信任不记(审计仍记 completed)。
    if let Some(key) = super::super::trust_key_for(&approval.tool_name, &approval.arguments) {
        let inserted = if let Some(ref cid) = conv_id {
            session.conv(cid).session_trust.insert(key.clone())
        } else {
            false
        };
        if inserted {
            tracing::info!(
                tool = %approval.tool_name,
                key = ?key,
                "[AE-2025-04] 人工审批通过,记录会话信任: 下次同类操作自动放行"
            );
        }
    }
    let __hold534 = __lock_t534.elapsed();
    if __hold534 > std::time::Duration::from_millis(30) {
        eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
    }
    drop(session); // 释放锁后再执行

    // B-260617-01 决策 a(方案 a2 后端分支)：run_workflow 特殊处理。
    // run_workflow handler(tool_registry.rs:544)仅持 db:Arc<Database>,无 AppHandle/State
    // (registry/event_bus/workflows Repo),经 ai_tools.execute 调用必然 Err。原行为：审批通过 →
    // handler Err → tool_result=错误提示 → LLM 重试 → 又进审批 → 死循环(工具未标 no_retry)。
    // 修复：识别 run_workflow → 不走 ai_tools.execute → 直接调工作流执行函数(持有完整 State,
    // 同 invoke('run_workflow') IPC 入口)→ 结果回填 tool_result(让 LLM 收成功结果停止重试)。
    // 选 a2 而非 a1(前端拦截)：ai_approve 已持 state+app,后端统一更简,无需新增前端 IPC +
    // 不把工作流知识泄漏到前端。失败(tool 未注册/参数缺失)按原 Err 路径处理(failed tool_result)。
    //
    // 仅分支 run_workflow,其余工具审批路径完全不变(含 AE-04 trust_key_for 写 trust / audit 终态
    // 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
    // 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
    let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
        super::super::execute_run_workflow_for_tool(&app, &state, &args).await
    } else {
        // F-260620 对齐 ai_authorize_dir(:658):ai_approve 执行同样包 60s 超时。
        // 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue
        // → IPC 永挂 → per_conv.generating 永真 → 前端 130s 看门狗静默吞消息(F-260620 同型故障,
        // 之前只给 ai_authorize_dir 加超时,ai_approve 漏)。
        // run_command 实时流式:经 task-local sink 注入(AppHandle + tool_call_id + conv_id),
        // handler 内 execute_streaming 每行 emit AiCommandOutput(治执行中黑盒)。非 run_command 不注入。
        let sink = if approval.tool_name == "run_command" {
            Some(super::super::command_stream::CommandSink::new(
                app.clone(), id.clone(), conv_id.clone(),
            ))
        } else { None };
        let exec_fut = super::super::command_stream::scope(sink, state.ai_tools.execute(&approval.tool_name, args.clone()));
        match tokio::time::timeout(
            std::time::Duration::from_secs(60),
            exec_fut,
        ).await {
            Ok(r) => r,
            // 任务2: ai_approve 路径同步 run_command 失败提示(超时为最常见失败场景)。
            // 仅对 run_command 追加(其余工具无 shell 适配问题),避免污染其他工具错误语义。
            Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}{}", approval.tool_name,
                if approval.tool_name == "run_command" && cfg!(windows) {
                    "\n提示: PowerShell 下路径用正斜杠或双引号包裹,命令间用 `;` 而非 `&&`(PS5)"
                } else { "" })),
        }
    };
    // 工具失败不 return Err：把错误包成 tool_result，落库 + emit completed + 续循环全走通。
    // 否则前端 approveToolCall 的 catch 会回滚 pending_approval，审批按钮卡死无法消除。
    let (audit_status, result_val) = match &exec_result {
        Ok(val) => ("completed", val.clone()),
        Err(e) => ("failed", serde_json::Value::String(e.to_string())),
    };
    // 审计：人工审批后无论成败回填（决策者=human）
    audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;

    // AR-11（方案A）：人工审批通过且工具执行成功(非 failed)后 emit df-data-changed,
    // 前端 store listen 刷新列表（仅命中映射的工具 emit，见 emit_data_changed）。
    if exec_result.is_ok() {
        emit_data_changed(&app, &approval.tool_name);
        // MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
        // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
        // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
        if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
            state.reload_allowed_dirs().await;
        }
    }

    // 重新获取锁，替换占位 tool_result 为真实结果（失败时为错误信息，LLM 据此决定下一步）
    // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
    //   无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
    let __lock_t704 = std::time::Instant::now();
    let mut session = state.ai_session.lock().await;
    if let Some(ref cid) = conv_id {
        session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
    }

    let ev_completed = AiChatEvent::AiToolCallCompleted {
        id: id.clone(),
        result: result_val.clone(),
        conversation_id: conv_id.clone(),
    };
    let _ = app.emit("ai-chat-event", ev_completed.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
    let ev_approval = AiChatEvent::AiApprovalResult {
        id,
        approved: true,
        conversation_id: conv_id.clone(),
    };
    let _ = app.emit("ai-chat-event", ev_approval.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
    let __hold704 = __lock_t704.elapsed();
    if __hold704 > std::time::Duration::from_millis(30) {
        eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704);
    }
    drop(session);

    // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
    // create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
    // 故补全点放此(工具已 completed,可拿到 result.id + args.source)。
    // message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
    // assistant_with_tools 消息持久驻留 messages,审批时仍可读。
    // 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
    if audit_status == "completed" {
        let message_id = {
            let __lock_t735 = std::time::Instant::now();
            let session = state.ai_session.lock().await;
            let v = conv_id.as_deref().and_then(|cid| {
                session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
            });
            let __hold735 = __lock_t735.elapsed();
            if __hold735 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
            }
            v
        };
        super::super::audit::maybe_fill_idea_source(
            &state.db,
            &approval.tool_name,
            &approval.arguments,
            &result_val,
            message_id.as_deref(),
        ).await;
    }

    // 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
    // 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
    // save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
    if let Some(ref cid) = conv_id {
        save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
    }

    // F-260616-11 决策 a: 审批续跑 iteration 累计(不重置)——读 per_conv.iteration_used 透传
    // 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
    // F-09 B 批4:per_conv 唯一真相源;无 conv_id 时 start_iter=0。
    let cont_conv_id = conv_id.clone().unwrap_or_default();
    let start_iter = {
        let __lock_t761 = std::time::Instant::now();
        let session = state.ai_session.lock().await;
        let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
        let __hold761 = __lock_t761.elapsed();
        if __hold761 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
        }
        v
    };
    // 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
    try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;

    Ok("completed".to_string())
}

/// F-260620 临时诊断:授权/审批 IPC 调用链文件日志(不依赖终端 stderr,排障用)。
/// 写入 OS 临时目录(跨平台,不污染用户项目目录)。打包分发后仍可工作。


/// 阶段4(容错/恢复):取路径的 Windows 盘符(如 "C:" / "E:"),非 Windows / 无盘符返 None。
///
/// 仅做词法判断(取前两字符 `<drive>:`),不做 canonicalize(避免 IO 卡死——这正是本批
/// 防卡死预检的目标)。UNC 路径 `\\server\share` 返 None(跨盘语义不适用)。
fn drive_letter(path: &str) -> Option<String> {
    let bytes = path.as_bytes();
    if bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic() {
        let drive = (bytes[0] as char).to_ascii_uppercase();
        return Some(format!("{}:", drive));
    }
    None
}

/// 取当前进程工作目录的盘符,用于跨盘移动文件检测(仅 Windows)。
/// 打包分发后依赖运行期 current_dir,非编译期常量。失败回退 None(降级为不报跨盘提示)。
fn workspace_drive() -> Option<String> {
    std::env::current_dir().ok().and_then(|d| drive_letter(&d.to_string_lossy().to_string()))
}

/// 阶段4:检测授权路径是否与 workspace 跨盘,返提示文案(Some=跨盘需告知 LLM,None=同盘无提示)。
///
/// 仅对涉及文件移动/删除的工具(delete_file / rename_file)有意义——这些工具的目标位
/// (.trash 或 rename 的 to)锚定 workspace_root,源在另一盘时走 copy+remove 降级。
/// 其他文件工具(read_file/write_file)无跨盘移动语义,不提示。
fn cross_drive_hint(tool_name: &str, raw_paths: &[String]) -> Option<String> {
    // 仅文件移动/删除类工具涉及跨盘(源在 A 盘 → 目标 .trash/to 在 workspace 盘)
    if !matches!(tool_name, "delete_file" | "rename_file" | "move_file") {
        return None;
    }
    let ws_drive = workspace_drive()?;
    let cross_drives: Vec<&String> = raw_paths.iter()
        .filter(|p| drive_letter(p).map_or(false, |d| d != ws_drive))
        .collect();
    if cross_drives.is_empty() {
        return None;
    }
    Some(format!(
        "⚠ 跨盘提示:路径 {:?} 与工作区({})不同盘,工具内部走 copy+remove 降级(非原子,大文件较慢,失败可能留残,已内置回滚)",
        cross_drives, ws_drive
    ))
}

/// 把跨盘提示追加到 tool_result(保留原结果,仅追加提示行)。
///
/// - String 结果:追加 `\n<提示>`。
/// - Object 结果:追加 `cross_drive_note` 字段(不覆盖原字段)。
/// - 其他类型:包成 Object 的 `result` + `cross_drive_note` 双字段。
fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json::Value {
    match result {
        serde_json::Value::String(s) => serde_json::Value::String(format!("{}\n{}", s, note)),
        serde_json::Value::Object(mut map) => {
            map.insert("cross_drive_note".to_string(), serde_json::Value::String(note.to_string()));
            serde_json::Value::Object(map)
        }
        other => serde_json::json!({
            "result": other,
            "cross_drive_note": note,
        }),
    }
}

/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
///
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
///
/// `decision`(三授权档 + 拒):
/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
///
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。
#[tauri::command]
pub async fn ai_authorize_dir(
    app: AppHandle,
    state: State<'_, AppState>,
    tool_call_id: String,
    decision: String,
) -> Result<String, String> {
    // F-260620 卡死已根治(路径授权审批链闭环)。原 [AI-AUTHZ] eprintln 降级为 tracing::debug,
    // 避免污染 stderr(用户可见);authz_debug 文件日志保留(读 authz-debug.log 定位,排障仍可用)。
    tracing::debug!(
        tool_call_id = %tool_call_id,
        decision = %decision,
        "[AI-AUTHZ] ai_authorize_dir 入口"
    );
    // 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
    let approval = {
        let __lock_t864 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        let v = match session.pending_approvals.remove(&tool_call_id) {
            Some(a) => a,
            None => {
                // 内存无此挂起。释放锁后查审计表(避免持锁 await DB)。
                drop(session);
                // 关闭/拒绝无效根因修复(2026-08,对齐 ai_approve None 分支同款修复):
                // path 类审批同样存在"已处理但前端未收敛"场景(对话 pending_approvals JSON 列残留
                // 陈旧 path 条目 / 跨会话恢复竞态)。原此处直接 return Err 不 emit → 前端
                // pendingApprovals(kind='path')永不清 → 卡死"关不了"。修复:审计已落定时
                // emit AiApprovalResult{approved:false} 让前端收敛(幂等)。
                match state.ai_tool_executions.find_by_tool_call_id(&tool_call_id).await {
                    Ok(Some(rec)) if rec.status == "completed" || rec.status == "rejected" || rec.status == "failed" => {
                        let ev = AiChatEvent::AiApprovalResult {
                            id: tool_call_id.clone(),
                            approved: false,
                            conversation_id: rec.conversation_id.clone(),
                        };
                        let _ = app.emit("ai-chat-event", ev.clone());
                        let _ = state.ai_event_bus.publish_event(ev);
                        return Ok(format!("已处理({})", rec.status));
                    }
                    _ => {}
                }
                return Err(format!("未找到路径授权挂起: {}", tool_call_id));
            }
        };
        let __hold864 = __lock_t864.elapsed();
        if __hold864 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
        }
        v
    };
    // 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
    // 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
    // 兜底/回退:Err 前 re-insert 回 pending_approvals 保持挂起态(原「未找到」会丢挂起致卡死,
    // re-insert 保守兜底防误调导致 pending 丢失 + loop 永挂)。
    let req = match approval.kind.clone() {
        ApprovalKind::Path(req) => req,
        ApprovalKind::Risk { .. } => {
            // 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
            let __lock_t878 = std::time::Instant::now();
            let mut session = state.ai_session.lock().await;
            session.pending_approvals.insert(tool_call_id.clone(), approval);
            let __hold878 = __lock_t878.elapsed();
            if __hold878 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
            }
            return Err(format!(
                "tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
                tool_call_id
            ));
        }
    };
    // L1 补丁:req.dirs 含所有未授权父目录(rename_file from/to 不同目录时多项)。
    // once/always 遍历全部写入白名单,确保工具执行时 handler 内 resolve 全放行。
    let dir_strs: Vec<String> = req.dirs.iter()
        .map(|d| d.to_string_lossy().to_string())
        .collect();
    let conv_id = approval.conversation_id.clone();
    let args = approval.arguments.clone();

    // ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
    if decision == "deny" {
        // BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
        let err_msg = serde_json::json!({
            "status": "rejected",
            "reason": "path_auth_denied",
            "message": "用户拒绝路径授权"
        }).to_string();
        {
            let __lock_t903 = std::time::Instant::now();
            let mut session = state.ai_session.lock().await;
            if let Some(ref cid) = conv_id {
                session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
            }
            let __hold903 = __lock_t903.elapsed();
            if __hold903 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
            }
        }
        let ev_completed = AiChatEvent::AiToolCallCompleted {
            id: tool_call_id.clone(),
            result: serde_json::Value::String(err_msg.clone()),
            conversation_id: conv_id.clone(),
        };
        let _ = app.emit("ai-chat-event", ev_completed.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
        let ev_approval = AiChatEvent::AiApprovalResult {
            id: tool_call_id.clone(),
            approved: false,
            conversation_id: conv_id.clone(),
        };
        let _ = app.emit("ai-chat-event", ev_approval.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
        audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
        if let Some(ref cid) = conv_id {
            save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
        }
        let cont_conv_id = conv_id.clone().unwrap_or_default();
        let start_iter = {
            let __lock_t930 = std::time::Instant::now();
            let session = state.ai_session.lock().await;
            let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
            let __hold930 = __lock_t930.elapsed();
            if __hold930 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
            }
            v
        };
        try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
        return Ok("rejected".to_string());
    }

    // ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
    // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。
    // deny 已在上方早 return,此处仅 once/session/always。
    match decision.as_str() {
        "once" => {
            for d in &dir_strs {
                state.add_once_allowed_dir(d.clone()).await;
            }
            tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)");
        }
        "session" => {
            for d in &dir_strs {
                state.add_session_allowed_dir(d.clone()).await;
            }
            tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)");
        }
        "always" => {
            for d in &dir_strs {
                if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await {
                    tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e);
                    // 降级:写入会话临时授权,保本路径本会话可用
                    state.add_session_allowed_dir(d.clone()).await;
                }
            }
            tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)");
        }
        _ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗)
    }

    // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
    // 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
    tracing::debug!(tool = %approval.tool_name, "[AI-AUTHZ] execute 前");
    // F-260620 防工具卡死 ai_authorize_dir(致 per_conv.generating 永真 + 前端看门狗 130s 超时后
    // 静默吞消息):list_directory 等大目录工具可能 read_dir+metadata 海量文件卡住,execute 不返回
    // → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → generating 永真。包 60s 超时(对齐
    // run_command),超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
    let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
        super::super::execute_run_workflow_for_tool(&app, &state, &args).await
    } else {
        match tokio::time::timeout(
            std::time::Duration::from_secs(60),
            state.ai_tools.execute(&approval.tool_name, args.clone()),
        ).await {
            Ok(r) => r,
            Err(_) => Err(anyhow::anyhow!("工具执行超时(60s): {}", approval.tool_name)),
        }
    };
    tracing::debug!(
        ok = exec_result.is_ok(),
        status = if exec_result.is_ok() { "completed" } else { "failed" },
        "[AI-AUTHZ] execute 后"
    );
    let (audit_status, mut result_val) = match &exec_result {
        Ok(val) => ("completed", val.clone()),
        Err(e) => ("failed", serde_json::Value::String(e.to_string())),
    };
    // 阶段4(容错/恢复):跨盘预检 → tool_result 提示。
    // 检测授权路径(req.raw_paths 规范化前原值)与 workspace_root 是否跨盘(Windows 不同盘符,
    // 如 C盘授权路径 → E盘 workspace)。跨盘时文件工具(delete_file→.trash / rename_file from→to)
    // 内部走 copy+remove 降级(非原子,大文件慢),提示 LLM 知晓成本 + 失败留残风险。
    // 提示追加到 result_val(tool_result),不阻断执行(降级已能处理,仅告知)。
    if let Some(note) = cross_drive_hint(&approval.tool_name, &req.raw_paths) {
        result_val = append_cross_drive_note(result_val, &note);
    }
    audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
    if exec_result.is_ok() {
        emit_data_changed(&app, &approval.tool_name);
        // MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
        // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
        // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
        if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
            state.reload_allowed_dirs().await;
        }
    }

    // once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。
    // 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。
    state.clear_once_allowed_dirs().await;

    // 替换占位 tool_result 为真实结果
    {
        let __lock_t1018 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        if let Some(ref cid) = conv_id {
            session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
        }
        let __hold1018 = __lock_t1018.elapsed();
        if __hold1018 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
        }
    }
    let ev_completed = AiChatEvent::AiToolCallCompleted {
        id: tool_call_id.clone(),
        result: result_val.clone(),
        conversation_id: conv_id.clone(),
    };
    let _ = app.emit("ai-chat-event", ev_completed.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_completed);
    let ev_approval = AiChatEvent::AiApprovalResult {
        id: tool_call_id.clone(),
        approved: true,
        conversation_id: conv_id.clone(),
    };
    let _ = app.emit("ai-chat-event", ev_approval.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
    if let Some(ref cid) = conv_id {
        save_conversation(&state.ai_session, &state.db, cid, None, None, true).await;
    }
    let cont_conv_id = conv_id.clone().unwrap_or_default();
    let start_iter = {
        let __lock_t1044 = std::time::Instant::now();
        let session = state.ai_session.lock().await;
        let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
        let __hold1044 = __lock_t1044.elapsed();
        if __hold1044 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
        }
        v
    };
    try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
    Ok(audit_status.to_string())
}

/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
///
/// 阶段4:`kind` 字段透传(`risk` / `path`),前端 switchConversation 恢复 pendingApprovals 时
/// 按 kind 渲染——risk 类显 approve/reject,path 类显 once/always/deny(对齐阶段3b 统一审批模型)。
#[derive(Debug, Serialize)]
pub struct PendingToolCallInfo {
    pub tool_call_id: String,
    pub conversation_id: Option<String>,
    /// 阶段4:审批类型,前端按 kind 渲染不同决策按钮。
    /// 'risk' = 普通 RiskLevel 审批(approve/reject);'path' = 路径授权(once/always/deny)。
    #[serde(rename = "kind")]
    pub kind: &'static str,
}

/// 查询某对话积压的待审批工具(前端 switchConversation 后恢复 toolCard 的 pending_approval 态)
#[tauri::command]
pub async fn ai_pending_tool_calls(
    state: State<'_, AppState>,
    conv_id: String,
) -> Result<Vec<PendingToolCallInfo>, String> {
    let __lock_t1071 = std::time::Instant::now();
    let session = state.ai_session.lock().await;
    // 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
    // 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
    // (risk→approve/reject,path→once/always/deny),对齐阶段3b 统一审批模型。
    // (阶段3a 时前端仅恢复 kind==Risk 走 toolCard;阶段3b 前端切后 path 类也归一渲染,后端两 kind 都返。)
    let list = session
        .pending_approvals
        .values()
        .filter(|a| a.conversation_id.as_deref() == Some(conv_id.as_str()))
        .map(|a| PendingToolCallInfo {
            tool_call_id: a.tool_call_id.clone(),
            conversation_id: a.conversation_id.clone(),
            kind: match a.kind {
                ApprovalKind::Risk { .. } => "risk",
                ApprovalKind::Path(_) => "path",
            },
        })
        .collect();
    let __hold1071 = __lock_t1071.elapsed();
    if __hold1071 > std::time::Duration::from_millis(30) {
        eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
    }
    Ok(list)
}

/// 清空对话历史
#[tauri::command]
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
    let __lock_t1095 = std::time::Instant::now();
    let mut session = state.ai_session.lock().await;
    // 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
    let active_id = session.active_conversation_id.clone();
    // SW-260618-02:清 pending 前先把占位 tool_result 终态化(此处 messages 紧随即 clear,
    // 调用为 no-op,但保持清理点统一口径防顺序变更时占位随 messages 残留 DB)。
    // F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages.clear 双写;
    //   pending_approvals 仅清 active conv 的(retain 保留其他 conv 的审批)。
    finalize_pending_placeholders(&mut *session, &active_id.clone().unwrap_or_default(), "会话已清除");
    if let Some(ref id) = active_id {
        session.conv(id).messages.clear();
    }
    // 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
    session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref());
    let __hold1095 = __lock_t1095.elapsed();
    if __hold1095 > std::time::Duration::from_millis(30) {
        eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
    }
    drop(session);
    // 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7)
    // F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None)
    //   旧 clear_messages(置 messages='[]')保留调用:备份列同步清空,防 reload fallback 读旧脏数据。
    if let Some(id) = active_id {
        state
            .ai_conversations
            .clear_messages(&id)
            .await
            .map_err(err_str)?;
        state
            .ai_messages
            .delete_range(&id, 0, None)
            .await
            .map_err(err_str)?;
    }
    Ok(())
}

/// 手动上下文分段(F-15 阶段2):归档保护区外消息,不删 DB。
///
/// 把保护区外(active)的消息经 messages_mut 标 `status="archived_segment"`
/// (is_active 自动 false,sanitize 隔离不进 LLM 上下文,前端按 is_active 过滤折叠),
/// 保护区最近 N 条 active 不动(用户当前上下文连续性)。落库持久化新 status,不删 DB。
///
/// 空会话/无 active 可分段消息 → emit AiContextCleared 后 noop 返回 Ok(不报错)。
///
/// 与 ai_chat_clear 区别:clear 真删 DB 全清;本命令只软分段(归档),保留可追溯历史。
#[tauri::command]
pub async fn ai_chat_clear_context(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
) -> Result<(), String> {
    // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
    //   (真并发下后台 conv 也应可分段);messages 操作走 per_conv。
    let conv_id = {
        let __lock_t1145 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        // 保护区:保留最近 PROTECT_COUNT 条 active。
        const PROTECT_COUNT: usize = 6;
        let conv = session.conv(&conversation_id);
        let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
        // 空会话/全在保护区(消息 ≤ N 条) → 无可分段消息,emit 后 noop
        if protect_start == 0 {
            drop(session);
            let ev = AiChatEvent::AiContextCleared {
                conversation_id: Some(conversation_id.clone()),
            };
            let _ = app.emit("ai-chat-event", ev.clone());
            // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
            let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
            return Ok(());
        }
        // 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
        // msg-split-phase1:改既有消息 status,需标记全量重写(append-only 增量路径不覆盖旧行)。
        //    借用结束后单独调 mark_needs_full_rewrite(借用冲突:循环持 &mut 切片时不能再持 &mut self)。
        let mut dirtied = false;
        for t in conv.messages.messages_mut()[..protect_start].iter_mut() {
            if t.message.is_active() {
                t.message.status = Some(MessageStatus::ArchivedSegment);
                dirtied = true;
            }
        }
        if dirtied {
            conv.messages.mark_needs_full_rewrite();
        }
        let __hold1145 = __lock_t1145.elapsed();
        if __hold1145 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
        }
        drop(session);
        conversation_id
    };
    // 落库持久化新 status(照 save_conversation 模式,DB 持久化新 status)
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;
    let ev = AiChatEvent::AiContextCleared {
        conversation_id: Some(conv_id),
    };
    let _ = app.emit("ai-chat-event", ev.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
    Ok(())
}

/// 手动 LLM 压缩上下文(F-15 阶段2):摘要保护区外消息并插入首位。
///
/// 流程(失败不阻塞,消息状态不变):
/// 1. emit AiCompressing + set_compressing(true) 防重入
/// 2. 算 compress_end(保护区外,同 clear_context 的 protect_start)
/// 3. 先取保护区外 active 消息的克隆(读不改)→ 喂 compress_via_llm
/// 4. LLM 成功 → compress_old_messages 标 compressed(扣 token) + insert_at(0, system 摘要)
/// 5. LLM 失败 → set_compressing(false) + emit AiError(不含 api_key) + 返回 Err;消息状态不变
///
/// 空会话/无 active 可压缩消息 → set_compressing(false) 后 emit AiCompressing noop 返回 Ok。
#[tauri::command]
pub async fn ai_chat_compress_context(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
    language: Option<String>,
) -> Result<(), String> {
    let provider_config = super::super::prompt::get_active_provider(&state).await?;
    const PROTECT_COUNT: usize = 6;
    let lang = language.unwrap_or_else(|| "zh-CN".to_string());

    // 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
    // F-260616-09 B 批2:messages 操作改 per_conv(conv_id 来源:IPC 参数 conversation_id,已与 active 校验)。
    let (conv_id, active_msgs, compress_end) = {
        let __lock_t1205 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        // F-260616-09 B 批4(决策 e):移除 active 一致性校验(真并发下后台 conv 也应可压缩)。
        let conv = session.conv(&conversation_id);
        if conv.messages.is_compressing() {
            return Err("压缩正在进行中".to_string());
        }
        // emit 开始 + 置位防重入(成对释放,见下方所有出口)
        let ev = AiChatEvent::AiCompressing {
            conversation_id: Some(conversation_id.clone()),
        };
        let _ = app.emit("ai-chat-event", ev.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
        conv.messages.set_compressing(true);

        let protect_start = conv.messages.len().saturating_sub(PROTECT_COUNT);
        if protect_start == 0 {
            // 空会话/全在保护区 → 无可压缩消息,set_compressing(false) 后返回 Ok
            conv.messages.set_compressing(false);
            let cid = session.active_conversation_id.clone();
            drop(session);
            // 治 Task#1:手动 IPC 路径用 AiManualCompressed(前端弹 toast 告知"无可压缩/已完成")。
            let ev = AiChatEvent::AiManualCompressed {
                conversation_id: cid,
                summary: String::new(),
            };
            let _ = app.emit("ai-chat-event", ev.clone());
            // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
            let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
            return Ok(());
        }
        // 读 active 克隆(不改 status):compress_old_messages 留到 LLM 成功后才调
        let active_msgs: Vec<ChatMessage> = conv
            .messages
            .messages_mut()[..protect_start]
            .iter()
            .filter(|t| t.message.is_active())
            .map(|t| t.message.clone())
            .collect();
        let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
        let __hold1205 = __lock_t1205.elapsed();
        if __hold1205 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
        }
        (cid, active_msgs, protect_start)
    };

    if active_msgs.is_empty() {
        // 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
        let __lock_t1250 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        session.conv(&conv_id).messages.set_compressing(false);
        let __hold1250 = __lock_t1250.elapsed();
        if __hold1250 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
        }
        drop(session);
        // 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
        let ev = AiChatEvent::AiManualCompressed {
            conversation_id: Some(conv_id),
            summary: String::new(),
        };
        let _ = app.emit("ai-chat-event", ev.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
        return Ok(());
    }

    // 拿 provider(照 ai_chat_send 的 build_provider_for 模式,密钥经 resolve_provider_secret 闭环,
    // FR-S1 api_key 绝不进 payload/日志/错误信息)
    let provider = match super::super::secret::build_provider_for(&provider_config) {
        Ok(p) => p,
        Err(e) => {
            let __lock_t1269 = std::time::Instant::now();
            let mut session = state.ai_session.lock().await;
            session.conv(&conv_id).messages.set_compressing(false);
            let __hold1269 = __lock_t1269.elapsed();
            if __hold1269 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
            }
            drop(session);
            let ev = AiChatEvent::AiError {
                error: format!("压缩失败: {}", e),
                error_type: Some(super::super::ErrorType::Auth),
                conversation_id: Some(conv_id),
            };
            let _ = app.emit("ai-chat-event", ev.clone());
            // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
            let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
            return Err(format!("压缩失败: {}", e));
        }
    };
    let llm_concurrency = state.llm_concurrency.clone();
    let summary = match super::super::compress::compress_via_llm(
        provider.as_ref(),
        &provider_config,
        active_msgs,
        &lang,
        &conv_id,
        &llm_concurrency,
    ).await {
        Ok(s) => s.nl_summary,
        Err(e) => {
            // LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
            let __lock_t1295 = std::time::Instant::now();
            let mut session = state.ai_session.lock().await;
            session.conv(&conv_id).messages.set_compressing(false);
            let __hold1295 = __lock_t1295.elapsed();
            if __hold1295 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
            }
            drop(session);
            let ev = AiChatEvent::AiError {
                error: format!("压缩失败: {}", e),
                error_type: Some(super::super::ErrorType::Unknown),
                conversation_id: Some(conv_id),
            };
            let _ = app.emit("ai-chat-event", ev.clone());
            // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
            let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
            return Err(format!("压缩失败: {}", e));
        }
    };

    // LLM 成功 → 标 compressed(扣 token) + 摘要插首位
    {
        let __lock_t1312 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        let conv = session.conv(&conv_id);
        let _compressed = conv.messages.compress_old_messages(compress_end);
        conv.messages.insert_at(0, ChatMessage::system(&summary));
        conv.messages.set_compressing(false);
        let __hold1312 = __lock_t1312.elapsed();
        if __hold1312 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
        }
    }
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
    // 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
    let ev = AiChatEvent::AiManualCompressed {
        conversation_id: Some(conv_id),
        summary,
    };
    let _ = app.emit("ai-chat-event", ev.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
    Ok(())
}

/// 编辑最后一条 user 消息并重新生成（UX-09）
///
/// 流程(复用 ai_regenerate 的 spawn 模式):占用 generating → 校验活跃对话一致 →
/// ① 替换末条 active user 消息 content=new_message → ② 其后所有消息标 truncated(软删,
/// 保留 DB 可追溯但不进 LLM 上下文,前端视图过滤)→ save 落库 → spawn run_agentic_loop 重跑。
///
/// 约束:① 只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)② generating 中拦截 ③ 活跃对话一致。
/// 与 ai_regenerate 的区别:不 pop 旧 AI 回复(改标 truncated 保留),且编辑 user 内容后重跑。
#[tauri::command]
pub async fn ai_chat_edit(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
    new_message: String,
    language: Option<String>,
    model_override: Option<String>,
) -> Result<String, String> {
    let provider_config = super::super::prompt::get_active_provider(&state).await?;

    // 原子占用 generating + 替换末条 user content + truncate 其后
    // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
    //   (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
    // B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。
    if !state.conv_states.can_accept_request(&conversation_id) {
        return Err("AI 正在生成中，请等待完成".to_string());
    }
    {
        let mut session = state.ai_session.lock().await;
        let conv = session.conv(&conversation_id);
        // ① 替换末条 active user 消息 content(无 active user → Err)
        if conv.messages.replace_last_active_user_content(&new_message).is_err() {
            return Err("没有可编辑的用户消息".to_string());
        }
        // ② 其后所有消息标 truncated(无后续也 OK,返回 0)
        let _ = conv.messages.truncate_after_user_message(&new_message)
            .map_err(|_| "定位被编辑消息失败".to_string())?;
        // 占用生成态 + stop_flag
        // 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口迁移设置(不再手动赋值)。
        conv.stop_flag.store(false, Ordering::SeqCst);
        conv.agent_language = language.clone();
        // F-260616-11: 编辑重生成 = 新生命周期起点,iteration 从头计数。
        conv.iteration_used = 0;
        // F-01 阶段6: 记录用户指定模型 override(主对话专用)。
        conv.model_override = model_override.clone();
        // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
    }

    let _tool_defs = state.ai_tools.tool_definitions();
    let lang = language.unwrap_or_else(|| "zh-CN".to_string());
    let system_prompt = build_system_prompt(&state, &lang).await;

    // conv_id 直接用 IPC 参数 conversation_id(F-260616-09 B 批4,读 per_conv.messages)。
    let conv_id = conversation_id.clone();
    // 知识注入:用新 user 文本检索(与 send/regenerate 同款)。
    // DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
    // 注:edit 路径上方已 replace_last_active_user_content 把新文本写回末条 active user,
    // helper 读到的末条 active user 即编辑后的新文本,语义等价。
    let mut system_prompt = {
        let config = state.knowledge_config.lock().await.clone();
        inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await
    };
    // Augmentation 注入:edit 路径无新 @ mention / 技能,传 None 走空路径(不污染 prompt)。
    let aug_seg = resolve_and_inject(&state, &provider_config, &None, &None, &lang).await;
    if !aug_seg.is_empty() {
        system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
    }

    // 落库:编辑+截断后的历史先持久化(前端立即反映已截断旧回复)
    save_conversation(&state.ai_session, &state.db, &conv_id, None, None, true).await;

    let session_arc = state.ai_session.clone();
    let tools_arc = state.ai_tools.clone();
    let db = state.db.clone();
    let app_handle = app.clone();
    let knowledge_config = state.knowledge_config.lock().await.clone();
    let llm_concurrency = state.llm_concurrency.clone();
    let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);

    tauri::async_runtime::spawn(async move {
        run_agentic_loop(
            session_arc,
            tools_arc,
            db,
            app_handle,
            provider_config,
            system_prompt,
            conv_id,
            knowledge_config,
            llm_concurrency,
            max_iterations,
            max_retries,
            0,
            model_override,
        )
        .await;
    });

    Ok("ok".to_string())
}

/// 强制发送消息（B-260616-02: L2 发送韧性；BUG-260617-04: 原子复位+占用消除双锁竞态）
///
/// 当后端 generating=true 残留（HMR/异常退出等）导致 sendMessage 被拦截时，
/// 前端可调此命令强制复位 generating + 清审批，然后发送新消息。
///
/// 旧实现先 lock 复位 generating→释放锁→调 ai_chat_send 再 lock，两锁之间另一个发送
/// IPC(force_send/send)可插入占用 generating=true → ai_chat_send 因 generating=true 被
/// reject → 前端以为已强制发送但后端拒绝,状态不一致(BUG-260617-04)。
///
/// 修法:把"软停止复位 + 新消息占用 generating"合并到**同一把锁**,消除竞态窗口;
/// 其后 system prompt 构建 + spawn run_agentic_loop 与 ai_chat_send 同款(照 ai_chat_edit
/// 的"锁内占用+锁外 spawn"模式),不走 ai_chat_send 门控(避免再次过 generating 检查)。
#[tauri::command]
pub async fn ai_chat_force_send(
    app: AppHandle,
    state: State<'_, AppState>,
    message: String,
    language: Option<String>,
    skill: Option<String>,
    model_override: Option<String>,
    // F-260614-05 Phase 2c: 透传 parts(强制发送同款多模态支持)。
    // FR-S1 核验:ContentPart Image base64 是图片数据非 api_key,不入敏感面,语义同 ai_chat_send。
    parts: Option<Vec<ContentPart>>,
    // Input Augmentation:@ mention 区间元数据(同 ai_chat_send,语义一致)。
    mention_spans: Option<Vec<MentionSpanDto>>,
    // F-260616-09 B 批4(决策 e 真并发上线):加 conversation_id 参数,强制复位+发送仅作用于目标 conv。
    //   None/空 → fallback active(旧调用方兼容)。
    conversation_id: Option<String>,
) -> Result<String, String> {
    // 获取活跃提供商(只读,失败可直接返回,不占用 generating)
    let provider_config = super::super::prompt::get_active_provider(&state).await?;

    // 原子"复位 + 占用":同一把锁内先清目标 conv 的旧生成态,再占用 generating + 追加用户消息。
    // stop_flag 置 true(清旧)随即 false(新 loop 起跑)在锁内瞬变,无人能观察中间态;
    // 关键是复位与占用之间无锁释放窗口,杜绝并发 send IPC 抢占 generating。
    //
    // F-260616-09 B 批4(决策 e):force_send 仅复位**目标 conv 自己**的 generating(用户当前面板),
    // 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
    // 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
    let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
        let __lock_t1473 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        // 目标 conv:入参优先 → active → 懒创建。
        let target = conversation_id.clone().filter(|s| !s.is_empty())
            .or_else(|| session.active_conversation_id.clone());
        let target = match target {
            Some(id) => id,
            None => {
                let id = new_id();
                session.active_conversation_id = Some(id.clone());
                session.active_conv_created_at = Some(now_millis());
                id
            }
        };
        if session.active_conversation_id.as_deref() != Some(target.as_str()) {
            session.active_conversation_id = Some(target.clone());
            if session.active_conv_created_at.is_none() {
                session.active_conv_created_at = Some(now_millis());
            }
        }
        // ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
        // was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
        // 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
        let was_gen = state.conv_states.is_active(&target);
        // SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
        // 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
        finalize_pending_placeholders(&mut *session, &target, "已取消");
        // 阶段3a 单真相源合并:单表 retain(仅清目标 conv,保留其他 conv,kind 不区分)。
        session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
        {
            let conv = session.conv(&target);
            // B-Phase3:conv_state 写切 ConvStateStore 单源。
            if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) {
                tracing::warn!(
                    conv_id = %target,
                    error = %e,
                    "[ai] conv_states 强制发送→Idle 非法(不阻断强制发送)"
                );
            }
            conv.stop_flag.store(true, Ordering::SeqCst);
        }
        // ② 同锁内立即占用 + 追加用户消息(逻辑照 ai_chat_send,无生成态拦截——
        // 这是"强制"语义本身,前面已主动复位)
        let conv = session.conv(&target);
        // 批3 收口:生成态由 run_agentic_loop 入口 ConvState→Generating 迁移设置(不再手动赋值)。
        conv.stop_flag.store(false, Ordering::SeqCst);
        conv.agent_language = language.clone();
        // F-260616-11: 强制发送 = 新对话生命周期起点,iteration 从头计数。
        conv.iteration_used = 0;
        conv.model_override = model_override.clone();
        // user content 处理(技能调用空文本标记 /{skillname}),照 ai_chat_send
        let user_content = if message.trim().is_empty() {
            if let Some(ref skill_name) = skill {
                format!("/{}", skill_name)
            } else {
                message.clone()
            }
        } else {
            message.clone()
        };
        // parts 非空走 user_parts(多模态),空/None 走 user()(纯文本零回归)
        if let Some(ps) = parts.as_ref().filter(|p| !p.is_empty()) {
            conv.messages.push(ChatMessage::user_parts(&user_content, ps.clone()));
        } else {
            conv.messages.push(ChatMessage::user(&user_content));
        }
        // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
        let user_msg_id = conv.messages.last_user_message_id();
    // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
        let __hold1473 = __lock_t1473.elapsed();
        if __hold1473 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
        }
        (was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
    };

    // 通知前端目标 conv 旧生成已结束(若先前在生成;emit 在锁外,避免持锁调 runtime emit)
    if let Some(ref cid) = old_conv_id {
        let ev = AiChatEvent::AiCompleted {
            total_tokens: 0,
            prompt_tokens: 0,
            completion_tokens: 0,
            incomplete: None,
            conversation_id: Some(cid.clone()),
            pinned_goals: old_pinned_goals.clone(),
        };
        let _ = app.emit("ai-chat-event", ev.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
    }

    // system prompt 构建(照 ai_chat_send):augmentation 注入 + 知识注入顺序一致。
    let _tool_defs = state.ai_tools.tool_definitions();
    let lang = language.unwrap_or_else(|| "zh-CN".to_string());
    // 去重:同 ai_chat_send,从 mention_spans 提取被@的 project/task id 从清单排除。
    let (excl_proj, excl_task) = excluded_ids_from_mentions(&mention_spans);
    let mut system_prompt = build_system_prompt_with_excluded(&state, &lang, &excl_proj, &excl_task).await;
    // Augmentation 注入:/ 技能 + @ mention 经统一 Resolver 投影(语义同 ai_chat_send)。
    let aug_seg = resolve_and_inject(&state, &provider_config, &skill, &mention_spans, &lang).await;
    if !aug_seg.is_empty() {
        system_prompt = format!("{}\n\n---\n{}", aug_seg, system_prompt);
    }
    // conv_id 已在上方状态占用块得出。
    // 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口(同消息取 text+id,②口径修复)。
    // 此路径 message 刚 push 为末条 active user,helper 读到的即本轮 user,语义等价。
    {
        let config = state.knowledge_config.lock().await.clone();
        system_prompt = inject_knowledge_into_prompt(&state, &conv_id, system_prompt, &config).await;
    }

    // 后台 spawn agentic loop(照 ai_chat_send:229-242 / ai_chat_edit:695-721 同款)
    let session_arc = state.ai_session.clone();
    let tools_arc = state.ai_tools.clone();
    let db = state.db.clone();
    let app_handle = app.clone();
    let knowledge_config = state.knowledge_config.lock().await.clone();
    let llm_concurrency = state.llm_concurrency.clone();
    let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
    let max_retries = state.agent_max_retries.load(Ordering::SeqCst);

    tauri::async_runtime::spawn(async move {
        run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id, knowledge_config, llm_concurrency, max_iterations, max_retries, 0, model_override).await;
    });

    Ok("ok".to_string())
}

/// 停止当前 AI 生成
///
/// 两种场景：
/// - loop 正在流式生成：置 stop_flag，stream_llm / 循环检查点尽快退出并 emit AiCompleted
/// - 有挂起审批（loop 已 return 等待中）：stop_flag 无人读取，直接清审批 + 复位 generating，
///   否则停止按钮表面无反应、会话卡在 generating=true
#[tauri::command]
pub async fn ai_chat_stop(
    state: State<'_, AppState>,
    app: AppHandle,
    // F-260616-09 B 批4(决策 e 真并发上线):加 conversation_id 参数,停止仅作用于目标 conv。
    //   None/空 → fallback active(旧调用方兼容)。
    conversation_id: Option<String>,
) -> Result<(), String> {
    let __lock_t1611 = std::time::Instant::now();
    let mut session = state.ai_session.lock().await;
    // 目标 conv:入参优先 → active。
    let target = conversation_id.filter(|s| !s.is_empty())
        .or_else(|| session.active_conversation_id.clone())
        .unwrap_or_default();
    if target.is_empty() {
        // 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
        let __hold1611 = __lock_t1611.elapsed();
        if __hold1611 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
        }
        drop(session);
        return Ok(());
    }
    // B-Phase3:读侧切无锁 conv_states(单源收敛)。
    let is_gen = state.conv_states.is_active(&target);
    if !is_gen {
        return Ok(());
    }
    // 目标 conv 是否有待审批(仅本 conv 的)。
    let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
    if has_pending {
        // 审批等待态：loop 已退出，直接清理目标 conv 让其立即可用
        // SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
        // 清 pending 前先终态化为"会话已停止"。仅清目标 conv 的 pending(retain 保留其他 conv)。
        finalize_pending_placeholders(&mut *session, &target, "会话已停止");
        session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
        let conv = session.conv(&target);
        // B-Phase3:conv_state 写切 ConvStateStore 单源(per_conv 字段已删)。
        {
            let conv_states = app.state::<AppState>().conv_states.clone();
            if let Err(e) = conv_states.transition(&target, ConvState::Idle) {
                tracing::warn!(
                    conv_id = %target,
                    error = %e,
                    "[ai] conv_states ai_chat_stop 审批分支→Idle 非法(不阻断 emit AiCompleted)"
                );
            }
        }
        conv.stop_flag.store(true, Ordering::SeqCst); // 双保险：防 try_continue 误判重启
        let pinned_goals = conv.pinned_goals.clone();
        let __hold1611 = __lock_t1611.elapsed();
        if __hold1611 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
        }
        drop(session);
        let ev = AiChatEvent::AiCompleted {
            total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
            conversation_id: Some(target),
            pinned_goals,
        };
        let _ = app.emit("ai-chat-event", ev.clone());
        // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
        let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
        return Ok(());
    }
    // 流式生成中：置位让目标 conv 的 loop 自行收尾
    let notify = {
        let conv = session.conv(&target);
        conv.stop_flag.store(true, Ordering::SeqCst);
        conv.notify.clone()
    };
    // B-260615-14:置位后立即 notify_one 唤醒阻塞在 stream.next() 的 select!，
    // 不再等 30s 心跳 tick 或 120s idle timeout 才轮到 stop_flag 检查。
    // Notify 仅承载「即时唤醒」，停止真值仍由 stop_flag 决定（stream_llm 唤醒后再判 flag）。
    notify.notify_one();
    let conv_id = Some(target);
    let __hold1611 = __lock_t1611.elapsed();
    if __hold1611 > std::time::Duration::from_millis(30) {
        eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
    }
    drop(session);

    // B-260615-13 兜底任务：loop 若 panic/异常退出漏发收尾，stop_flag 无人读，
    // 用户点 stop 无反应、generating 卡 true。这里 sleep 短超时后重检目标 conv generating，
    // 仍 true(loop 没复位)则强制复位 + emit AiCompleted 通知前端收尾。
    // 正常路径(loop 活自行复位)此时 generating 已 false，无操作退出。
    let session_arc = state.ai_session.clone();
    let app_handle = app.clone();
    tauri::async_runtime::spawn(async move {
        tokio::time::sleep(std::time::Duration::from_secs(3)).await;
        let __lock_t1686 = std::time::Instant::now();
        let mut session = session_arc.lock().await;
        let target_owned = conv_id.clone().unwrap_or_default();
        // B-Phase3:读侧切无锁 conv_states。
        let still_gen = if target_owned.is_empty() {
            false
        } else {
            app.state::<AppState>().conv_states.is_active(&target_owned)
        };
        if still_gen {
            // B-Phase3:conv_state 写切 ConvStateStore 单源。
            {
                let conv_states = app.state::<AppState>().conv_states.clone();
                if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) {
                    tracing::warn!(
                        conv_id = %target_owned,
                        error = %e,
                        "[ai] conv_states 3秒超时→Idle 非法(不阻断 emit AiCompleted)"
                    );
                }
            }
            let pinned_goals = session.conv(&target_owned).pinned_goals.clone();
            let __hold1686 = __lock_t1686.elapsed();
            if __hold1686 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
            }
            drop(session); // 释放锁后再 emit，避免持锁调 runtime emit
            let ev = AiChatEvent::AiCompleted {
                total_tokens: 0,
                prompt_tokens: 0,
                completion_tokens: 0,
                incomplete: None,
                conversation_id: conv_id,
                pinned_goals,
            };
            let _ = app_handle.emit("ai-chat-event", ev.clone());
            // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
            let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
        } else {
            let __hold1686 = __lock_t1686.elapsed();
            if __hold1686 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
            }
        }
    });

    Ok(())
}

/// 续跑 agentic 循环（F-260616-03：达 max_iterations 暂停态用户点「继续」）
///
/// 场景：run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// generating=true 暂停。用户点继续调本命令 → 复位 stop_flag（防上一轮残留致续跑入口即退出）
/// → 调 try_continue_agent_loop 重新 spawn run_agentic_loop。iteration 从 0 重计(天然再跑
/// max_iterations 轮,决策 a),F-260616-11 落地后:重置 session.iteration_used=0 + 传
/// start_iteration=0(达 max 续跑重计);审批续跑(ai_approve)则累计传 session.iteration_used,
/// 两路径区分见 run_agentic_loop 达 max 分支注释。
///
/// 校验复用 ai_approve 模式：generating 必须为 true（暂停态）+ active_conversation_id 一致性
/// （防陈旧 loop 续跑污染新对话）。无硬上限防无限续（决策 b：用户主动授权 = 同意烧 token）。
#[tauri::command]
pub async fn ai_continue_loop(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
) -> Result<String, String> {
    {
        let __lock_t1744 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
        //   (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
        // B-Phase3:读侧切无锁 conv_states。
        if !state.conv_states.is_active(&conversation_id) {
            let __hold1744 = __lock_t1744.elapsed();
            if __hold1744 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
            }
            return Err("AI 未在暂停态，无需继续".to_string());
        }
        let conv = session.conv(&conversation_id);
        // 复位停止信号：暂停态可能因上一轮 stop_flag 残留为 true，续跑 loop 入口会立即退出走完成流程
        conv.stop_flag.store(false, Ordering::SeqCst);
        // F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
        // 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
        conv.iteration_used = 0;
        let __hold1744 = __lock_t1744.elapsed();
        if __hold1744 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
        }
    }
    // 复用审批恢复续 loop 入口（不重写 loop），其内部 spawn run_agentic_loop
    // F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
    try_continue_agent_loop(&app, &state, &conversation_id, 0).await;
    Ok("ok".to_string())
}

/// 停止 agentic 循环并走完成流程（F-260616-03：达 max_iterations 暂停态用户点「停止」）
///
/// 场景：run_agentic_loop 达 max_iterations 未收敛 → emit AiMaxRoundsReached + 保持
/// ConvState=Generating 暂停。用户点停止调本命令 → 复位生成态(ConvState→Idle) + emit AiCompleted（标收敛）。
///
/// 不重复 save 逻辑：暂停态进入前 run_agentic_loop 已 save_conversation 落库（agentic/mod.rs
/// 达上限分支），此处仅复位生成态 + emit AiCompleted 通知前端收尾。校验复用 ai_approve
/// 模式（生成态 + active_conversation_id 一致性）。
#[tauri::command]
pub async fn ai_stop_loop(
    app: AppHandle,
    state: State<'_, AppState>,
    conversation_id: String,
) -> Result<String, String> {
    let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
    {
        let __lock_t1782 = std::time::Instant::now();
        let mut session = state.ai_session.lock().await;
        // F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
        //   (真并发下后台 conv 也应可停止)。
        // B-Phase3:读侧切无锁 conv_states。
        if !state.conv_states.is_active(&conversation_id) {
            let __hold1782 = __lock_t1782.elapsed();
            if __hold1782 > std::time::Duration::from_millis(30) {
                eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
            }
            return Err("AI 未在暂停态，无需停止".to_string());
        }
        let conv = session.conv(&conversation_id);
        // 置 stop_flag 双保险：防 try_continue 误判重启（与 ai_chat_stop 审批分支一致）
        conv.stop_flag.store(true, Ordering::SeqCst);
        // B-Phase3:conv_state 写切 ConvStateStore 单源。
        if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Idle) {
            tracing::warn!(
                conv_id = %conversation_id,
                error = %e,
                "[ai] conv_states ai_stop_loop→Idle 非法(不阻断 emit AiCompleted)"
            );
        }
        pinned_goals = conv.pinned_goals.clone();
        let __hold1782 = __lock_t1782.elapsed();
        if __hold1782 > std::time::Duration::from_millis(30) {
            eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
        }
    }
    // 暂停态进入前已 save_conversation，此处零 token 上报仅作收敛信号（与 try_continue 补发 AiCompleted 一致）
    let ev = AiChatEvent::AiCompleted {
        total_tokens: 0,
        prompt_tokens: 0,
        completion_tokens: 0,
        incomplete: None,
        conversation_id: Some(conversation_id),
        pinned_goals,
    };
    let _ = app.emit("ai-chat-event", ev.clone());
    // L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
    let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
    Ok("ok".to_string())
}

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

    // ============================================================
    // G1 extract_pinned_goal 单测
    // ============================================================

    #[test]
    fn test_extract_plain_goal() {
        let goal = extract_pinned_goal("实现代码审查功能");
        assert_eq!(goal, "实现代码审查功能");
    }

    #[test]
    fn test_extract_strip_mention_suffix() {
        let goal = extract_pinned_goal("分析灵感模块不足 [项目: DevFlow]");
        assert_eq!(goal, "分析灵感模块不足");
    }

    #[test]
    fn test_extract_strip_mention_prefix() {
        let goal = extract_pinned_goal("[任务: T-123] 修复登录页白屏");
        assert_eq!(goal, "修复登录页白屏");
    }

    #[test]
    fn test_extract_empty_input() {
        let goal = extract_pinned_goal("");
        assert!(goal.is_empty());
    }

    #[test]
    fn test_extract_only_mention() {
        let goal = extract_pinned_goal("[项目: DevFlow]");
        assert!(goal.is_empty());
    }

    #[test]
    fn test_extract_english_kind() {
        let goal = extract_pinned_goal("Fix login bug [task: T-789]");
        assert_eq!(goal, "Fix login bug");
    }

    #[test]
    fn test_extract_fullwidth_colon() {
        let goal = extract_pinned_goal("重构模块 [项目：DevFlow]");
        assert_eq!(goal, "重构模块");
    }

    #[test]
    fn test_extract_truncate_long() {
        let long = "a".repeat(GOAL_MAX_CHARS + 100);
        let goal = extract_pinned_goal(&long);
        assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
    }

    // ============================================================
    // 低价值目标过滤
    // ============================================================

    #[test]
    fn test_extract_mention_unclosed() {
        let goal = extract_pinned_goal("测试 [项目: DevFlow");
        assert_eq!(goal, "测试 [项目: DevFlow");
    }
}
