新增: 子代理轨迹查看与任务关联会话

- 会话只读窥视命令,不切换当前状态
- 任务关联会话反查,审计表结果精确匹配
- 托盘气泡键名修正,首关说明链路修通
This commit is contained in:
lxy
2026-10-03 17:57:33 +08:00
parent 8e09112d59
commit 890ceef0ec
3 changed files with 273 additions and 4 deletions
+229 -2
View File
@@ -10,11 +10,12 @@
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use tauri::State;
use df_ai::ai_tools::RiskLevel;
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
use df_storage::crud::{AiConversationRepo, AiToolExecutionRepo, AuditQuery};
use df_storage::db::Database;
use df_storage::models::AiToolExecutionRecord;
use df_types::types::new_id;
@@ -250,6 +251,128 @@ pub async fn list_tool_executions(
})
}
// ============================================================
// S4 TaskDrawer 关联会话反查(按 task_id 推导发起会话,只读)
// ============================================================
/// 任务工具精确名单(与 tools/task.rs 声明的三个任务工具一一对应)。
///
/// 审计表无"task_id → conversation"映射列,主链路靠本名单行 + result JSON 反推;
/// SQL 与本名单必须同步维护(见下方 SELECT 注释)。
const TASK_CONV_TOOLS: [&str; 3] = ["create_task", "update_task", "advance_task"];
/// 关联会话跳转条目 DTO(传给前端 TaskDrawer「关联会话」info-item)。
#[derive(Debug, Clone, Serialize)]
pub struct TaskConversationRef {
pub conversation_id: String,
/// 会话标题(ai_conversations.title,懒创建未命名时 None,前端回退显 id)
pub title: Option<String>,
/// 最近一次命中命中的工具名(create_task/update_task/advance_task)
pub tool_name: String,
/// 最近一次命中时间(毫秒字符串,同库 requested_at 口径)
pub requested_at: String,
}
/// 单行审计记录命中判定(纯函数):工具在名单内 且 result JSON 顶层 id 与 task_id 精确判等。
///
/// 禁 LIKE:任务 id 做子串匹配会误命中(如 "t1" 命中 "t12"),必须解析 JSON 取 id 字段判等;
/// result 非 JSON / 缺 id 字段 / id 不等 / 工具不在名单 → 一律不命中(failed 行的
/// `{"error":...}` 与 rejected 行的人话文本由此自然被排除)。
fn match_task_audit_row(tool_name: &str, result: Option<&str>, task_id: &str) -> bool {
if !TASK_CONV_TOOLS.contains(&tool_name) {
return false;
}
result
.and_then(|r| serde_json::from_str::<serde_json::Value>(r).ok())
.and_then(|v| v.get("id").and_then(|id| id.as_str()).map(|s| s.to_string()))
.map(|id| id == task_id)
.unwrap_or(false)
}
/// 按 task_id 反查发起过任务工具的会话(供 IPC 与单测共用,不依赖 AppState)。
///
/// 数据源 ai_tool_executions 审计表(V9):每笔任务工具执行都记 conversation_id + result。
/// repo 现有查询只支持 status/risk/tool_keyword(LIKE)维度,表达不了 `tool_name IN 精确名单`,
/// 故走专用 SELECT(与 state.rs 清理孤儿 pending 消息同款 app 层直查先例);SQL 里的工具名单
/// 是 TASK_CONV_TOOLS 的硬拷贝,单测覆盖三个名字防漂移。
///
/// 过滤:① 命中行按会话去重(行按 requested_at 倒序,首见=最近一次);② 子代理会话
/// (origin='sub_agent',V44)剔除,v1 只列主会话;③ 会话已不在库的 id 一并剔除
/// (switchConversation 对不存在 id 会走"新建兜底",跳过去是空会话,不如不列)。
/// LIMIT 1000 扫描上界:抽屉每次打开都查,封顶防超大审计表全表拖慢;取最近 1000 行,
/// 更早的关联对"跳回最近上下文"场景无意义。
pub(crate) async fn find_task_conversations(
db: &Database,
task_id: &str,
) -> Result<Vec<TaskConversationRef>, String> {
let conn = db.conn();
let rows: Vec<(String, String, Option<String>, String)> =
tokio::task::spawn_blocking(move || -> Result<Vec<(String, String, Option<String>, String)>, String> {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
// 工具名单硬拷贝自 TASK_CONV_TOOLS(三个名字),改动须两处同步
"SELECT conversation_id, tool_name, result, requested_at FROM ai_tool_executions \
WHERE tool_name IN ('create_task','update_task','advance_task') \
AND conversation_id IS NOT NULL \
ORDER BY CAST(requested_at AS INTEGER) DESC LIMIT 1000",
)
.map_err(|e| e.to_string())?;
let mapped = stmt
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?;
let mut out = Vec::new();
for r in mapped {
out.push(r.map_err(|e| e.to_string())?);
}
Ok(out)
})
.await
.map_err(|e| format!("find_task_conversations join 失败: {}", e))??;
let conv_repo = AiConversationRepo::new(db);
let mut visited: HashSet<String> = HashSet::new();
let mut out = Vec::new();
for (conv_id, tool_name, result, requested_at) in rows {
if !match_task_audit_row(&tool_name, result.as_deref(), task_id) {
continue;
}
// 去重 + 防同会话重复查 origin(首个可见行即该会话最近一次命中)
if !visited.insert(conv_id.clone()) {
continue;
}
if let Ok(Some(rec)) = conv_repo.get_by_id(&conv_id).await {
// origin=None(主会话)才列;查无记录的会话同样不列(见函数注释③)
if rec.origin.is_none() {
out.push(TaskConversationRef {
conversation_id: conv_id,
title: rec.title,
tool_name,
requested_at,
});
}
}
}
Ok(out)
}
/// TaskDrawer「关联会话」查询 IPC:按 task_id 反查发起过 create/update/advance_task 的主会话,
/// 按 最近命中 倒序去重返回。只读,无副作用。
#[tauri::command]
pub async fn list_task_conversations(
state: State<'_, AppState>,
task_id: String,
) -> Result<Vec<TaskConversationRef>, String> {
find_task_conversations(&state.db, &task_id).await
}
// ============================================================
// AC-5 按工具失败率统计(诊断 IPC,替代 ad-hoc 查库)
// ============================================================
@@ -421,6 +544,110 @@ mod tests {
assert_eq!(out.stats[0].tool_name, "read_file");
}
/// S4 关联会话反查:插入一条指定 conv/tool/result/requested_at 的审计行(测试构造数据用)。
async fn insert_conv_audit(
repo: &AiToolExecutionRepo,
conv: &str,
tool: &str,
result: Option<String>,
t: i64,
) {
repo.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv.to_string()),
message_id: None,
tool_call_id: new_id().into(),
tool_name: tool.to_string(),
arguments: "{}".to_string(),
result,
status: "completed".to_string(),
risk_level: "low".to_string(),
requested_at: t.to_string(),
executed_at: Some(t.to_string()),
decided_by: Some("auto".to_string()),
})
.await
.expect("测试数据插入应成功");
}
/// S4 关联会话反查:插入一条指定 origin 的会话记录(V44 origin 列,测试过滤子会话用)。
async fn insert_conversation(db: &Database, id: &str, title: Option<&str>, origin: Option<&str>) {
use df_storage::models::AiConversationRecord;
AiConversationRepo::new(db)
.insert(AiConversationRecord {
id: id.to_string(),
title: title.map(|s| s.to_string()),
messages: "[]".to_string(),
provider_id: None,
model: None,
models: None,
archived: false,
pinned: false,
prompt_tokens: None,
completion_tokens: None,
pinned_goals: None,
pending_approvals: None,
created_at: "1".to_string(),
updated_at: "1".to_string(),
origin: origin.map(|s| s.to_string()),
})
.await
.expect("测试会话插入应成功");
}
/// S4 关联会话反查三件事:① result JSON 顶层 id 精确判等(禁 LIKE,前缀 id 不误命中);
/// ② 同会话多行命中按最近一次去重 + 行按 requested_at 倒序;③ origin='sub_agent' 子会话
/// 与已不在库的会话 id 剔除,只列主会话。SQL 三个工具名与 TASK_CONV_TOOLS 同步覆盖。
#[tokio::test]
async fn find_task_conversations_exact_id_dedup_origin_filter() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let repo = AiToolExecutionRepo::new(&db);
let t = 2_000_000_000_000i64;
insert_conversation(&db, "conv-main", Some("主会话"), None).await;
insert_conversation(&db, "conv-sub", Some("子代理"), Some("sub_agent")).await;
// conv-main:create(t-30) + update(t-10) 两次命中 → 去重后取 update(最近)
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t1","title":"A","status":"todo"}"#.into()), t - 30).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t1","field":"title","updated":true}"#.into()), t - 10).await;
// conv-sub:advance 命中但 origin='sub_agent' → 过滤
insert_conv_audit(&repo, "conv-sub", "advance_task",
Some(r#"{"id":"t1","status":"in_progress"}"#.into()), t - 20).await;
// 干扰项:非名单工具 / id 前缀(t11 ≠ t1)/ 缺 id / 非法 JSON / 其他任务
insert_conv_audit(&repo, "conv-main", "list_tasks",
Some(r#"{"id":"t1"}"#.into()), t - 5).await;
insert_conv_audit(&repo, "conv-main", "update_task",
Some(r#"{"id":"t12","field":"title","updated":true}"#.into()), t - 6).await;
insert_conv_audit(&repo, "conv-main", "advance_task",
Some(r#"{"error":"boom"}"#.into()), t - 7).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some("审批超时,自动取消".into()), t - 8).await;
insert_conv_audit(&repo, "conv-main", "create_task",
Some(r#"{"id":"t2","title":"B"}"#.into()), t - 9).await;
insert_conv_audit(&repo, "conv-ghost", "create_task",
Some(r#"{"id":"t1","title":"C"}"#.into()), t - 4).await; // 会话已不在库
let out = find_task_conversations(&db, "t1").await.unwrap();
assert_eq!(out.len(), 1, "只应命中主会话 conv-main(去重后单条)");
assert_eq!(out[0].conversation_id, "conv-main");
assert_eq!(out[0].title.as_deref(), Some("主会话"));
assert_eq!(out[0].tool_name, "update_task", "同会话两行命中取最近一次(update)");
assert_eq!(out[0].requested_at, (t - 10).to_string());
}
/// S4 纯函数判定表:名单外工具 / 非 JSON / 缺 id / id 不等 均不命中,精确判等才命中。
#[test]
fn match_task_audit_row_truth_table() {
assert!(match_task_audit_row("create_task", Some(r#"{"id":"t1"}"#), "t1"));
assert!(!match_task_audit_row("create_task", Some(r#"{"id":"t12"}"#), "t1"), "禁 LIKE:前缀不误命中");
assert!(!match_task_audit_row("list_tasks", Some(r#"{"id":"t1"}"#), "t1"), "名单外工具不命中");
assert!(!match_task_audit_row("create_task", Some("人话文本"), "t1"), "非 JSON 不命中");
assert!(!match_task_audit_row("create_task", Some(r#"{"error":"x"}"#), "t1"), "缺 id 不命中");
assert!(!match_task_audit_row("create_task", None, "t1"), "result 空不命中");
}
/// from 时间过滤:仅统计 requested_at >= from 的记录。
///
/// patch_file 追加一条更早(early)的 completed → from=late 时其不计入,