修复: aichat上下文管理与信息组织P1批

逐件(证据:深查-aichat质量/信息UX-2026-10-17):
- 恢复链套娃:RECOVERY_ARTIFACT_PREFIX双侧过滤+单点拦截(E1)
- message_repo全量重写保留原created_at(E11,治1750条时间线污染)
- search_files/grep 0命中附阴性存疑提示(E6,共享negative_hit_hint)
- 项目清单三源活跃度排序+system prompt指纹对齐(E4跑题)
- 会话列表活跃/归档offset分页+侧栏加载更多(F4-1,治50硬上限)
- inject消息source标记(轮次分桶不误计发起,V52同源)
- 提示词回复风格条款中英双语(反口水话,含测试断言)
- i18n补键:复制/折叠量/发送title/历史定位提示(F1-1/F2-1/F6-1)

验收:cargo test --workspace 2281 passed 0 failed;vue-tsc EXIT 0
This commit is contained in:
lxy
2026-10-18 23:15:45 +08:00
parent a273a86a7d
commit 05d0f89c16
10 changed files with 570 additions and 57 deletions
@@ -232,14 +232,24 @@ pub async fn ai_conversation_create(
///
/// 截断按组独立:活跃/归档各自取 limit,归档项不挤占活跃名额(侧栏归档分组数据源依赖
/// include_archived=true,见 useAiConversations.loadConversations)。
///
/// F4-1(2026-10-17)分页:active_offset/archived_offset 按组跳过前 N 条(默认 0=第一页,
/// 旧调用方零改动)。治「会话列表+搜索硬上限 50」——实测 115 条活跃主会话 57% 在桌面
/// 端零入口;侧栏首屏仍一页(50/组)保启动性能,「加载更多」按 offset 续拉,搜索前前端
/// 逐页拉满(搜索跑在已加载列表上,截断列表=搜索失灵)。offset 超组长度返回该组空页
/// (自然收敛,无需显式 has_more 字段:页内某组满额 limit 即可能还有下一页)。
#[tauri::command]
pub async fn ai_conversation_list(
state: State<'_, AppState>,
limit: Option<usize>,
include_archived: Option<bool>,
active_offset: Option<usize>,
archived_offset: Option<usize>,
) -> Result<Vec<serde_json::Value>, String> {
let limit = limit.unwrap_or(50);
let include_archived = include_archived.unwrap_or(false);
let active_offset = active_offset.unwrap_or(0);
let archived_offset = archived_offset.unwrap_or(0);
let records = state.ai_conversations.list_all().await.map_err(err_str)?;
// list_all 已按 created_at DESC(最新在前);默认排除归档 + 截断 limit。
// origin 非空(子代理轨迹,V44)不进侧栏列表——轨迹在库可追溯,展示面只留主会话。
@@ -273,20 +283,25 @@ pub async fn ai_conversation_list(
"loop_state": r.loop_state,
})
}).collect();
// 分组截断:活跃/归档各自独立取 limit,防归档项挤占活跃名额(前端侧栏活跃列表被截空)。
// 原单 take(limit) 在 include_archived=true 时归档项混排会占满 50 名额。
let mut active = Vec::with_capacity(limit);
let mut archived = Vec::with_capacity(limit);
for s in summaries {
if s.get("archived").and_then(|v| v.as_bool()) == Some(true) {
if archived.len() < limit { archived.push(s); }
} else if active.len() < limit {
active.push(s);
}
}
// 活跃在前(侧栏主区),归档在后(归档分组);各自保持 created_at DESC 原序。
active.extend(archived);
Ok(active)
Ok(paginate_by_group(summaries, limit, active_offset, archived_offset))
}
/// F4-1:摘要按组独立分页——活跃/归档各自 skip(offset)+take(limit),活跃在前归档在后,
/// 组内保持传入原序(created_at DESC)。offset=0 与旧「分组截断」口径逐字节等价
/// (回归保护见 paginate_first_page_matches_legacy_grouped_truncation)。
/// 抽纯函数供单测:分组正确性/offset 续拉/越界空页/归档不挤占活跃名额。
fn paginate_by_group(
summaries: Vec<serde_json::Value>,
limit: usize,
active_offset: usize,
archived_offset: usize,
) -> Vec<serde_json::Value> {
let (active, archived): (Vec<_>, Vec<_>) = summaries
.into_iter()
.partition(|s| s.get("archived").and_then(|v| v.as_bool()) != Some(true));
let mut page: Vec<serde_json::Value> = active.into_iter().skip(active_offset).take(limit).collect();
page.extend(archived.into_iter().skip(archived_offset).take(limit));
page
}
/// B3(无人值守可靠性专项):列出可恢复会话(loop_state='parked')。
@@ -391,11 +406,25 @@ pub(crate) async fn recover_parked_inner(
.await
}
/// 恢复产物前缀:恢复链自身生成的引导消息以它开头(恢复产物非用户意图,任何情况下
/// 都不该作为有效目标回放——2026-10-15 目标语义重构约定)。递归套娃根因:恢复消息
/// 经 begin_turn(chat.rs:950/2461)被记为 WorkingContext Active 目标落库,下次恢复
/// 回放时被当有效目标嵌套进新恢复消息,逐层加深(实证 conv 76730ce8 seq 250:
/// 目标文本=上一条恢复消息原文截 200 字符)。防线收敛在恢复链单点:
/// parse_pinned_goals 反解剔除 + build_recovery_message 回放剔除,同判据。
pub(crate) const RECOVERY_ARTIFACT_PREFIX: &str = "[系统恢复]";
/// 恢复产物判定:目标文本以恢复前缀开头即恢复链自产物,不作为有效目标。
fn is_recovery_artifact(text: &str) -> bool {
text.starts_with(RECOVERY_ARTIFACT_PREFIX)
}
/// 目标快照:按最终有效意图生成 parked 恢复引导消息。
///
/// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活。
/// 快照全空(旧会话无快照)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 目标 + unresolved 未解项;中途被调整作废(Cancelled)或已完成(Completed)的目标不复活;
/// 恢复产物([系统恢复] 前缀)剔除——回放它即递归套娃,历史污染目标在此单点拦截。
/// 快照全空(旧会话无快照/全被过滤)回退 [`PARKED_RECOVERY_MESSAGE`],行为同今日。
/// 不另立存储:快照即 working_context_json + pinned_goals 两列的语义化读取(单一真值来源)。
pub(crate) fn build_recovery_message(
working_context: &df_ai::context_helpers::WorkingContext,
@@ -405,12 +434,18 @@ pub(crate) fn build_recovery_message(
let mut seen = std::collections::HashSet::new();
let mut active: Vec<&str> = Vec::new();
for g in &working_context.goals {
if matches!(g.status, GoalStatus::Active) && seen.insert(g.text.clone()) {
if matches!(g.status, GoalStatus::Active)
&& !is_recovery_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
for g in pinned_goals {
if matches!(g.status, crate::commands::ai::GoalStatus::Active) && seen.insert(g.text.clone()) {
if matches!(g.status, crate::commands::ai::GoalStatus::Active)
&& !is_recovery_artifact(&g.text)
&& seen.insert(g.text.clone())
{
active.push(&g.text);
}
}
@@ -442,7 +477,8 @@ pub(crate) fn build_recovery_message(
/// pinned_goals 落库反解(新旧格式兼容):新格式 [{text,status}] 保 status,
/// 旧格式 ["text"] 转 Active;非数组/坏 JSON 一律空(降级不炸)。switch 恢复与
/// parked 恢复共用(DRY,单一事实源)。
/// parked 恢复共用(DRY,单一事实源)。恢复产物([系统恢复] 前缀)剔除——存量
/// 污染目标不进内存,switch 载入后下轮 save 重写 pinned_goals 即自愈落库。
pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::GoalEntry> {
let parsed: Option<serde_json::Value> = raw.and_then(|s| serde_json::from_str(s).ok());
match parsed {
@@ -468,6 +504,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec<crate::commands::ai::
Some(crate::commands::ai::GoalEntry::new(text))
}
})
.filter(|e| !is_recovery_artifact(&e.text))
.collect(),
_ => vec![],
}
@@ -1477,6 +1514,73 @@ mod tests {
assert_eq!(back.timestamp, Some(123));
}
// ============================================================
// F4-1 会话列表分页(paginate_by_group:活跃/归档各自 offset+limit)
// ============================================================
fn conv_val(id: &str, archived: bool) -> serde_json::Value {
serde_json::json!({ "id": id, "archived": archived })
}
/// 首页(offset=0)与旧「分组截断」口径等价:各组前 limit 条,活跃在前归档在后。
#[test]
fn paginate_first_page_matches_legacy_grouped_truncation() {
// 60 活跃 + 60 归档,limit 50:旧口径各取 50(归档不挤占活跃名额)
let summaries: Vec<_> = (0..60).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..60).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
let page = paginate_by_group(summaries, 50, 0, 0);
assert_eq!(page.len(), 100, "各组独立取满 50");
assert!(page.iter().take(50).all(|s| s["archived"] == false), "前 50 全活跃");
assert!(page.iter().skip(50).all(|s| s["archived"] == true), "后 50 全归档");
assert_eq!(page[0]["id"], "a0");
assert_eq!(page[49]["id"], "a49");
assert_eq!(page[50]["id"], "r0");
assert_eq!(page[99]["id"], "r49");
}
/// offset 续拉:第二页从各组 offset 处继续,两组独立推进互不影响
/// (115 活跃场景:第一页 a0..a49,第二页 active_offset=50 拉 a50..a99)。
#[test]
fn paginate_offset_advances_each_group_independently() {
let summaries: Vec<_> = (0..115).map(|i| conv_val(&format!("a{i}"), false))
.chain((0..3).map(|i| conv_val(&format!("r{i}"), true)))
.collect();
// 第二页:活跃 offset 50(满页 50 条),归档 offset 0(仅 3 条全回)
let page2 = paginate_by_group(summaries.clone(), 50, 50, 0);
assert_eq!(page2.len(), 53);
assert_eq!(page2[0]["id"], "a50");
assert_eq!(page2[49]["id"], "a99");
assert_eq!(page2[50]["id"], "r0", "归档组从 0 起独立分页,不被活跃 offset 影响");
// 第三页:活跃 offset 100(尾页 15 条),归档 offset 3(越界空)
let page3 = paginate_by_group(summaries, 50, 100, 3);
assert_eq!(page3.len(), 15);
assert_eq!(page3[0]["id"], "a100");
assert_eq!(page3[14]["id"], "a114");
assert!(page3.iter().all(|s| s["archived"] == false), "归档越界返回该组空页");
}
/// offset 超组长度的组返回空页(自然收敛);两组同时越界返回空 Vec。
#[test]
fn paginate_offset_beyond_length_yields_empty() {
let summaries = vec![conv_val("a0", false), conv_val("r0", true)];
assert!(paginate_by_group(summaries.clone(), 50, 5, 0).iter().all(|s| s["archived"] == true));
assert!(paginate_by_group(summaries.clone(), 50, 0, 5).iter().all(|s| s["archived"] == false));
assert!(paginate_by_group(summaries, 50, 5, 5).is_empty());
}
/// 输入混排(归档穿插在活跃间,list_all 全量 DESC 实际形态)时 partition 仍按组归位,
/// 组内保持传入原序。
#[test]
fn paginate_partition_from_interleaved_input() {
let summaries = vec![
conv_val("a0", false), conv_val("r0", true), conv_val("a1", false), conv_val("r1", true),
];
let page = paginate_by_group(summaries, 50, 0, 0);
let ids: Vec<&str> = page.iter().map(|s| s["id"].as_str().unwrap()).collect();
assert_eq!(ids, vec!["a0", "a1", "r0", "r1"], "活跃全量在前(原序),归档全量在后(原序)");
}
// ============================================================
// F-260619-03 批次 B 端到端 round-trip(写路径全链:映射 → replace → list → 还原)
// ============================================================
@@ -1789,4 +1893,72 @@ mod tests {
assert!(parse_pinned_goals(Some("not-json")).is_empty());
assert!(parse_pinned_goals(None).is_empty());
}
// ── 恢复产物过滤(递归套娃防线,conv 76730ce8 seq 250 实证) ──
/// 构造一条上一代恢复消息全文(截 200 字符,复刻 begin_turn 误记目标的现场形态)。
fn prev_recovery_text() -> String {
let full = format!(
"{} 应用重启导致上次任务中断,已完成进度已保留。请按以下目标快照继续完成未竟任务:\n\n有效目标:\n- 检查 各个 u-claw 实例状态\n\n若快照不足以判断进度,先向用户确认已完成步骤再继续。",
RECOVERY_ARTIFACT_PREFIX
);
full.chars().take(200).collect()
}
#[test]
fn recovery_message_zero_nesting_with_polluted_goals() {
use df_ai::context_helpers::GoalStatus;
// 嵌套恢复现场:WorkingContext.goals 与 pinned_goals 都躺着上一条恢复消息全文,
// 两侧 Active——未过滤时它会被当有效目标嵌进新恢复消息(套娃逐层加深)。
let wc = wc_with_goals(&[
(&prev_recovery_text(), GoalStatus::Active),
("交付 v2 登录接口", GoalStatus::Active),
]);
let pinned = vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("补齐单测覆盖".to_string()),
];
let msg = build_recovery_message(&wc, &pinned);
// 输出只允许保留消息头一处恢复前缀(零套娃),正常用户目标不受影响
assert_eq!(
msg.matches(RECOVERY_ARTIFACT_PREFIX).count(),
1,
"恢复产物目标必须被剔除,输出仅消息头一处前缀: {msg}"
);
assert!(msg.contains("交付 v2 登录接口"), "正常用户目标须保留: {msg}");
assert!(msg.contains("补齐单测覆盖"), "正常钉扎目标须保留: {msg}");
assert!(
!msg.contains("有效目标:\n- [系统恢复]"),
"上一代恢复消息不得作为目标行回放: {msg}"
);
}
#[test]
fn recovery_message_all_polluted_falls_back_to_constant() {
use df_ai::context_helpers::GoalStatus;
// 快照只剩恢复产物(历史最深套娃态):全部过滤后回退常量,套娃链在此截断
let wc = wc_with_goals(&[(&prev_recovery_text(), GoalStatus::Active)]);
let pinned = vec![crate::commands::ai::GoalEntry::new(prev_recovery_text())];
assert_eq!(
build_recovery_message(&wc, &pinned),
PARKED_RECOVERY_MESSAGE,
"全污染快照过滤后按空快照回退常量"
);
}
#[test]
fn parse_pinned_goals_drops_recovery_artifacts_idempotent() {
// 存量污染反解剔除:恢复前缀条目不进内存(幂等——清洗后再解析结果不变)
let polluted = serde_json::to_string(&vec![
crate::commands::ai::GoalEntry::new(prev_recovery_text()),
crate::commands::ai::GoalEntry::new("正常目标保留".to_string()),
])
.unwrap();
let parsed = parse_pinned_goals(Some(&polluted));
assert_eq!(parsed.len(), 1, "恢复产物条目须剔除: {parsed:?}");
assert_eq!(parsed[0].text, "正常目标保留");
// 幂等:清洗结果重解析不变
let cleaned = serde_json::to_string(&parsed).unwrap();
assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1);
}
}
+46 -10
View File
@@ -127,6 +127,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\
- {}\n\
- {}\n\
- Response style: lead with the conclusion and the action/result; omit restating the user's question, pleasantries, and summary closers; prefer bullet points over paragraphs; do not expand on what was not asked. Explain whenever explanation is needed — cut filler, not substance.\n\
## Focus\n\
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
@@ -160,6 +161,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) {
- {}\n\
- {}\n\
- {}\n\
- 回复风格:结论先行,直接给动作/结果;不复述用户问题,不做客套开场和总结性收尾;要点优先于段落;用户没问的不展开。需要解释时照常解释——省的是空话,不是内容。\n\
## 聚焦准则\n\
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
@@ -445,7 +447,10 @@ async fn entity_fingerprint(db: &Arc<Database>, sql: &'static str) -> Option<Str
/// - 日期:env_profile_line 注入当日日期,跨天必须失效(否则缓存串日期过期)。
/// - lang:system_prompt_parts / 各策略段 / 注明语随 lang 分支。
/// - custom_prompt:与 build 内读的同一设置,变更即失效。
/// - 活跃 project/task 的 id+updated_at:新增/软删/改 updated_at 均反映到指纹(比 MAX 更稳)。
/// - 活跃 project/task 指纹:task 取 id+updated_at;project 取 id+活跃时间(三源取大:
/// tasks.updated_at 最新/project_events 最新 created_at/projects.updated_at,仅未归档未删除,
/// 与「当前项目」清单的 list_active_with_activity 排序输入对齐)——任务更新/归档变化均
/// 反映到指纹,清单排序变了缓存串必失效。
/// - 去重排除集:被@实体走 augmentation 精准投影,排除集变化会改变清单内容,须纳入指纹。
async fn system_prompt_fingerprint(
state: &AppState,
@@ -463,7 +468,11 @@ async fn system_prompt_fingerprint(
.unwrap_or_default();
let proj_fp = entity_fingerprint(
&state.db,
"SELECT id, updated_at FROM projects WHERE deleted_at IS NULL",
"SELECT p.id, MAX(\
IFNULL((SELECT MAX(t.updated_at) FROM tasks t WHERE t.project_id = p.id AND t.deleted_at IS NULL), 0), \
IFNULL((SELECT MAX(e.created_at) FROM project_events e WHERE e.project_id = p.id), 0), \
IFNULL(p.updated_at, 0)\
) FROM projects p WHERE p.deleted_at IS NULL AND p.archived = 0",
)
.await?;
let task_fp = entity_fingerprint(
@@ -586,24 +595,25 @@ async fn build_context_lists(
) -> String {
let (_, proj_label, task_label) = system_prompt_parts(lang);
let mut out = String::new();
// 附加当前数据上下文
if let Ok(projects) = state.projects.list_active().await {
// 附加当前数据上下文。项目段按最近活跃排序(任务活跃为主信号),治创建序误导项目绑定
// (创建序会把早停摆项目排在清单首位,AI 误当最高优先项目锚点)。
if let Ok(projects) = state.projects.list_active_with_activity().await {
if !projects.is_empty() {
out.push_str(proj_label);
// system prompt 前缀克制:仅最近 20 个项目,防 context 膨胀
// 去重:被 @ 的项目跳过(已有 augmentation 精准投影,清单再现致同一实体两次入 prompt)
for p in projects.iter().take(20) {
if excluded_project_ids.iter().any(|id| id == &p.id) {
if excluded_project_ids.iter().any(|id| id == &p.record.id) {
continue;
}
out.push_str(&format!(
"- {} (id: {}) ({}): {}\n",
p.name,
p.id,
p.status.as_str(),
truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS)
p.record.name,
p.record.id,
p.record.status.as_str(),
truncate_desc(&p.record.description, CONTEXT_DESC_MAX_CHARS)
));
if let Some(ref dir) = p.path {
if let Some(ref dir) = p.record.path {
out.push_str(&format!(" 目录: {}\n", dir));
}
}
@@ -921,6 +931,32 @@ mod tests {
assert!(prefix.contains("聚焦准则"));
}
// M4 回复风格纪律:中文版行为准则含「回复风格」条款(反口水话,不反内容)
#[test]
fn system_prompt_zh_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("zh-CN");
assert!(
prefix.contains("回复风格"),
"中文 system prompt 应含回复风格条款,实际: {}",
prefix
);
// 核心条款抽样:结论先行 + 「省的是空话,不是内容」的度(需要解释时照常解释)
assert!(prefix.contains("结论先行"));
assert!(prefix.contains("省的是空话,不是内容"));
}
// M4 回复风格纪律:英文版 Guidelines 含 Response style 条款
#[test]
fn system_prompt_en_has_response_style_clause() {
let (prefix, _, _) = system_prompt_parts("en");
assert!(
prefix.contains("Response style"),
"英文 system prompt 应含 Response style 条款,实际: {}",
prefix
);
assert!(prefix.contains("cut filler, not substance"));
}
// 文档探索策略段(中/英)存在且含关键引导
#[test]
fn doc_exploration_strategy_section_present_zh_en() {
+38 -3
View File
@@ -1098,7 +1098,7 @@ pub fn register(
}
// output_mode 分派返回结构
let result = match output_mode {
let mut result = match output_mode {
"files_with_matches" => {
// 仅返命中文件路径列表(去重,顺序保留首次命中)
let files: Vec<String> = matches_out.iter()
@@ -1183,6 +1183,11 @@ pub fn register(
})
}
};
// 0 命中「阴性存疑」提示:路径/glob 写法/大小写任一不对都静默 0 命中,
// LLM 拿空结果易直接当「不存在」下结论(实测假阴性被当事实断言 3 轮),强制附一行复核提示。
if total == 0 {
result["hint"] = serde_json::json!(negative_hit_hint("路径/glob 写法/大小写 -i"));
}
Ok(result)
}
);
@@ -1228,10 +1233,13 @@ pub fn register(
let mut out = serde_json::json!({
"path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more
});
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)。
// total=0 时空结果引导(治 LLM 搜不到就放弃/盲目重试)+ 阴性存疑提示(0 命中≠不存在)。
// 注:search_files 已大小写不敏感(内部 pattern.to_lowercase + 包含匹配),文案勿写"换大小写"。
if total == 0 {
out["hint"] = serde_json::json!("未找到匹配文件。可尝试: ① 放宽关键词(更短/字符更少); ② 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配");
out["hint"] = serde_json::json!(format!(
"{}\n未找到匹配文件。可尝试: ① 补 recursive=true 搜子目录(不传只搜当前层); ② 放宽关键词(更短/字符更少); ③ 用 grep 工具做跨文件内容正则搜索(pattern 支持正则, -i 大小写不敏感)。注: search_files 为文件名大小写不敏感包含匹配",
negative_hit_hint("路径/recursive")
));
}
Ok(out)
}
@@ -1675,6 +1683,16 @@ fn file_not_found_error(path: &str) -> anyhow::Error {
anyhow::anyhow!(msg)
}
/// 搜索工具(search_files/grep)0 命中「阴性存疑」提示。
///
/// 治勘察假阴性被当事实断言:search_files 未传 recursive 只搜根目录,
/// 0 命中被 LLM 直接当「功能未实施」连续断言 3 轮。路径/glob/recursive 等范围
/// 参数写错时同样静默 0 命中,故空结果强制附本提示让 LLM 复核后再下结论。
/// 措辞克制一行;`scope` 按工具传入各自的复核点。
fn negative_hit_hint(scope: &str) -> String {
format!("⚠ 0 命中不一定代表不存在——请复核搜索范围({scope})后再下结论")
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2003,6 +2021,23 @@ mod tests {
assert!(msg.contains("相近文件候选"), "应含相近文件候选,实际: {msg}");
assert!(msg.contains("target.rs"), "候选应含 target.rs,实际: {msg}");
}
// ============================================================
// negative_hit_hint — 搜索工具 0 命中阴性存疑提示(治假阴性被当事实断言)
// ============================================================
/// 两搜索工具 0 命中提示共用句式:声明「0 命中≠不存在」+ 按工具各自的复核点。
#[test]
fn negative_hit_hint_states_doubt_and_scope() {
// grep 侧复核点:路径/glob 写法/大小写
let grep_hint = negative_hit_hint("路径/glob 写法/大小写 -i");
assert!(grep_hint.contains("0 命中不一定代表不存在"), "应声明阴性存疑,实际: {grep_hint}");
assert!(grep_hint.contains("复核搜索范围"), "应引导复核,实际: {grep_hint}");
assert!(grep_hint.contains("glob"), "grep 侧应含 glob 复核点,实际: {grep_hint}");
// search_files 侧复核点:recursive(实测根因是未传 recursive 只搜根目录)
let search_hint = negative_hit_hint("路径/recursive");
assert!(search_hint.contains("recursive"), "search_files 侧应含 recursive 复核点,实际: {search_hint}");
}
}