新增: 子代理轨迹查看与任务关联会话
- 会话只读窥视命令,不切换当前状态 - 任务关联会话反查,审计表结果精确匹配 - 托盘气泡键名修正,首关说明链路修通
This commit is contained in:
@@ -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 时其不计入,
|
||||
|
||||
@@ -604,6 +604,42 @@ pub async fn ai_conversation_load_more(
|
||||
}))
|
||||
}
|
||||
|
||||
/// 只读窥视子代理轨迹(S3 子代理轨迹查看)
|
||||
///
|
||||
/// 复用 ai_conversation_switch 的读路径(ai_messages.list_by_conversation → record_to_message,
|
||||
/// 含空表回退旧 messages JSON 列的兼容分支),但**只读不切状态**:不写 activeConversationId/
|
||||
/// per_conv/pending_approvals/标题重生成。子会话(ai_conversations.origin='sub_agent')不进侧栏、
|
||||
/// 不能走 switch(切走会把父会话 active 顶掉并触发一整套会话态副作用),前端完成卡浮层
|
||||
/// 查看轨迹的唯一合理通道就是本只读命令。
|
||||
///
|
||||
/// 返回全量消息不分页:子代理单任务轨迹体量有限,分页属过度设计(设计宁少勿预留)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_conversation_peek(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let record = state.ai_conversations.get_by_id(&conversation_id).await
|
||||
.map_err(err_str)?
|
||||
.ok_or_else(|| format!("对话不存在: {}", conversation_id))?;
|
||||
// 读路径与 switch 同构:优先 ai_messages 表,空表回退旧 messages JSON 列(双向兼容)
|
||||
let all_records = state.ai_messages.list_by_conversation(&conversation_id).await
|
||||
.map_err(err_str)?;
|
||||
let messages: Vec<ChatMessage> = if !all_records.is_empty() {
|
||||
all_records.iter().map(record_to_message).collect()
|
||||
} else if record.messages != "[]" && !record.messages.is_empty() {
|
||||
serde_json::from_str(&record.messages)
|
||||
.map_err(|e| format!("解析消息失败: {}", e))?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let messages_json = serde_json::to_string(&messages)
|
||||
.map_err(|e| format!("序列化消息失败: {}", e))?;
|
||||
Ok(serde_json::json!({
|
||||
"title": record.title,
|
||||
"messages": messages_json,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 删除对话
|
||||
#[tauri::command]
|
||||
pub async fn ai_conversation_delete(
|
||||
|
||||
@@ -458,6 +458,8 @@ pub fn run() {
|
||||
commands::ai::ai_conversation_create,
|
||||
commands::ai::ai_conversation_list,
|
||||
commands::ai::ai_conversation_switch,
|
||||
// S3 子代理轨迹只读窥视(复用 switch 读路径,不切任何会话状态)
|
||||
commands::ai::ai_conversation_peek,
|
||||
commands::ai::ai_conversation_load_more,
|
||||
commands::ai::ai_conversation_delete,
|
||||
commands::ai::ai_conversation_rename,
|
||||
@@ -480,6 +482,8 @@ pub fn run() {
|
||||
commands::ai::ai_authorize_dir,
|
||||
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::record::list_tool_executions,
|
||||
// S4 TaskDrawer 关联会话:按 task_id 反查发起过任务工具的主会话(审计表推导,只读)
|
||||
commands::ai::audit::record::list_task_conversations,
|
||||
// AC-5 按工具失败率统计(诊断 IPC,聚合 ai_tool_executions 覆盖全量/时间范围)
|
||||
commands::ai::audit::record::tool_failure_stats,
|
||||
// 知识库
|
||||
@@ -561,9 +565,11 @@ pub fn run() {
|
||||
let _ = window.hide(); // 无运行会话:隐藏到托盘(原行为)
|
||||
return;
|
||||
}
|
||||
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡
|
||||
// 有运行会话:每进程一次给主窗发提示事件(payload 带运行数),前端画气泡。
|
||||
// 键名必须为 running:App.vue 监听端读 e.payload.running,曾发 "active" 致
|
||||
// 端上恒取 undefined→按 0 处理,气泡整条链路静默失效(UX-P3 批次修正)。
|
||||
if !CLOSE_NOTICE_SENT.swap(true, std::sync::atomic::Ordering::SeqCst) {
|
||||
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "active": active }));
|
||||
let _ = app.emit_to("main", "tray-hide-notice", serde_json::json!({ "running": active }));
|
||||
}
|
||||
// 异步等 2 秒再 hide:气泡可见窗口,禁用 std::thread::sleep(同步阻塞卡渲染)
|
||||
let win = window.clone();
|
||||
|
||||
Reference in New Issue
Block a user