refactor(todo): 统一子代理 scope_key 为全局唯一的 task_id

- 修改 list_todos 处理器,子代理使用 task_id 作为 scope_key,替代原先的 sub:{parent_session_id}:{task_id}
- 调整 todo_write 的 scope_key_from_context 函数,子/孙代理使用 task_id 隔离,避免与父代理污染
- 修正子任务消息的 task_id 传递逻辑,在 useChat hook 中为子代理视图的孙子任务回填正确的 task_id
- 清理 MessageBubble 组件中多余的 isSubAgent 变量声明
This commit is contained in:
oudecheng 2026-06-18 16:44:54 +08:00
parent 421714dfa3
commit e585ec71b1
4 changed files with 37 additions and 6 deletions

View File

@ -41,10 +41,10 @@ impl CommandHandler for ListTodosCommandHandler {
_ => None, _ => None,
}; };
// 子代理scope_key = sub:{parent_session_id}:{task_id} // 子代理scope_key = task_id全局唯一与 todo_write 保持一致)
// 主代理scope_key = topic_id.unwrap_or(session_id) // 主代理scope_key = topic_id.unwrap_or(session_id)
let scope_key = if let (Some(tid), Some(parent_sid)) = (task_id.as_deref(), ctx.session_id.as_deref()) { let scope_key = if let Some(tid) = task_id.as_deref() {
format!("sub:{}:{}", parent_sid, tid) tid.to_string()
} else { } else {
ctx.topic_id ctx.topic_id
.as_deref() .as_deref()

View File

@ -330,10 +330,14 @@ impl Tool for TodoWriteTool {
/// 计算 scope_key /// 计算 scope_key
/// - 主代理 (nesting_depth == 0):优先 topic_id否则 session_id /// - 主代理 (nesting_depth == 0):优先 topic_id否则 session_id
/// - 子/孙代理 (nesting_depth > 0):使用 session_id 隔离,避免污染父代理 todo /// - 子/孙代理 (nesting_depth > 0):使用 task_id 隔离(全局唯一,与 list_todos 保持一致)
pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option<String> { pub(crate) fn scope_key_from_context(context: &ToolContext) -> Option<String> {
if context.nesting_depth > 0 { if context.nesting_depth > 0 {
context.session_id.clone().filter(|s| !s.is_empty()) // 使用 task_id 而不是 session_id 作为 scope_key。
// session_id 对于孙智能体包含父链(如 sub:sub:root:parent:task
// 而 list_todos handler 用根 session + task_id 拼接,两者不匹配。
// task_id 是全局唯一的 UUIDtask:xxx直接使用可避免层级不一致。
context.task_id.clone().filter(|s| !s.is_empty())
} else { } else {
let tid = context.topic_id.as_deref().filter(|t| !t.is_empty()); let tid = context.topic_id.as_deref().filter(|t| !t.is_empty());
let sid = context.session_id.as_deref().filter(|s| !s.is_empty()); let sid = context.session_id.as_deref().filter(|s| !s.is_empty());

View File

@ -364,7 +364,6 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
const isTaskTool = message.toolName === 'task' const isTaskTool = message.toolName === 'task'
const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null const taskResult = isTaskTool && hasResult ? parseTaskResult(displayContent) : null
const isSubAgent = !!message.subagentTaskId
const subagentType = (message.arguments as Record<string, unknown> | null)?.subagent_type as string || 'general' const subagentType = (message.arguments as Record<string, unknown> | null)?.subagent_type as string || 'general'
const taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || '' const taskDescription = (message.arguments as Record<string, unknown> | null)?.description as string || ''
const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || '' const taskPrompt = (message.arguments as Record<string, unknown> | null)?.prompt as string || ''

View File

@ -395,6 +395,34 @@ export function useChat(): UseChatReturn {
return return
} }
// Backfill grandchild task_id on task tool_call in sub-agent view.
// When the sub-agent spawns a grandchild via the task tool, the
// protocol's subagent_task_id field carries the parent task_id for
// routing, not the new child task_id. We must update it to the
// child's task_id from the task_started event so that "查看实时进度"
// navigates to the correct (grandchild) session.
if (message.type === 'task_started') {
const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) {
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && m.subagentTaskId === currentSubAgentView.taskId) {
updatedMessages[i] = { ...m, subagentTaskId: msg.task_id }
break
}
}
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
})
return
}
}
// Only accept messages explicitly tagged with matching subagent_task_id. // Only accept messages explicitly tagged with matching subagent_task_id.
// History messages are now tagged by the backend (send_task_messages), // History messages are now tagged by the backend (send_task_messages),
// and live sub-agent messages are tagged by SubAgentEmitter. // and live sub-agent messages are tagged by SubAgentEmitter.