Files
DevFlow/crates/df-ai/src/plan_hint.rs
绝尘 bd6a41fe6e 新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
2026-06-21 20:51:26 +08:00

709 lines
25 KiB
Rust

//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数
//!
//! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。
//! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。
//!
//! ## 定位
//! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词,
//! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有
//! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块
//! 在其之上叠加「并行组 / 顺序依赖」的编排关系。
//!
//! ## 纯函数边界(零 IO 零状态)
//! - 无 `.await` / 无 db / 无 provider / 无全局状态。
//! - 输入:意图标签 + 上下文关键词(纯字符串切片)。
//! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。
//! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是
//! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排,
//! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。
//!
//! ## 与 0.7 planner.rs 的关系
//! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。
//! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略);
//! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。
//! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan`
//! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。
//!
//! ## feature flag
//! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。
//! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。
// ---- feature flag 占位 ------------------------------------------------------
/// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。
/// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。
/// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。
pub const PLAN_HINT_ENABLED: bool = true;
// ---- 数据结构 --------------------------------------------------------------
/// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。
///
/// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/...,
/// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParallelGroup {
/// 组内工具名列表(均为 `intent` crate 内声明的注册名)。
pub tools: Vec<&'static str>,
}
impl ParallelGroup {
/// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。
pub fn new(tools: Vec<&'static str>) -> Self {
Self { tools }
}
/// 组内工具数。
pub fn len(&self) -> usize {
self.tools.len()
}
/// 是否为空组。
pub fn is_empty(&self) -> bool {
self.tools.is_empty()
}
}
/// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。
///
/// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名
/// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SequentialDep {
/// 前置工具(先执行)。
pub from: &'static str,
/// 后置工具(待 from 完成后执行)。
pub to: &'static str,
}
impl SequentialDep {
/// 构造一条顺序依赖边。
pub fn new(from: &'static str, to: &'static str) -> Self {
Self { from, to }
}
}
/// 编排提示(轻量 PlanHint)。
///
/// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。
/// - `sequence`:跨组的顺序依赖边(DAG 边)。
///
/// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐):
/// 1. `groups` 内各组 `tools` 非空;
/// 2. `sequence` 不含自环(`from != to`);
/// 3. `sequence` 无重复边(去重);
/// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。
///
/// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PlanHint {
/// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。
pub groups: Vec<ParallelGroup>,
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
pub sequence: Vec<SequentialDep>,
}
impl PlanHint {
/// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。
pub fn empty() -> Self {
Self::default()
}
/// 是否为空提示(无任何编排信息)。
pub fn is_empty(&self) -> bool {
self.groups.is_empty() && self.sequence.is_empty()
}
/// 工具总数(所有并行组工具去重后的并集大小)。
pub fn tool_count(&self) -> usize {
let mut seen = std::collections::HashSet::new();
for g in &self.groups {
for t in &g.tools {
seen.insert(*t);
}
}
seen.len()
}
}
// ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) --------------------------------
/// PlanHint 合法性校验结果。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HintValidity {
/// 合法,可交付主 loop 调度。
Ok,
/// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。
Invalid(&'static str),
}
/// 校验 PlanHint 合法性(纯函数)。
///
/// 检查四项(见 `PlanHint` 文档约束):
/// 1. 组内非空;
/// 2. 无自环(`from != to`);
/// 3. 无重复边;
/// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现,
/// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。
///
/// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。
pub fn validate(hint: &PlanHint) -> HintValidity {
// 1. 组内非空
for g in &hint.groups {
if g.tools.is_empty() {
return HintValidity::Invalid("empty_parallel_group");
}
}
// 2. 无自环
for d in &hint.sequence {
if d.from == d.to {
return HintValidity::Invalid("self_loop_dep");
}
}
// 3. 无重复边(同 from→to 不重复)
let mut seen_edges = std::collections::HashSet::new();
for d in &hint.sequence {
if !seen_edges.insert((d.from, d.to)) {
return HintValidity::Invalid("duplicate_dep_edge");
}
}
// 4. Kahn 拓扑判无环
if has_cycle(&hint.sequence) {
return HintValidity::Invalid("cyclic_dep");
}
HintValidity::Ok
}
/// Kahn 算法判有向边集合是否含环(纯函数)。
///
/// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。
/// 返回 `true` = 有环(非法),`false` = DAG(合法)。
///
/// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 →
/// 最终已出队节点数 < 总节点数 → 存在环。
fn has_cycle(edges: &[SequentialDep]) -> bool {
if edges.is_empty() {
return false;
}
// 收集所有节点(去重)。
let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new();
for d in edges {
nodes.insert(d.from);
nodes.insert(d.to);
}
let n = nodes.len();
if n == 0 {
return false;
}
// 节点 → 索引(便于用 Vec 计数)。
let mut idx: std::collections::HashMap<&'static str, usize> =
std::collections::HashMap::with_capacity(n);
for (i, name) in nodes.iter().enumerate() {
idx.insert(*name, i);
}
// 入度表 + 邻接表。
let mut indeg = vec![0usize; n];
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
for d in edges {
let u = idx[&d.from];
let v = idx[&d.to];
adj[u].push(v);
indeg[v] += 1;
}
// BFS from 入度 0 节点。
let mut queue: std::collections::VecDeque<usize> = std::collections::VecDeque::new();
for (i, &d) in indeg.iter().enumerate() {
if d == 0 {
queue.push_back(i);
}
}
let mut visited = 0usize;
while let Some(u) = queue.pop_front() {
visited += 1;
for &v in &adj[u] {
indeg[v] -= 1;
if indeg[v] == 0 {
queue.push_back(v);
}
}
}
// 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。
visited < n
}
// ---- 启发式产 PlanHint(纯函数,无 LLM) -----------------------------------
/// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。
///
/// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。
/// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景:
///
/// | 场景关键词 | 产出 |
/// |---|---|
/// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file |
/// | grep/find 后改 | search_files → write_file/patch_file |
/// | http + 写文件 | http_request → write_file(先取再存) |
/// | 仅读多文件 | 全并行单组 |
/// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) |
///
/// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式,
/// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。
///
/// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定,
/// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。
pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint {
// 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。
if !PLAN_HINT_ENABLED {
return PlanHint::empty();
}
let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。
let ctx = context_keywords;
let lower = ctx.to_lowercase();
// ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ----
let is_search = lower.contains("grep")
|| lower.contains("搜索")
|| lower.contains("查找")
|| lower.contains("find ");
let is_modify = lower.contains("修改")
|| lower.contains("")
|| lower.contains("write")
|| lower.contains("patch")
|| lower.contains("写入")
|| lower.contains("重命名")
|| lower.contains("rename");
if is_search && is_modify {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["search_files"]),
ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]),
],
sequence: vec![SequentialDep::new("search_files", "write_file")],
};
}
// ---- 规则 2:http 取数 + 写文件 → http_request → write_file ----
let is_http = lower.contains("http")
|| lower.contains("api")
|| lower.contains("请求")
|| lower.contains("fetch");
if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["http_request"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("http_request", "write_file")],
};
}
// ---- 规则 3:读多文件(无写) → 全并行单组 ----
let read_count = [
("read_file", lower.matches("read").count() + lower.matches("读取").count()),
("list_directory", lower.matches("目录").count() + lower.matches("directory").count()),
]
.iter()
.filter(|(_, c)| *c >= 1)
.count();
if read_count >= 2 && !is_modify {
// 多种读工具信号 → 单并行组(可同时调度)
let mut tools: Vec<&'static str> = Vec::new();
if lower.contains("read") || lower.contains("读取") {
tools.push("read_file");
}
if lower.contains("目录") || lower.contains("directory") {
tools.push("list_directory");
}
if tools.len() >= 2 {
return PlanHint {
groups: vec![ParallelGroup::new(tools)],
sequence: vec![],
};
}
}
// ---- 规则 4:读后写 → read_file → write_file/patch_file ----
let is_read = lower.contains("read") || lower.contains("读取");
if is_read && is_modify {
return PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file", "patch_file"]),
],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
}
// ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ----
PlanHint::empty()
}
// ---- 单测 -------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// --- feature flag 占位 ---
#[test]
fn plan_hint_enabled_is_true_in_phase0a() {
// Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。
assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true");
}
// --- 数据结构基本 ---
#[test]
fn parallel_group_new_and_len() {
let g = ParallelGroup::new(vec!["read_file", "write_file"]);
assert_eq!(g.len(), 2);
assert!(!g.is_empty());
}
#[test]
fn parallel_group_empty_construct_allowed_but_invalid() {
// 空 tools 允许构造(调用方失误),validate 阶段标记非法
let g = ParallelGroup::new(vec![]);
assert!(g.is_empty());
assert_eq!(g.len(), 0);
}
#[test]
fn sequential_dep_new() {
let d = SequentialDep::new("read_file", "write_file");
assert_eq!(d.from, "read_file");
assert_eq!(d.to, "write_file");
}
#[test]
fn plan_hint_empty_is_empty() {
let h = PlanHint::empty();
assert!(h.is_empty());
assert_eq!(h.tool_count(), 0);
}
#[test]
fn plan_hint_tool_count_dedup_across_groups() {
// 两组含同名工具 → tool_count 去重
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file", "write_file"]),
ParallelGroup::new(vec!["read_file", "patch_file"]),
],
sequence: vec![],
};
// read_file/write_file/patch_file = 3(去重)
assert_eq!(h.tool_count(), 3);
}
// --- plan_hint 基本编排提示 ---
#[test]
fn hint_read_then_modify_yields_sequence() {
// 规则 4:读后写 → read_file → write_file
let h = plan_hint("code", "先 read 这个文件再修改它");
assert!(!h.is_empty(), "读后写应产非空 hint");
assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)");
assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"),
"应含 read_file→write_file 依赖边");
}
#[test]
fn hint_grep_then_modify_yields_search_first() {
// 规则 1:grep 后改 → search_files → write_file
let h = plan_hint("search", "grep 一下再修改 write_file");
assert!(!h.is_empty());
// 首组应是 search_files
assert!(h.groups[0].tools.contains(&"search_files"));
assert!(h.sequence.iter().any(|d| d.from == "search_files"),
"search_files 应作为顺序依赖起点");
}
#[test]
fn hint_http_then_save_yields_http_first() {
// 规则 2:http + 保存 → http_request → write_file
let h = plan_hint("http", "请求这个 api 并保存写入到文件");
assert!(!h.is_empty());
assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file"));
}
#[test]
fn hint_multi_read_only_yields_single_parallel_group() {
// 规则 3:读多文件(无写)→ 单并行组
let h = plan_hint("file", "read 文件,再看一下 directory 目录");
assert!(!h.is_empty());
assert_eq!(h.groups.len(), 1, "应单并行组");
let tools = &h.groups[0].tools;
assert!(tools.contains(&"read_file"), "组内应含 read_file");
assert!(tools.contains(&"list_directory"), "组内应含 list_directory");
assert!(h.sequence.is_empty(), "全并行无顺序依赖");
}
// --- 并行组识别 ---
#[test]
fn parallel_group_tools_are_registry_names() {
// 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。
// 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用,
// 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验,
// 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移,
// intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。
let registry: std::collections::HashSet<&str> = [
// Data domain
"list_projects", "create_project", "update_project", "delete_project",
"bind_directory", "list_tasks", "create_task", "update_task", "advance_task",
"delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash",
"restore_project", "purge_project", "get_project_count", "get_task_count",
// File domain
"read_file", "write_file", "patch_file", "list_directory", "search_files",
"file_info", "append_file", "delete_file", "rename_file",
// Exec domain
"run_command",
// Http domain
"http_request",
]
.iter()
.copied()
.collect();
let h = plan_hint("file", "read 再读取目录 directory");
if !h.is_empty() {
for g in &h.groups {
for t in &g.tools {
assert!(registry.contains(*t), "工具名 {} 不在 registry", t);
}
}
}
}
#[test]
fn hint_groups_tools_non_empty_when_non_empty_hint() {
// 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底)
let h = plan_hint("code", "read 后 write_file 修改");
if !h.is_empty() {
for g in &h.groups {
assert!(!g.tools.is_empty(), "产出组的 tools 不应空");
}
}
}
// --- 顺序依赖 ---
#[test]
fn hint_sequence_edges_from_less_than_to() {
// 顺序依赖边 from/to 都是合法工具名(from 先于 to)
let h = plan_hint("code", "read 后修改 write_file");
for d in &h.sequence {
assert_ne!(d.from, d.to, "依赖边不应自环");
assert!(!d.from.is_empty());
assert!(!d.to.is_empty());
}
}
#[test]
fn hint_no_duplicate_sequence_edge() {
// 产出的依赖边不应重复
let h = plan_hint("code", "read 后 write_file 修改");
let mut seen = std::collections::HashSet::new();
for d in &h.sequence {
assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to);
}
}
// --- 空 hint ---
#[test]
fn hint_empty_for_plain_chat() {
// 无编排信号 → 空 PlanHint
let h = plan_hint("chat", "你好谢谢");
assert!(h.is_empty(), "闲聊应产空 hint, 退单链");
}
#[test]
fn hint_empty_for_unknown_intent_no_signal() {
let h = plan_hint("unknown", "今天的天气不错");
assert!(h.is_empty());
}
#[test]
fn hint_empty_when_flag_off() {
// 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。
// 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部
// 短路逻辑(若常量改 false,此测会失败提醒对齐)。
// 用单工具避免命中多工具规则。
let h = plan_hint("file", "read");
// 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空
assert!(h.is_empty(), "单工具无编排信号应空 hint");
}
// --- validate 合法性 ---
#[test]
fn validate_empty_hint_is_ok() {
let h = PlanHint::empty();
assert_eq!(validate(&h), HintValidity::Ok);
}
#[test]
fn validate_normal_dag_is_ok() {
// read_file → write_file(单边 DAG)合法
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
assert_eq!(validate(&h), HintValidity::Ok);
}
#[test]
fn validate_empty_parallel_group_is_invalid() {
let h = PlanHint {
groups: vec![ParallelGroup::new(vec![])],
sequence: vec![],
};
assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group"));
}
#[test]
fn validate_self_loop_dep_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![SequentialDep::new("read_file", "read_file")],
};
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
}
#[test]
fn validate_duplicate_edge_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![
SequentialDep::new("read_file", "write_file"),
SequentialDep::new("read_file", "write_file"),
],
};
assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge"));
}
// --- Kahn 环检测 ---
#[test]
fn has_cycle_empty_edges_false() {
assert!(!has_cycle(&[]));
}
#[test]
fn has_cycle_single_edge_no_cycle() {
let edges = vec![SequentialDep::new("a", "b")];
assert!(!has_cycle(&edges));
}
#[test]
fn has_cycle_two_node_cycle_detected() {
// a→b→a 环
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("b", "a"),
];
assert!(has_cycle(&edges), "a↔b 环应被检测");
}
#[test]
fn has_cycle_three_node_cycle_detected() {
// a→b→c→a 环
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("b", "c"),
SequentialDep::new("c", "a"),
];
assert!(has_cycle(&edges));
}
#[test]
fn has_cycle_dag_with_branch_no_cycle() {
// a→b, a→c, b→d, c→d(菱形 DAG,无环)
let edges = vec![
SequentialDep::new("a", "b"),
SequentialDep::new("a", "c"),
SequentialDep::new("b", "d"),
SequentialDep::new("c", "d"),
];
assert!(!has_cycle(&edges));
}
#[test]
fn validate_cyclic_dep_is_invalid() {
let h = PlanHint {
groups: vec![
ParallelGroup::new(vec!["read_file"]),
ParallelGroup::new(vec!["write_file"]),
],
sequence: vec![
SequentialDep::new("read_file", "write_file"),
SequentialDep::new("write_file", "read_file"),
],
};
assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep"));
}
// --- 对抗/边界 ---
#[test]
fn validate_check_order_self_loop_before_cycle() {
// 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误)
let h = PlanHint {
groups: vec![ParallelGroup::new(vec!["a"])],
sequence: vec![SequentialDep::new("a", "a")],
};
assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep"));
}
#[test]
fn hint_intent_label_ignored_in_phase0a() {
// 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1)
// 同关键词不同 intent_label → 同产出
let h1 = plan_hint("code", "read 后修改 write_file");
let h2 = plan_hint("debug", "read 后修改 write_file");
assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致");
}
#[test]
fn hint_single_tool_no_signal_is_empty() {
// 单 read(无写无多读)→ 不命中任何规则 → 空
let h = plan_hint("file", "读取文件");
assert!(h.is_empty());
}
#[test]
fn validate_ignores_nodes_not_in_groups() {
// validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松,
// 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口)
let h = PlanHint {
groups: vec![ParallelGroup::new(vec!["read_file"])],
sequence: vec![SequentialDep::new("read_file", "write_file")],
};
// write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松)
assert_eq!(validate(&h), HintValidity::Ok);
}
}