修复: 后端若干bug
This commit is contained in:
@@ -1222,6 +1222,9 @@ pub(crate) async fn run_agentic_loop(
|
||||
error_type: None,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,7 +168,13 @@ pub(crate) async fn save_conversation(
|
||||
let mut session = session_arc.lock().await;
|
||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||
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 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
@@ -434,4 +440,68 @@ mod tests {
|
||||
fn truncate_parts_empty_returns_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;
|
||||
(page.join("\n"), Some(skip), more)
|
||||
} 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;
|
||||
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();
|
||||
(page.join("\n"), None, more)
|
||||
};
|
||||
@@ -1108,8 +1114,10 @@ fn register_file_tools(
|
||||
Err(_) => None, // 不存在(新建)
|
||||
};
|
||||
if let Some(parent) = target.parent() {
|
||||
// FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all)
|
||||
if !parent.starts_with(&workspace_root()) {
|
||||
// FR-S8:parent 必须在授权目录内(防 path=授权根时 parent 越界 create_dir_all)。
|
||||
// BUG-260623-01:原用 workspace_root()(编译期写死 devflow 源码目录)→ 用户授权的其他项目
|
||||
// 目录(ai-news 等)parent 不 starts_with 它 → 误拒授权目录内写。改用 allowed_dirs 白名单。
|
||||
if !snap.is_authorized(parent) {
|
||||
anyhow::bail!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
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 content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
|
||||
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!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
@@ -1552,7 +1561,8 @@ fn register_file_tools(
|
||||
// 目标父目录不存在则创建(对齐 write_file L643/append_file L851,跨目录移动到不存在父目录否则 rename 失败)
|
||||
let to_target = std::path::Path::new(to_path);
|
||||
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!("禁止在项目目录之外创建目录");
|
||||
}
|
||||
tokio::fs::create_dir_all(parent).await
|
||||
@@ -2611,6 +2621,73 @@ mod tests {
|
||||
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)
|
||||
// 纯函数 apply_line_range / resolve_anchor_to_lines,不依赖 fs/async/锁。
|
||||
|
||||
Reference in New Issue
Block a user