修复: 后端若干bug
This commit is contained in:
@@ -286,6 +286,73 @@ impl ToolDomain {
|
|||||||
|
|
||||||
// ---- 识别器 -----------------------------------------------------------------
|
// ---- 识别器 -----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 意图识别 L0 去噪:剥离 mention 结构化前缀(`[项目: DevFlow]` / `[任务: xxx]` 等)。
|
||||||
|
///
|
||||||
|
/// mention 前缀是系统注入的结构标签,语义已由 augmentation @ 通道单独 resolve
|
||||||
|
/// 注入 system prompt;content 里残留的前缀只污染关键词识别——实测会话
|
||||||
|
/// `[项目: DevFlow] 这个错误是项目里的 miniapp 报错` 被误判 `Intent::Project`,
|
||||||
|
/// 收敛砍掉 File domain 致 `read_file` 对 LLM 不可见,agent 被迫反复 `list_tasks`。
|
||||||
|
///
|
||||||
|
/// 剥离规则:扫描 `[`,若紧跟已知 mention kind(项目/任务/想法/技能,
|
||||||
|
/// project/task/idea/skill,大小写不敏感)+ 冒号(全/半角 `:`/`:`),则连同到
|
||||||
|
/// 首个 `]` 整段移除;未闭合或非已知 kind 的 `[...]` 原样保留(代码片段/错误码)。
|
||||||
|
///
|
||||||
|
/// kind 表与 `MentionRef`(project/task/idea/skill)对齐——单一真相源,新增 mention
|
||||||
|
/// kind 时此处同步。不折叠空白:关键词 `contains` 匹配对空白不敏感,剥离即可。
|
||||||
|
fn strip_mention_tags(message: &str) -> String {
|
||||||
|
const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"];
|
||||||
|
let chars: Vec<char> = message.chars().collect();
|
||||||
|
let n = chars.len();
|
||||||
|
let mut out = String::with_capacity(message.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < n {
|
||||||
|
if chars[i] == '[' {
|
||||||
|
if let Some(close) = try_match_mention(&chars, i, KINDS) {
|
||||||
|
i = close + 1; // 跳过整段 mention(含 `]`)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push(chars[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 探测 `chars[open] == '['` 起的 mention 段:`[kind: ...]`。
|
||||||
|
///
|
||||||
|
/// 返回匹配 `]` 的索引(`open` 指向 `[`)。kind 大小写不敏感(ASCII)+ 中文直比;
|
||||||
|
/// 冒号接受全角 `:`/半角 `:`;kind 后允许空白;未闭合 `]` 返 `None`(不当 mention,保留原样)。
|
||||||
|
fn try_match_mention(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;
|
||||||
|
}
|
||||||
|
// 未闭合 `]` → 不当 mention,保留原样
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
|
/// 意图识别器(无状态,所有方法均为纯函数,可 `Default` 构造)。
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct IntentRecognizer;
|
pub struct IntentRecognizer;
|
||||||
@@ -299,14 +366,16 @@ impl IntentRecognizer {
|
|||||||
///
|
///
|
||||||
/// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。
|
/// 优先级:SPECIFIC > ENTITY > GENERIC。高分组的胜者直接返回,不累积低分组。
|
||||||
pub fn recognize(message: &str) -> (Intent, f32) {
|
pub fn recognize(message: &str) -> (Intent, f32) {
|
||||||
|
// L0 去噪:先剥离 mention 结构标签(防 `[项目: x]` 把代码任务误判 Project)。
|
||||||
|
let cleaned = strip_mention_tags(message);
|
||||||
// 依次按优先级组求胜者。高分组有命中即提前返回。
|
// 依次按优先级组求胜者。高分组有命中即提前返回。
|
||||||
if let Some(hit) = best_in_group(message, SPECIFIC_GROUP) {
|
if let Some(hit) = best_in_group(&cleaned, SPECIFIC_GROUP) {
|
||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
if let Some(hit) = best_in_group(message, ENTITY_GROUP) {
|
if let Some(hit) = best_in_group(&cleaned, ENTITY_GROUP) {
|
||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
if let Some(hit) = best_in_group(message, GENERIC_GROUP) {
|
if let Some(hit) = best_in_group(&cleaned, GENERIC_GROUP) {
|
||||||
return hit;
|
return hit;
|
||||||
}
|
}
|
||||||
(Intent::Unknown, 0.0)
|
(Intent::Unknown, 0.0)
|
||||||
@@ -347,7 +416,7 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
|||||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||||
///
|
///
|
||||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||||
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||||
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||||
@@ -357,9 +426,12 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
|||||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||||
Intent::File => &[ToolDomain::File],
|
Intent::File => &[ToolDomain::File],
|
||||||
Intent::Project => &[ToolDomain::Data],
|
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
|
||||||
Intent::Task => &[ToolDomain::Data],
|
// (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。
|
||||||
Intent::Idea => &[ToolDomain::Data],
|
// 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。
|
||||||
|
Intent::Project => &[ToolDomain::Data, ToolDomain::File],
|
||||||
|
Intent::Task => &[ToolDomain::Data, ToolDomain::File],
|
||||||
|
Intent::Idea => &[ToolDomain::Data, ToolDomain::File],
|
||||||
Intent::Conversation => &[],
|
Intent::Conversation => &[],
|
||||||
Intent::Search => &[ToolDomain::File],
|
Intent::Search => &[ToolDomain::File],
|
||||||
Intent::Http => &[ToolDomain::Http],
|
Intent::Http => &[ToolDomain::Http],
|
||||||
@@ -608,6 +680,47 @@ mod tests {
|
|||||||
assert_eq!(i, Intent::Search);
|
assert_eq!(i, Intent::Search);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- strip_mention_tags(L0 去噪)---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_removes_all_mention_kinds() {
|
||||||
|
assert_eq!(strip_mention_tags("[项目: DevFlow] 重构代码"), " 重构代码");
|
||||||
|
assert_eq!(strip_mention_tags("[任务: 修复bug] 看看"), " 看看");
|
||||||
|
assert_eq!(strip_mention_tags("[想法: x][技能: y] 闲聊"), " 闲聊");
|
||||||
|
// 英文 kind + 大小写不敏感
|
||||||
|
assert_eq!(strip_mention_tags("[Project: x] fix this"), " fix this");
|
||||||
|
assert_eq!(strip_mention_tags("[TASK: y] do it"), " do it");
|
||||||
|
// 全角冒号
|
||||||
|
assert_eq!(strip_mention_tags("[项目:DevFlow] 阅读"), " 阅读");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_preserves_non_mention_brackets() {
|
||||||
|
// 非 mention 的 [...] 原样保留(代码片段/错误码)
|
||||||
|
assert_eq!(strip_mention_tags("数组 [1,2,3] 求和"), "数组 [1,2,3] 求和");
|
||||||
|
assert_eq!(strip_mention_tags("[error] 致命"), "[error] 致命"); // 'error' 非 kind
|
||||||
|
assert_eq!(strip_mention_tags("[项目报错] 看"), "[项目报错] 看"); // kind 后无冒号
|
||||||
|
// 未闭合的 mention-like 不剥离(防误吞)
|
||||||
|
assert_eq!(strip_mention_tags("[项目: 未闭合"), "[项目: 未闭合");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognize_mention_prefix_not_misread_as_project() {
|
||||||
|
// 核心修复:[项目: DevFlow] 帮我重构代码 → Code(非 Project)
|
||||||
|
// 原 bug:mention 前缀的"项目"命中 PROJECT(1.0)→ 收敛砍 File → read_file 不可见。
|
||||||
|
let (i, c) = IntentRecognizer::recognize("[项目: DevFlow] 帮我重构这段代码");
|
||||||
|
assert_eq!(i, Intent::Code);
|
||||||
|
assert!(c >= 0.7, "重构+代码 应达 Code 阈值, 实际 conf={}", c);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognize_real_project_word_still_hits_project() {
|
||||||
|
// 用户口语真"项目"(非 mention 前缀)仍命中 Project——预处理只去系统噪声。
|
||||||
|
// 正是 L1(tool_subset_for 加 File)不可省的佐证:口语误判靠映射兜底。
|
||||||
|
let (i, _) = IntentRecognizer::recognize("创建项目并绑定目录");
|
||||||
|
assert_eq!(i, Intent::Project);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recognize_http_zh() {
|
fn recognize_http_zh() {
|
||||||
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
|
let (i, _) = IntentRecognizer::recognize("调用接口请求这个 api");
|
||||||
@@ -713,11 +826,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn subset_project_is_data() {
|
fn subset_project_is_data_and_file() {
|
||||||
|
// L1 修复:Project 加 File domain(防"提项目 → 误判 Project → 砍只读探索")。
|
||||||
|
// Data(create_project/bind_directory)+ File 只读(read_file)共存。
|
||||||
let s = tool_subset_for(&Intent::Project);
|
let s = tool_subset_for(&Intent::Project);
|
||||||
assert!(s.contains(&"create_project"));
|
assert!(s.contains(&"create_project"));
|
||||||
assert!(s.contains(&"bind_directory"));
|
assert!(s.contains(&"bind_directory"));
|
||||||
assert!(!s.contains(&"read_file"));
|
assert!(s.contains(&"read_file"), "Project 应保留 read_file(L1)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -22,20 +22,49 @@ pub struct ShellResult {
|
|||||||
pub enum ShellType {
|
pub enum ShellType {
|
||||||
/// Windows cmd.exe (默认 Windows)
|
/// Windows cmd.exe (默认 Windows)
|
||||||
Cmd,
|
Cmd,
|
||||||
/// PowerShell
|
/// PowerShell 5.x(Windows 自带,不支持 && 运算符)
|
||||||
PowerShell,
|
PowerShell,
|
||||||
|
/// PowerShell 7(pwsh,支持 && 运算符;运行时探测,未装回退 PowerShell)
|
||||||
|
Pwsh,
|
||||||
/// Unix sh (默认非 Windows)
|
/// Unix sh (默认非 Windows)
|
||||||
Sh,
|
Sh,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ShellType {
|
impl Default for ShellType {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
// L1 环境感知:Windows 默认 PowerShell(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
|
||||||
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
|
||||||
if cfg!(windows) { ShellType::PowerShell } else { ShellType::Sh }
|
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
|
||||||
|
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
|
||||||
|
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
|
||||||
|
if cfg!(windows) {
|
||||||
|
if probe_pwsh() { ShellType::Pwsh } else { ShellType::PowerShell }
|
||||||
|
} else {
|
||||||
|
ShellType::Sh
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 探测 pwsh(PowerShell 7)是否可用(OnceLock 缓存,只探一次)。
|
||||||
|
///
|
||||||
|
/// LLM 普遍生成 `&&`(Unix 习惯),仅 PS7+ 支持,Windows 自带 PS5 不支持。
|
||||||
|
/// 探测:成功 spawn `pwsh -Command exit 0` 即可用。同步阻塞仅一次(spawn 极快),
|
||||||
|
/// Windows 加 CREATE_NO_WINDOW 防黑窗闪现。
|
||||||
|
fn probe_pwsh() -> bool {
|
||||||
|
static CACHE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||||
|
*CACHE.get_or_init(|| {
|
||||||
|
let mut cmd = std::process::Command::new("pwsh");
|
||||||
|
cmd.arg("-NoProfile").arg("-Command").arg("exit 0");
|
||||||
|
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
|
||||||
|
}
|
||||||
|
cmd.status().map(|s| s.success()).unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Shell 命令执行请求
|
/// Shell 命令执行请求
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ShellRequest {
|
pub struct ShellRequest {
|
||||||
@@ -67,6 +96,13 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
|
|||||||
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
ShellType::Pwsh => {
|
||||||
|
// PS7(支持 && 运算符),同 PS5 参数语义。-NoProfile 避加载用户 profile(速度+确定性)
|
||||||
|
let mut c = tokio::process::Command::new("pwsh");
|
||||||
|
c.arg("-NoProfile").arg("-Command").arg(&request.command);
|
||||||
|
c.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
c
|
||||||
|
}
|
||||||
ShellType::Cmd => {
|
ShellType::Cmd => {
|
||||||
let mut c = tokio::process::Command::new("cmd");
|
let mut c = tokio::process::Command::new("cmd");
|
||||||
c.arg("/C").arg(&request.command);
|
c.arg("/C").arg(&request.command);
|
||||||
|
|||||||
@@ -1222,6 +1222,9 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
error_type: None,
|
error_type: None,
|
||||||
conversation_id: Some(conv_id.clone()),
|
conversation_id: Some(conv_id.clone()),
|
||||||
});
|
});
|
||||||
|
// BUG-260623-02:全 provider 失败也需落库 user 消息(已 push 内存 chat.rs:383),
|
||||||
|
// 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。
|
||||||
|
save_conversation(&session_arc, &db, &conv_id, None, Some(&resolved_model), true).await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,7 +168,13 @@ pub(crate) async fn save_conversation(
|
|||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||||
for m in &mut msgs {
|
for m in &mut msgs {
|
||||||
m.content = truncate_for_persist(&m.content);
|
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||||
|
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
|
||||||
|
// (实测 tool result read 大文件后落库 Invalid control character @pos3072)。
|
||||||
|
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||||
|
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||||
|
m.content = truncate_for_persist(&m.content);
|
||||||
|
}
|
||||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||||
if let Some(parts) = m.parts.as_ref() {
|
if let Some(parts) = m.parts.as_ref() {
|
||||||
@@ -434,4 +440,68 @@ mod tests {
|
|||||||
fn truncate_parts_empty_returns_none() {
|
fn truncate_parts_empty_returns_none() {
|
||||||
assert_eq!(truncate_parts_for_persist(&[]), None);
|
assert_eq!(truncate_parts_for_persist(&[]), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- P0-2: tool role content 不截断(防 JSON 结构破坏) ----------
|
||||||
|
|
||||||
|
/// 辅助:构造指定 role + content 的 ChatMessage,用 provider 全路径类型
|
||||||
|
fn make_msg(role: df_ai::provider::MessageRole, content: &str) -> df_ai::provider::ChatMessage {
|
||||||
|
df_ai::provider::ChatMessage {
|
||||||
|
id: None,
|
||||||
|
role,
|
||||||
|
content: content.to_string(),
|
||||||
|
parts: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
tool_calls: None,
|
||||||
|
model: None,
|
||||||
|
status: None,
|
||||||
|
reasoning_content: None,
|
||||||
|
timestamp: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// save_conversation 的 content 截断决策片段(抽测 role 分支,避免起 DB/锁)。
|
||||||
|
/// 对 tool role 跳过截断,其余 role 走截断。
|
||||||
|
fn truncate_decision(m: &mut df_ai::provider::ChatMessage) {
|
||||||
|
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||||
|
m.content = truncate_for_persist(&m.content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_role_large_content_not_truncated() {
|
||||||
|
// tool result 是结构化 JSON,中段截断插裸换行+省略标记会破坏 JSON parse。
|
||||||
|
// >8192(阈值)的 tool content 必须原样保留(不进 truncate_for_persist)。
|
||||||
|
let json_like = format!(
|
||||||
|
"{{\"file\":\"x.rs\",\"content\":\"{}\"}}",
|
||||||
|
"a".repeat(TRUNCATE_THRESHOLD + 2000)
|
||||||
|
);
|
||||||
|
let original = json_like.clone();
|
||||||
|
let mut m = make_msg(df_ai::provider::MessageRole::Tool, &json_like);
|
||||||
|
truncate_decision(&mut m);
|
||||||
|
// 关键断言:tool role 不截断,原样保留(长度与内容完全一致)
|
||||||
|
assert_eq!(m.content.len(), original.len(), "tool role content 不应被截断");
|
||||||
|
assert_eq!(m.content, original, "tool role content 原样保留");
|
||||||
|
// 不含截断标记(证明未进 truncate_for_persist)
|
||||||
|
assert!(!m.content.contains("已截断"), "tool role 不应含截断标注");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assistant_role_large_content_still_truncated() {
|
||||||
|
// 对照:assistant role 超阈值仍截断(回归保护)
|
||||||
|
let long: String = "x".repeat(TRUNCATE_THRESHOLD + 1000);
|
||||||
|
let mut m = make_msg(df_ai::provider::MessageRole::Assistant, &long);
|
||||||
|
truncate_decision(&mut m);
|
||||||
|
assert!(m.content.contains("已截断"), "assistant role 超阈值应截断");
|
||||||
|
assert!(m.content.len() < long.len(), "assistant role 截断后应变短");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_role_short_content_unchanged() {
|
||||||
|
// 短 tool content:即使进了 truncate_for_persist 也不会改(阈值以下),
|
||||||
|
// 但此处验证 role 分支本身对短 tool content 也安全(原样保留)。
|
||||||
|
let short = r#"{"ok":true,"rows":3}"#;
|
||||||
|
let mut m = make_msg(df_ai::provider::MessageRole::Tool, short);
|
||||||
|
truncate_decision(&mut m);
|
||||||
|
assert_eq!(m.content, short);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1021,9 +1021,15 @@ fn register_file_tools(
|
|||||||
let more = (skip + page.len()) < line_count;
|
let more = (skip + page.len()) < line_count;
|
||||||
(page.join("\n"), Some(skip), more)
|
(page.join("\n"), Some(skip), more)
|
||||||
} else {
|
} else {
|
||||||
// 无 offset 默认前 500 行(大文件翻页友好,避免一次灌入全量)
|
// 无 offset: 尊重 LLM 传入的 limit(对齐有 offset 分支语义),
|
||||||
|
// 未传 limit 默认 500 行(大文件翻页友好,避免一次灌入全量)。
|
||||||
|
// BUG-260623-01: 旧实现固定 take(500) 忽略 limit,
|
||||||
|
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||||||
const DEFAULT_PREVIEW_LINES: usize = 500;
|
const DEFAULT_PREVIEW_LINES: usize = 500;
|
||||||
let page: Vec<&str> = content.lines().take(DEFAULT_PREVIEW_LINES).collect();
|
let limit = args["limit"].as_u64()
|
||||||
|
.unwrap_or(DEFAULT_PREVIEW_LINES as u64)
|
||||||
|
.min(2000) as usize;
|
||||||
|
let page: Vec<&str> = content.lines().take(limit).collect();
|
||||||
let more = line_count > page.len();
|
let more = line_count > page.len();
|
||||||
(page.join("\n"), None, more)
|
(page.join("\n"), None, more)
|
||||||
};
|
};
|
||||||
@@ -1108,8 +1114,10 @@ fn register_file_tools(
|
|||||||
Err(_) => None, // 不存在(新建)
|
Err(_) => None, // 不存在(新建)
|
||||||
};
|
};
|
||||||
if let Some(parent) = target.parent() {
|
if let Some(parent) = target.parent() {
|
||||||
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
|
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
|
||||||
if !parent.starts_with(&workspace_root()) {
|
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
|
||||||
|
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
|
||||||
|
if !snap.is_authorized(parent) {
|
||||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir_all(parent).await
|
tokio::fs::create_dir_all(parent).await
|
||||||
@@ -1415,7 +1423,8 @@ fn register_file_tools(
|
|||||||
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
|
||||||
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||||
if !parent.starts_with(&workspace_root()) {
|
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||||||
|
if !snap.is_authorized(parent) {
|
||||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir_all(parent).await
|
tokio::fs::create_dir_all(parent).await
|
||||||
@@ -1552,7 +1561,8 @@ fn register_file_tools(
|
|||||||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||||||
let to_target = std::path::Path::new(to_path);
|
let to_target = std::path::Path::new(to_path);
|
||||||
if let Some(parent) = to_target.parent() {
|
if let Some(parent) = to_target.parent() {
|
||||||
if !parent.starts_with(&workspace_root()) {
|
// BUG-260623-01:用 allowed_dirs 校验(非 workspace_root 编译期写死),授权目录内 parent 放行
|
||||||
|
if !snap.is_authorized(parent) {
|
||||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir_all(parent).await
|
tokio::fs::create_dir_all(parent).await
|
||||||
@@ -2611,6 +2621,73 @@ mod tests {
|
|||||||
fs::remove_dir_all(&tmp).ok();
|
fs::remove_dir_all(&tmp).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// read_file 分页 limit 尊重测试(BUG-260623-01 回归守护)
|
||||||
|
//
|
||||||
|
// read_file handler 无 offset 分支旧实现固定 take(500),忽略 LLM 传入的 limit。
|
||||||
|
// 实测 LLM 传 limit=15 仍返回 430 行(撑爆 prompt + 触发 truncate 破坏 JSON)。
|
||||||
|
// 修复:无 offset 时也读 args[limit](对齐有 offset 分支语义),未传默认 500。
|
||||||
|
//
|
||||||
|
// 经完整 AiToolRegistry.execute 调用 read_file handler(端到端覆盖 offset/limit 分支),
|
||||||
|
// 而非内联重复分页逻辑——避免测试与实现两处分页代码漂移(实现改测试不红)。
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 造 600 行文件,无 offset + limit=15 → returned_lines=15(limit 生效,旧 bug 返 500)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_file_no_offset_respects_limit() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("df_readfile_limit_{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
fs::create_dir_all(&tmp).unwrap();
|
||||||
|
// 600 行(>500 默认,确保 limit=15 与默认 500 行为可区分;15 < 500 锁定 limit 被读)
|
||||||
|
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||||||
|
let file = tmp.join("big.txt");
|
||||||
|
fs::write(&file, body).unwrap();
|
||||||
|
// persistent 存词法形态(对齐 is_authorized:candidate 经 strip_verbatim 归一词法去 \\?\ 前缀;
|
||||||
|
// 若 persistent 存 canonicalize 带 \\?\,starts_with 双侧形态不对齐恒 false → 误判未授权)。
|
||||||
|
let mut persistent = std::collections::HashSet::new();
|
||||||
|
persistent.insert(tmp.clone());
|
||||||
|
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||||||
|
|
||||||
|
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||||
|
let db = Arc::new(db);
|
||||||
|
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||||
|
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||||
|
let args = serde_json::json!({ "path": canon_file, "limit": 15 });
|
||||||
|
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||||
|
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||||
|
assert_eq!(returned, 15, "无 offset + limit=15 应返回 15 行(旧 bug 固定返 500)");
|
||||||
|
assert_eq!(res["has_more"], true, "600 行只取 15 行,应有更多");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 造 600 行文件,无 offset 无 limit → 默认 500 行(DEFAULT_PREVIEW_LINES)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_read_file_no_offset_no_limit_defaults_500() {
|
||||||
|
let tmp = std::env::temp_dir().join(format!("df_readfile_default_{}", std::process::id()));
|
||||||
|
let _ = fs::remove_dir_all(&tmp);
|
||||||
|
fs::create_dir_all(&tmp).unwrap();
|
||||||
|
let body: String = (0..600).map(|i| format!("line-{i}")).collect::<Vec<_>>().join("\n");
|
||||||
|
let file = tmp.join("big.txt");
|
||||||
|
fs::write(&file, body).unwrap();
|
||||||
|
// persistent 存词法形态(对齐 is_authorized 比对,见 test_read_file_no_offset_respects_limit 注释)
|
||||||
|
let mut persistent = std::collections::HashSet::new();
|
||||||
|
persistent.insert(tmp.clone());
|
||||||
|
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs { persistent, session: Default::default() }));
|
||||||
|
|
||||||
|
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
|
||||||
|
let db = Arc::new(db);
|
||||||
|
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
||||||
|
let canon_file = file.canonicalize().unwrap().to_string_lossy().to_string();
|
||||||
|
let args = serde_json::json!({ "path": canon_file });
|
||||||
|
let res = registry.execute("read_file", args).await.expect("read_file 执行失败");
|
||||||
|
let returned = res["returned_lines"].as_u64().expect("missing returned_lines");
|
||||||
|
assert_eq!(returned, 500, "无 offset 无 limit 应默认返回 500 行");
|
||||||
|
assert_eq!(res["has_more"], true, "600 行只取 500 行,应有更多");
|
||||||
|
|
||||||
|
fs::remove_dir_all(&tmp).ok();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// patch_file 三模式共用底层测试(F-260617-01)
|
// patch_file 三模式共用底层测试(F-260617-01)
|
||||||
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
||||||
|
|||||||
Reference in New Issue
Block a user