Compare commits

...

5 Commits

Author SHA1 Message Date
oudecheng
95cf152ab9 fix(agent): 修复工具调用消息序列的完整性问题
- 在历史压缩时,移除保留消息中不完整的工具调用序列,防止API 400错误
- 压缩后对当前历史消息再次进行工具调用序列清理,确保消息序列有效性
- OpenAI请求构建中,增加位置感知的工具调用和结果配对验证
- 跳过含有不完整工具调用序列的助手消息,避免发送非法请求
- 剥离不完整助手消息的工具调用字段,作为普通助手消息序列化
- 跟踪并警告不完整工具调用序列和被跳过的消息索引
2026-06-23 08:44:48 +08:00
oudecheng
3be9c1e646 feat(storage): 添加 todos 表的 created_by_message_id 字段迁移
- 检查 todos 表中是否存在 created_by_message_id 列
- 若不存在则添加该列,类型为 TEXT
- 记录添加列的迁移过程日志
- 确保数据库表结构更新完成后返回成功状态
2026-06-22 14:55:13 +08:00
oudecheng
4efc8b51e7 feat(todo): 添加待办项关联的创建消息ID并支持消息高亮
- 在待办相关数据结构和存储中新增 created_by_message_id 字段
- 记录待办项创建时对应的消息ID,支持追溯来源
- 在前端待办列表项增加点击事件,点击后滚动并高亮对应消息
- 在消息列表组件中实现高亮动画及自动滚动功能
- 更新相关工具、协议和数据库查询,确保新字段正确传递和存储
- 增加 CSS 动画实现待办对应消息的高亮闪烁效果
- 优化前端状态管理,支持设置与获取高亮消息ID
2026-06-22 14:50:37 +08:00
oudecheng
6a496ce212 feat(task): 优化孙智能体任务消息的发送与工具注册
- 在发送任务消息时增加可选的任务仓库参数支持子任务重发
- 新增 extract_parent_task_id 函数用于提取孙智能体的父任务 ID
- 补发子任务(孙智能体)的 TaskStarted 事件,解决视图重进导致的 navigateToTaskId 丢失
- 判断并附加子任务的父任务 ID,完善日志记录与事件发送
- 在子智能体运行时根据深度排除 task 工具,防止无限嵌套调用
- ToolRegistry 新增 without 方法,可创建排除指定工具的新实例用于子智能体配置
2026-06-22 11:31:41 +08:00
oudecheng
606fcbcd29 feat(agent): 注入并传递 tool_call_id 实现任务与工具调用精准关联
- 在 agent_loop 执行上下文中注入 tool_call_id,确保执行时传递该字段
- 在 agent_factory、ws、todo_read、todo_write 等多处构造对象时添加 tool_call_id 字段初始化
- 扩展协议定义及序列化,支持 tool_call_id 字段传递
- 在工具调用任务运行时传递 tool_call_id,便于事件追踪和层级关联
- 在前端 useChat hook 增加 tool_call_id 关联逻辑,实现 task_started 事件精准匹配和跳转
- 增加 pendingTaskNavs 缓存处理,解决 task_started 事件先于 tool_call 到达的顺序问题
- 调整消息渲染逻辑,根据 tool_call_id 进行优先匹配,提升用户交互体验
2026-06-22 10:22:36 +08:00
25 changed files with 357 additions and 36 deletions

View File

@ -1416,7 +1416,14 @@ impl AgentLoop {
}; };
match tool match tool
.execute_with_context(&self.tool_context, normalized_arguments.clone()) .execute_with_context(
&{
let mut ctx = self.tool_context.clone();
ctx.tool_call_id = Some(tool_call.id.clone());
ctx
},
normalized_arguments.clone(),
)
.await .await
{ {
Ok(result) => { Ok(result) => {

View File

@ -408,13 +408,28 @@ Be concise, aim for {} characters or less.
.summarize_segment(&summary_source, provider_config) .summarize_segment(&summary_source, provider_config)
.await?; .await?;
// Sanitize preserved messages: the boundary between the summarized
// and preserved sections can split an assistant tool_calls message
// from its tool result messages, creating orphaned sequences that
// would cause API 400 errors.
let mut preserved_messages = history[preserved_turn_start..].to_vec();
let removed =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut preserved_messages);
if removed > 0 {
tracing::warn!(
removed_count = removed,
preserved_turn_start,
"Compaction plan: removed incomplete tool call sequences from preserved messages"
);
}
Ok(Some(HistoryCompactionPlan { Ok(Some(HistoryCompactionPlan {
preserved_system_messages, preserved_system_messages,
summary_message: ChatMessage::system_with_context( summary_message: ChatMessage::system_with_context(
format!("[Compressed History]\n\n{}", summary), format!("[Compressed History]\n\n{}", summary),
Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()), Some(SYSTEM_CONTEXT_HISTORY_COMPACTION.to_string()),
), ),
preserved_messages: history[preserved_turn_start..].to_vec(), preserved_messages,
compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns, compressed_turns: turn_ranges.len() - self.config.retain_last_user_turns,
preserved_turns: self.config.retain_last_user_turns, preserved_turns: self.config.retain_last_user_turns,
})) }))
@ -445,7 +460,7 @@ Be concise, aim for {} characters or less.
"Starting context compression" "Starting context compression"
); );
let current_history = match self let mut current_history = match self
.build_compaction_plan(&history, provider_config) .build_compaction_plan(&history, provider_config)
.await? .await?
{ {
@ -461,6 +476,21 @@ Be concise, aim for {} characters or less.
None => history, None => history,
}; };
// Post-compression sanitization: compression can split an assistant
// tool_calls message from its tool result messages at the boundary
// between the summarized and preserved sections. This pass removes
// any orphaned tool_calls or tool results to ensure the message
// sequence is always valid for the LLM API.
let removed =
crate::bus::message::sanitize_incomplete_tool_call_sequences(&mut current_history);
if removed > 0 {
tracing::warn!(
removed_count = removed,
remaining_messages = current_history.len(),
"Post-compression sanitization removed incomplete tool call sequences"
);
}
tracing::info!( tracing::info!(
final_tokens = estimate_tokens(&current_history), final_tokens = estimate_tokens(&current_history),
final_msg_count = current_history.len(), final_msg_count = current_history.len(),

View File

@ -76,6 +76,7 @@ impl CommandHandler for ListTodosCommandHandler {
id: r.id, id: r.id,
content: r.content, content: r.content,
status: r.status, status: r.status,
created_by_message_id: r.created_by_message_id,
}) })
.collect(); .collect();

View File

@ -82,6 +82,7 @@ impl AgentFactory {
nesting_depth: 0, nesting_depth: 0,
task_id: None, task_id: None,
parent_task_id: None, parent_task_id: None,
tool_call_id: None,
}); });
// 如果有取消信号接收端,注入 Agent // 如果有取消信号接收端,注入 Agent
if let Some(token) = request.cancel_token { if let Some(token) = request.cancel_token {

View File

@ -200,6 +200,7 @@ impl BusToolCallEmitter {
priority: "medium".to_string(), priority: "medium".to_string(),
created_at: now + idx as i64, created_at: now + idx as i64,
updated_at: now, updated_at: now,
created_by_message_id: Some(message.id.clone()),
}) })
}) })
.collect(); .collect();

View File

@ -492,7 +492,7 @@ async fn handle_inbound(
if let Some(task_session_id) = response.metadata.get("task_session_id") { if let Some(task_session_id) = response.metadata.get("task_session_id") {
// 提前提取 task_id用于给历史消息打标记 // 提前提取 task_id用于给历史消息打标记
let task_id = response.metadata.get("task_id").cloned().unwrap_or_default(); let task_id = response.metadata.get("task_id").cloned().unwrap_or_default();
if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone())).await { if let Err(e) = send_task_messages(&store, task_session_id, sender, Some(task_id.clone()), Some(&state.task_repository)).await {
tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages"); tracing::warn!(error = %e, task_session_id = %task_session_id, "Failed to send task messages");
} }
@ -570,7 +570,7 @@ async fn handle_inbound(
&load_chat_channel, &load_chat_channel,
load_chat_id, load_chat_id,
); );
if let Err(e) = send_task_messages(&store, &session_id, sender, None).await { if let Err(e) = send_task_messages(&store, &session_id, sender, None, None).await {
tracing::warn!( tracing::warn!(
error = %e, error = %e,
channel = %load_chat_channel, channel = %load_chat_channel,
@ -669,9 +669,13 @@ async fn send_topic_history(
for task in running_tasks { for task in running_tasks {
if task.state == TaskSessionState::Running { if task.state == TaskSessionState::Running {
// 判断是否为孙智能体parent_session_id 以 "sub:" 开头表示父会话是子智能体
let parent_task_id = extract_parent_task_id(&task);
tracing::info!( tracing::info!(
task_id = %task.id, task_id = %task.id,
description = %task.description, description = %task.description,
parent_task_id = ?parent_task_id,
"Re-sending TaskStarted for running task after topic history load" "Re-sending TaskStarted for running task after topic history load"
); );
let _ = sender let _ = sender
@ -680,7 +684,8 @@ async fn send_topic_history(
description: task.description.clone(), description: task.description.clone(),
subagent_type: task.subagent_type.clone(), subagent_type: task.subagent_type.clone(),
topic_id: Some(topic_id.to_string()), topic_id: Some(topic_id.to_string()),
parent_task_id: None, parent_task_id,
tool_call_id: None,
}) })
.await; .await;
} }
@ -695,6 +700,7 @@ async fn send_task_messages(
session_id: &str, session_id: &str,
sender: &mpsc::Sender<WsOutbound>, sender: &mpsc::Sender<WsOutbound>,
subagent_task_id: Option<String>, subagent_task_id: Option<String>,
task_repository: Option<&Arc<dyn TaskRepository>>,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let messages = store.load_messages(session_id)?; let messages = store.load_messages(session_id)?;
@ -712,6 +718,37 @@ async fn send_task_messages(
} }
} }
// 补发子任务(孙智能体)的 TaskStarted 事件
// 解决重新进入子智能体视图后 navigateToTaskId 丢失的问题
if let (Some(repo), Some(parent_task_id)) = (task_repository, &subagent_task_id) {
match repo.list_tasks_for_session(session_id).await {
Ok(child_tasks) => {
for child in child_tasks {
if child.state == TaskSessionState::Running {
tracing::info!(
child_task_id = %child.id,
parent_task_id = %parent_task_id,
"Re-sending TaskStarted for child task after sub-agent view re-enter"
);
let _ = sender
.send(WsOutbound::TaskStarted {
task_id: child.id.clone(),
description: child.description.clone(),
subagent_type: child.subagent_type.clone(),
topic_id: child.parent_topic_id.clone(),
parent_task_id: Some(parent_task_id.clone()),
tool_call_id: None,
})
.await;
}
}
}
Err(e) => {
tracing::warn!(error = %e, session_id = %session_id, "Failed to list child tasks for resend");
}
}
}
Ok(()) Ok(())
} }
@ -736,6 +773,20 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) {
} }
} }
/// 从 TaskSession 中提取父任务 ID仅孙智能体有值
/// 孙智能体的 parent_session_id 格式为 "sub:{grandparent_session}:task:{parent_task_uuid}"
/// 从中提取 "task:{parent_task_uuid}" 作为 parent_task_id。
fn extract_parent_task_id(task: &crate::tools::task::types::TaskSession) -> Option<String> {
let parent = &task.parent_session_id;
// 仅当父会话是子智能体会话时才提取(格式: "sub:...:task:{uuid}"
if parent.starts_with("sub:") {
if let Some(pos) = parent.find(":task:") {
return Some(parent[pos + 1..].to_string()); // "task:{uuid}"
}
}
None
}
/// 将 ChatMessage 转换为 WsOutbound 列表 /// 将 ChatMessage 转换为 WsOutbound 列表
fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> { fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound> {
use crate::bus::message::ToolMessageState; use crate::bus::message::ToolMessageState;

View File

@ -88,6 +88,7 @@ pub struct TodoItemSummary {
pub id: String, pub id: String,
pub content: String, pub content: String,
pub status: String, pub status: String,
pub created_by_message_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -214,6 +215,8 @@ pub enum WsOutbound {
topic_id: Option<String>, topic_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
parent_task_id: Option<String>, parent_task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
}, },
#[serde(rename = "session_established")] #[serde(rename = "session_established")]
SessionEstablished { session_id: String }, SessionEstablished { session_id: String },

View File

@ -174,6 +174,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(), subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
topic_id: message.metadata.get("topic_id").cloned(), topic_id: message.metadata.get("topic_id").cloned(),
parent_task_id: message.metadata.get("parent_task_id").cloned(), parent_task_id: message.metadata.get("parent_task_id").cloned(),
tool_call_id: message.metadata.get("tool_call_id").cloned(),
}], }],
OutboundEventKind::StreamDelta => vec![WsOutbound::StreamDelta { OutboundEventKind::StreamDelta => vec![WsOutbound::StreamDelta {
id: message.tool_call_id.clone().unwrap_or_default(), id: message.tool_call_id.clone().unwrap_or_default(),

View File

@ -632,41 +632,68 @@ impl OpenAIProvider {
fn build_request_body(&self, request: &ChatCompletionRequest) -> Value { fn build_request_body(&self, request: &ChatCompletionRequest) -> Value {
let supports_images = self.supports_images(); let supports_images = self.supports_images();
// --- Final defense: validate tool_call / tool result pairing --- // --- Final defense: position-aware tool_call / tool result validation ---
// Collect all tool_call_ids that have a corresponding tool result message. //
// Any assistant tool_call NOT in this set is orphaned and must be stripped // Scan right-to-left (matching sanitize_incomplete_tool_call_sequences)
// to avoid API 400 errors ("insufficient tool messages following tool_calls"). // so that only tool results appearing AFTER an assistant message count
// as "resolved". A simple global scan would incorrectly accept a tool
// result that precedes its parent assistant (e.g. after compaction
// boundary splits), leading to API 400 errors:
// "insufficient tool messages following tool_calls message".
let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new(); let mut resolved_tool_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for m in &request.messages { let mut with_parent: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut skip_assistant_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
for (i, m) in request.messages.iter().enumerate().rev() {
if m.role == "tool" { if m.role == "tool" {
if let Some(ref tc_id) = m.tool_call_id { if let Some(ref tc_id) = m.tool_call_id {
resolved_tool_ids.insert(tc_id.as_str()); resolved_tool_ids.insert(tc_id.as_str());
} }
} }
}
// Build the set of assistant tool_call_ids that are fully valid
// (ALL tool_calls in the message have corresponding results).
// If an assistant has partial or no valid tool_calls, we strip the
// tool_calls field and serialize it as a plain assistant message.
let mut valid_tool_call_parent_ids: std::collections::HashSet<&str> = std::collections::HashSet::new();
for m in &request.messages {
if m.role == "assistant" { if m.role == "assistant" {
if let Some(ref calls) = m.tool_calls { if let Some(ref calls) = m.tool_calls {
if !calls.is_empty() if !calls.is_empty() {
&& calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str())) let all_resolved =
{ calls.iter().all(|tc| resolved_tool_ids.contains(tc.id.as_str()));
if all_resolved {
for tc in calls { for tc in calls {
valid_tool_call_parent_ids.insert(tc.id.as_str()); with_parent.insert(tc.id.as_str());
}
} else {
skip_assistant_indices.insert(i);
} }
} }
} }
} }
} }
// valid_tool_call_parent_ids = with_parent (assistant tool_call_ids
// whose parent assistant has ALL results after it)
let valid_tool_call_parent_ids = &with_parent;
let mut body = json!({ let mut body = json!({
"model": self.model_id, "model": self.model_id,
"messages": request.messages.iter().enumerate().filter_map(|(i, m)| { "messages": request.messages.iter().enumerate().filter_map(|(i, m)| {
// Skip assistant messages identified as having incomplete tool_calls
// (position-aware scan found missing tool results after this assistant).
if skip_assistant_indices.contains(&i) {
tracing::warn!(
message_index = i,
"build_request_body: skipping assistant with incomplete tool call sequence \
(tool results missing after this position)"
);
// Serialize as plain assistant message (strip tool_calls)
let mut message = json!({
"role": m.role,
"content": convert_content_blocks(supports_images, &self.name, &self.model_id, &m.content, i)
});
if let Some(reasoning_content) = &m.reasoning_content {
message["reasoning_content"] = Value::String(reasoning_content.clone());
}
return Some(message);
}
if m.role == "tool" { if m.role == "tool" {
// Skip orphaned tool results (no matching assistant tool_call) // Skip orphaned tool results (no matching assistant tool_call)
let is_orphaned = match &m.tool_call_id { let is_orphaned = match &m.tool_call_id {

View File

@ -1517,7 +1517,7 @@ impl SessionStore {
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> { pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
let conn = self.pool.get()?; let conn = self.pool.get()?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at "SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at, created_by_message_id
FROM todos FROM todos
WHERE scope_key = ?1 WHERE scope_key = ?1
ORDER BY created_at ASC", ORDER BY created_at ASC",
@ -1534,6 +1534,7 @@ impl SessionStore {
priority: row.get(6)?, priority: row.get(6)?,
created_at: row.get(7)?, created_at: row.get(7)?,
updated_at: row.get(8)?, updated_at: row.get(8)?,
created_by_message_id: row.get(9)?,
}) })
})?; })?;
@ -1913,6 +1914,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
priority TEXT NOT NULL DEFAULT 'medium', priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
created_by_message_id TEXT,
PRIMARY KEY (id, scope_key) PRIMARY KEY (id, scope_key)
); );
@ -1953,6 +1955,7 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
priority TEXT NOT NULL DEFAULT 'medium', priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
created_by_message_id TEXT,
PRIMARY KEY (id, scope_key) PRIMARY KEY (id, scope_key)
); );
@ -1974,6 +1977,17 @@ fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
tracing::info!("Todos table migration complete"); tracing::info!("Todos table migration complete");
} }
// Column migration: add created_by_message_id if it doesn't exist
let has_column = has_column(&conn, "todos", "created_by_message_id")?;
if !has_column {
tracing::info!("Adding created_by_message_id column to todos table");
conn.execute(
"ALTER TABLE todos ADD COLUMN created_by_message_id TEXT",
[],
)?;
tracing::info!("Todos table column migration complete");
}
Ok(()) Ok(())
} }

View File

@ -46,6 +46,7 @@ pub struct TodoRecord {
pub priority: String, pub priority: String,
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
pub created_by_message_id: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -73,6 +73,20 @@ impl ToolRegistry {
.cloned() .cloned()
.collect() .collect()
} }
/// 创建一个排除指定工具的新 registry 副本
pub fn without(&self, exclude: &[&str]) -> Self {
let exclude_set: std::collections::HashSet<&str> = exclude.iter().copied().collect();
let tools = self.tools.read().expect("ToolRegistry lock poisoned");
let filtered: HashMap<String, Arc<dyn ToolTrait>> = tools
.iter()
.filter(|(name, _)| !exclude_set.contains(name.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let new_registry = ToolRegistry::new();
*new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered;
new_registry
}
} }
impl Default for ToolRegistry { impl Default for ToolRegistry {

View File

@ -19,6 +19,7 @@ use crate::tools::{ToolContext, ToolRegistry};
use super::error::TaskError; use super::error::TaskError;
use super::prompt::{extract_summary, SubagentPromptBuilder}; use super::prompt::{extract_summary, SubagentPromptBuilder};
use super::repository::TaskRepository; use super::repository::TaskRepository;
use super::tool::TaskTool;
use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult}; use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult};
/// 子代理运行时配置 /// 子代理运行时配置
@ -234,6 +235,7 @@ impl SubAgentEmitter {
priority: "medium".to_string(), priority: "medium".to_string(),
created_at: now + idx as i64, created_at: now + idx as i64,
updated_at: now, updated_at: now,
created_by_message_id: Some(message.id.clone()),
}) })
}) })
.collect(); .collect();
@ -332,9 +334,17 @@ impl DefaultSubAgentRuntime {
) -> Result<AgentLoop, TaskError> { ) -> Result<AgentLoop, TaskError> {
let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt)); let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt));
// 孙智能体depth >= 2不注册 task 工具,防止无限嵌套
let child_depth = parent_nesting_depth + 1;
let tools = if child_depth >= 2 {
Arc::new(self.subagent_tools.without(&[TaskTool::TOOL_NAME]))
} else {
self.subagent_tools.clone()
};
AgentLoop::with_tools_and_system_prompt_provider( AgentLoop::with_tools_and_system_prompt_provider(
AgentRuntimeConfig::from(self.provider_config.clone()), AgentRuntimeConfig::from(self.provider_config.clone()),
self.subagent_tools.clone(), tools,
prompt_provider, prompt_provider,
None, // 子代理不需要 skill provider None, // 子代理不需要 skill provider
) )
@ -351,6 +361,7 @@ impl DefaultSubAgentRuntime {
nesting_depth: parent_nesting_depth + 1, nesting_depth: parent_nesting_depth + 1,
task_id: Some(session.id.clone()), task_id: Some(session.id.clone()),
parent_task_id, parent_task_id,
tool_call_id: None,
}); });
// 如果有 MessageBus附加实时广播 emitter // 如果有 MessageBus附加实时广播 emitter
@ -543,6 +554,11 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
metadata.insert("parent_task_id".to_string(), ptid.clone()); metadata.insert("parent_task_id".to_string(), ptid.clone());
} }
// 传递 tool_call_id前端据此精确匹配创建此任务的 tool_call
if let Some(ref tcid) = parent_context.tool_call_id {
metadata.insert("tool_call_id".to_string(), tcid.clone());
}
let event = OutboundMessage { let event = OutboundMessage {
channel: session.parent_channel_name.clone(), channel: session.parent_channel_name.clone(),
chat_id: session.parent_chat_id.clone(), chat_id: session.parent_chat_id.clone(),

View File

@ -15,6 +15,8 @@ pub struct TaskTool {
} }
impl TaskTool { impl TaskTool {
pub const TOOL_NAME: &'static str = "task";
/// 创建 TaskTool /// 创建 TaskTool
/// - `max_nesting_depth = None`:无深度限制(主 agent /// - `max_nesting_depth = None`:无深度限制(主 agent
/// - `max_nesting_depth = Some(N)`:允许最多 N 层嵌套(子 agent /// - `max_nesting_depth = Some(N)`:允许最多 N 层嵌套(子 agent
@ -29,7 +31,7 @@ impl TaskTool {
#[async_trait] #[async_trait]
impl Tool for TaskTool { impl Tool for TaskTool {
fn name(&self) -> &str { fn name(&self) -> &str {
"task" Self::TOOL_NAME
} }
fn description(&self) -> &str { fn description(&self) -> &str {

View File

@ -118,6 +118,7 @@ impl Tool for TodoReadTool {
id: r.id, id: r.id,
content: r.content, content: r.content,
status: r.status, status: r.status,
created_by_message_id: r.created_by_message_id,
}) })
.collect(); .collect();
@ -185,6 +186,7 @@ mod tests {
nesting_depth: 0, nesting_depth: 0,
task_id: None, task_id: None,
parent_task_id: None, parent_task_id: None,
tool_call_id: None,
} }
} }
@ -224,6 +226,7 @@ mod tests {
priority: "medium".to_string(), priority: "medium".to_string(),
created_at: 1000, created_at: 1000,
updated_at: 1000, updated_at: 1000,
created_by_message_id: None,
} }
} }
@ -238,6 +241,7 @@ mod tests {
id: "a1".to_string(), id: "a1".to_string(),
content: "任务A".to_string(), content: "任务A".to_string(),
status: "pending".to_string(), status: "pending".to_string(),
created_by_message_id: None,
}], }],
); );
} }
@ -313,6 +317,7 @@ mod tests {
id: "m1".to_string(), id: "m1".to_string(),
content: "主会话任务".to_string(), content: "主会话任务".to_string(),
status: "pending".to_string(), status: "pending".to_string(),
created_by_message_id: None,
}], }],
); );
} }

View File

@ -45,6 +45,7 @@ pub(crate) struct TodoItem {
pub id: String, pub id: String,
pub content: String, pub content: String,
pub status: String, pub status: String,
pub created_by_message_id: Option<String>,
} }
/// 工具完整返回 /// 工具完整返回
@ -143,7 +144,10 @@ impl Tool for TodoWriteTool {
None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")), None => return Ok(error_result("todo_write requires session_id or topic_id in tool context")),
}; };
// 2. 解析入参 // 2. 提取当前消息 ID用于记录待办的创建来源
let message_id = context.message_id.clone();
// 3. 解析入参
let todos_array = match args.get("todos").and_then(|v| v.as_array()) { let todos_array = match args.get("todos").and_then(|v| v.as_array()) {
Some(arr) => arr, Some(arr) => arr,
None => return Ok(error_result("Missing required parameter: todos (must be an array)")), None => return Ok(error_result("Missing required parameter: todos (must be an array)")),
@ -219,6 +223,7 @@ impl Tool for TodoWriteTool {
id, id,
content, content,
status: new_status.as_str().to_string(), status: new_status.as_str().to_string(),
created_by_message_id: message_id.clone(),
}); });
} else if merge_mode { } else if merge_mode {
// merge 模式id 不匹配,尝试 content fallback // merge 模式id 不匹配,尝试 content fallback
@ -238,6 +243,7 @@ impl Tool for TodoWriteTool {
id: old_item.id.clone(), id: old_item.id.clone(),
content, content,
status: new_status.as_str().to_string(), status: new_status.as_str().to_string(),
created_by_message_id: message_id.clone(),
}); });
} else { } else {
// 全新项 // 全新项
@ -245,6 +251,7 @@ impl Tool for TodoWriteTool {
id, id,
content, content,
status: new_status.as_str().to_string(), status: new_status.as_str().to_string(),
created_by_message_id: message_id.clone(),
}); });
} }
} else { } else {
@ -253,6 +260,7 @@ impl Tool for TodoWriteTool {
id, id,
content, content,
status: new_status.as_str().to_string(), status: new_status.as_str().to_string(),
created_by_message_id: message_id.clone(),
}); });
} }
} }
@ -415,6 +423,7 @@ mod tests {
nesting_depth: 0, nesting_depth: 0,
task_id: None, task_id: None,
parent_task_id: None, parent_task_id: None,
tool_call_id: None,
} }
} }

View File

@ -24,6 +24,8 @@ pub struct ToolContext {
pub task_id: Option<String>, pub task_id: Option<String>,
/// 父任务 ID仅子/孙智能体有值,用于构建任务层级) /// 父任务 ID仅子/孙智能体有值,用于构建任务层级)
pub parent_task_id: Option<String>, pub parent_task_id: Option<String>,
/// 当前工具调用的 ID由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件)
pub tool_call_id: Option<String>,
} }
#[async_trait] #[async_trait]

View File

@ -13,7 +13,7 @@ import { ChannelSelector } from './components/Header/ChannelSelector'
import { SessionSelector } from './components/Header/SessionSelector' import { SessionSelector } from './components/Header/SessionSelector'
import { useWebSocket } from './hooks/useWebSocket' import { useWebSocket } from './hooks/useWebSocket'
import { useChat } from './hooks/useChat' import { useChat } from './hooks/useChat'
import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup } from './types/protocol' import type { ChatMessage, Command, Attachment, SchedulerJobSessionLookup, TodoItemSummary } from './types/protocol'
function getInitialSettings(): GatewaySettings { function getInitialSettings(): GatewaySettings {
return getGatewaySettings() return getGatewaySettings()
@ -54,6 +54,9 @@ function App() {
setTodos, setTodos,
requestTodoList, requestTodoList,
requestSubAgentTodoList, requestSubAgentTodoList,
// 高亮消息
highlightedMessageId,
setHighlightedMessageId,
// 定时任务 // 定时任务
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
@ -396,6 +399,17 @@ function App() {
: requestTodoList() : requestTodoList()
}, [subAgentView, requestTodoList, requestSubAgentTodoList]) }, [subAgentView, requestTodoList, requestSubAgentTodoList])
// 点击待办项后滚动到对应消息
const handleTodoClick = useCallback((todo: TodoItemSummary) => {
// 直接使用后端返回的 created_by_message_id
if (todo.created_by_message_id) {
setHighlightedMessageId(todo.created_by_message_id)
} else {
// 如果消息 ID 不存在(旧数据),给出友好提示
alert('该待办的完成记录无法定位,可能是历史数据')
}
}, [setHighlightedMessageId])
const handleRefreshSchedulerJobs = useCallback(() => { const handleRefreshSchedulerJobs = useCallback(() => {
const cmd = requestSchedulerJobList() const cmd = requestSchedulerJobList()
handleCommand(cmd) handleCommand(cmd)
@ -694,11 +708,13 @@ function App() {
onStop={handleStopExecution} onStop={handleStopExecution}
showThinking={showThinking} showThinking={showThinking}
viewKey={viewKey} viewKey={viewKey}
highlightedMessageId={highlightedMessageId}
todoPanel={ todoPanel={
<TodoPanel <TodoPanel
todos={todos} todos={todos}
requestTodoList={refreshTodoList} requestTodoList={refreshTodoList}
sendCommand={sendMemoryCommand} sendCommand={sendMemoryCommand}
onTodoClick={handleTodoClick}
/> />
} }
/> />

View File

@ -15,6 +15,8 @@ interface ChatContainerProps {
todoPanel?: React.ReactNode todoPanel?: React.ReactNode
/** 视图标识,用于保存/恢复滚动位置 */ /** 视图标识,用于保存/恢复滚动位置 */
viewKey?: string viewKey?: string
/** 高亮的消息 ID */
highlightedMessageId?: string | null
} }
export function ChatContainer({ export function ChatContainer({
@ -28,11 +30,12 @@ export function ChatContainer({
showThinking = true, showThinking = true,
todoPanel, todoPanel,
viewKey, viewKey,
highlightedMessageId,
}: ChatContainerProps) { }: ChatContainerProps) {
return ( return (
<div className="flex h-full flex-col relative"> <div className="flex h-full flex-col relative">
<div className="flex-1 overflow-hidden relative"> <div className="flex-1 overflow-hidden relative">
<MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} /> <MessageList messages={messages} onNavigateToSubAgent={onNavigateToSubAgent} showThinking={showThinking} viewKey={viewKey} highlightedMessageId={highlightedMessageId} />
{todoPanel} {todoPanel}
</div> </div>
<MessageInput <MessageInput

View File

@ -647,7 +647,7 @@ export function MessageBubble({ message, onNavigateToSubAgent, showThinking = tr
} }
return ( return (
<div className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}> <div data-message-id={message.id} className={`flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'} animate-slide-in group`}>
<div <div
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`} className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-full ${getAvatarStyles()} shadow-lg`}
> >

View File

@ -9,9 +9,11 @@ interface MessageListProps {
showThinking?: boolean showThinking?: boolean
/** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */ /** 视图标识,用于保存/恢复滚动位置。不同视图间切换时保持各自的滚动位置。 */
viewKey?: string viewKey?: string
/** 高亮的消息 ID点击待办项后滚动并高亮显示 */
highlightedMessageId?: string | null
} }
export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey }: MessageListProps) { export function MessageList({ messages, onNavigateToSubAgent, showThinking = true, viewKey, highlightedMessageId }: MessageListProps) {
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const isAtBottomRef = useRef(true) const isAtBottomRef = useRef(true)
@ -117,6 +119,28 @@ export function MessageList({ messages, onNavigateToSubAgent, showThinking = tru
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
// ---- highlight and scroll to todo message ----
useEffect(() => {
if (!highlightedMessageId) return
const container = containerRef.current
if (!container) return
// 查找目标消息元素
const targetElement = container.querySelector(`[data-message-id="${highlightedMessageId}"]`)
if (!targetElement) return
// 滚动到目标位置
targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 添加高亮样式
targetElement.classList.add('todo-highlight')
setTimeout(() => {
targetElement.classList.remove('todo-highlight')
}, 2000)
}, [highlightedMessageId])
// ---- empty state ---- // ---- empty state ----
if (messages.length === 0) { if (messages.length === 0) {

View File

@ -6,6 +6,7 @@ interface TodoPanelProps {
todos: TodoItemSummary[] todos: TodoItemSummary[]
requestTodoList: () => Command requestTodoList: () => Command
sendCommand: (cmd: Command) => void sendCommand: (cmd: Command) => void
onTodoClick?: (todo: TodoItemSummary) => void
} }
/* ── status config ────────────────────────────────────── */ /* ── status config ────────────────────────────────────── */
@ -64,7 +65,7 @@ function savePos(pos: { x: number; y: number }) {
/* ── TodoPanel ────────────────────────────────────────── */ /* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) { export function TodoPanel({ todos, requestTodoList, sendCommand, onTodoClick }: TodoPanelProps) {
const [expanded, setExpanded] = useState(() => { const [expanded, setExpanded] = useState(() => {
try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false } try { return localStorage.getItem('picobot-todo-expanded') === 'true' } catch { return false }
}) })
@ -249,12 +250,16 @@ export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProp
<div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}> <div className={`todo-group-body ${isCollapsed ? 'todo-group-body-closed' : 'todo-group-body-open'}`}>
<div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5"> <div className="ml-[7px] border-l-2 border-[var(--border-color)]/60 pl-3 mt-1.5 space-y-0.5">
{items.map(item => ( {items.map(item => (
<div key={item.id} className="group/item py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5"> <button
key={item.id}
onClick={() => onTodoClick?.(item)}
className="group/item w-full text-left py-1.5 px-2 -mx-2 rounded-md transition-colors duration-150 hover:bg-[var(--overlay-hover)] flex items-start gap-1.5 cursor-pointer"
>
<span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} /> <span className={`h-2 w-2 rounded-full ${cfg.dot} shrink-0 mt-1.5`} />
<span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words"> <span className="text-[13px] leading-relaxed text-[var(--text-primary)]/85 group-hover/item:text-[var(--text-primary)] transition-colors break-words">
{item.content} {item.content}
</span> </span>
</div> </button>
))} ))}
</div> </div>
</div> </div>

View File

@ -106,6 +106,10 @@ interface UseChatReturn {
requestTodoList: () => Command requestTodoList: () => Command
requestSubAgentTodoList: (subTaskId: string) => Command requestSubAgentTodoList: (subTaskId: string) => Command
// 高亮消息 ID点击待办后滚动到对应消息
highlightedMessageId: string | null
setHighlightedMessageId: Dispatch<SetStateAction<string | null>>
// 定时任务状态 // 定时任务状态
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler' sidebarTab: 'topics' | 'scheduler'
@ -156,6 +160,7 @@ export function useChat(): UseChatReturn {
const [memories, setMemories] = useState<MemorySummary[]>([]) const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([]) const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [highlightedMessageId, setHighlightedMessageId] = useState<string | null>(null)
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]) const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null) const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
@ -176,6 +181,10 @@ export function useChat(): UseChatReturn {
const selectedTopicRef = useRef<string | null>(null) const selectedTopicRef = useRef<string | null>(null)
const pendingNewTopicRef = useRef(false) const pendingNewTopicRef = useRef(false)
// Pending task navigations: tool_call_id -> task_id
// Used when task_started arrives before the tool_call is in the sub-agent view
const pendingTaskNavsRef = useRef<Map<string, string>>(new Map())
// Ref to send commands from within handleServerMessage (set by App.tsx) // Ref to send commands from within handleServerMessage (set by App.tsx)
const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null) const sendMessageRef = useRef<((msg: WsInbound) => boolean) | null>(null)
const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => { const setSendMessage = useCallback((fn: (msg: WsInbound) => boolean) => {
@ -391,14 +400,30 @@ export function useChat(): UseChatReturn {
if (message.type === 'task_started') { if (message.type === 'task_started') {
const msg = message as TaskStarted const msg = message as TaskStarted
if (msg.parent_task_id === currentSubAgentView.taskId) { if (msg.parent_task_id === currentSubAgentView.taskId) {
let matched = false
setSubAgentStack((prev) => { setSubAgentStack((prev) => {
if (prev.length === 0) return prev if (prev.length === 0) return prev
const top = prev[prev.length - 1] const top = prev[prev.length - 1]
const updatedMessages = [...top.messages] const updatedMessages = [...top.messages]
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = updatedMessages.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !updatedMessages[idx].navigateToTaskId) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: msg.task_id }
matched = true
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = updatedMessages.length - 1; i >= 0; i--) { for (let i = updatedMessages.length - 1; i >= 0; i--) {
const m = updatedMessages[i] const m = updatedMessages[i]
if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) { if (m.type === 'tool_call' && m.toolName === 'task' && !m.navigateToTaskId) {
updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id } updatedMessages[i] = { ...m, navigateToTaskId: msg.task_id }
matched = true
break break
} }
} }
@ -406,6 +431,11 @@ export function useChat(): UseChatReturn {
newStack[newStack.length - 1] = { ...top, messages: updatedMessages } newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack return newStack
}) })
if (!matched) {
// tool_call 尚未到达,存储 pending navigation 等后续 tool_call 到达时回填
const key = msg.tool_call_id || `fallback:${msg.task_id}`
pendingTaskNavsRef.current.set(key, msg.task_id)
}
return return
} }
} }
@ -416,6 +446,33 @@ export function useChat(): UseChatReturn {
const msgSubagentTaskId = getSubagentTaskId(message) const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) { if (msgSubagentTaskId && msgSubagentTaskId === currentSubAgentView.taskId) {
appendToSubAgentViewMessage(message) appendToSubAgentViewMessage(message)
// 检查 pending navigation当 task tool_call 到达时,回填之前未匹配的 navigateToTaskId
if (message.type === 'tool_call') {
const tc = message as ToolCall
if (tc.tool_name === 'task' && tc.tool_call_id) {
const key = tc.tool_call_id
const pendingTaskId = pendingTaskNavsRef.current.get(key)
if (pendingTaskId) {
pendingTaskNavsRef.current.delete(key)
setSubAgentStack((prev) => {
if (prev.length === 0) return prev
const top = prev[prev.length - 1]
const updatedMessages = [...top.messages]
const idx = updatedMessages.findIndex(m =>
m.toolCallId === tc.tool_call_id && m.type === 'tool_call')
if (idx >= 0) {
updatedMessages[idx] = { ...updatedMessages[idx], navigateToTaskId: pendingTaskId }
const newStack = [...prev]
newStack[newStack.length - 1] = { ...top, messages: updatedMessages }
return newStack
}
return prev
})
}
}
}
// 子代理 todo_write 完成后自动刷新待办列表 // 子代理 todo_write 完成后自动刷新待办列表
if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') { if (message.type === 'tool_result' && (message as ToolResult).tool_name === 'todo_write') {
const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId) const refreshCmd = requestSubAgentTodoList(currentSubAgentView.taskId)
@ -459,10 +516,22 @@ export function useChat(): UseChatReturn {
// 设置 navigateToTaskId让用户可以点击查看实时进度 // 设置 navigateToTaskId让用户可以点击查看实时进度
setMessages((prev) => { setMessages((prev) => {
console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length) console.log('[useChat] task_started searching messages for task tool_call, total messages:', prev.length, 'tool_call_id:', msg.tool_call_id)
// 优先:按 tool_call_id 精确匹配
if (msg.tool_call_id) {
const idx = prev.findIndex(m =>
m.toolCallId === msg.tool_call_id && m.type === 'tool_call' && m.toolName === 'task')
if (idx >= 0 && !prev[idx].navigateToTaskId) {
console.log('[useChat] task_started EXACT MATCH at index', idx, 'task_id:', msg.task_id)
const updated = [...prev]
updated[idx] = { ...updated[idx], navigateToTaskId: msg.task_id }
return updated
}
}
// 回退backward-search (兼容无 tool_call_id 的旧版本)
for (let i = prev.length - 1; i >= 0; i--) { for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) { if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].navigateToTaskId) {
console.log('[useChat] task_started SET navigateToTaskId at index', i, 'task_id:', msg.task_id) console.log('[useChat] task_started BACKWARD MATCH at index', i, 'task_id:', msg.task_id)
const updated = [...prev] const updated = [...prev]
updated[i] = { ...updated[i], navigateToTaskId: msg.task_id } updated[i] = { ...updated[i], navigateToTaskId: msg.task_id }
return updated return updated
@ -1037,6 +1106,8 @@ export function useChat(): UseChatReturn {
setTodos, setTodos,
requestTodoList, requestTodoList,
requestSubAgentTodoList, requestSubAgentTodoList,
highlightedMessageId,
setHighlightedMessageId,
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
setSidebarTab, setSidebarTab,

View File

@ -294,10 +294,25 @@ body {
} }
} }
@keyframes todo-highlight-pulse {
0%, 100% {
background-color: transparent;
}
50% {
background-color: rgba(0, 240, 255, 0.15);
}
}
.animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); } .animate-todo-card-in { animation: todo-card-in 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); }
.animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; } .animate-todo-item-in { animation: todo-item-in 0.2s ease-out forwards; }
.animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; } .animate-todo-ring-pulse { animation: todo-ring-pulse 2s ease-in-out infinite; }
/* 待办点击高亮效果 */
.todo-highlight {
animation: todo-highlight-pulse 1s ease-in-out 2;
border-radius: 8px;
}
/* 分组折叠内容展开/收起 */ /* 分组折叠内容展开/收起 */
.todo-group-body { .todo-group-body {
overflow: hidden; overflow: hidden;

View File

@ -102,6 +102,7 @@ export interface TaskStarted {
subagent_type: string subagent_type: string
topic_id?: string topic_id?: string
parent_task_id?: string parent_task_id?: string
tool_call_id?: string
} }
export interface SessionEstablished { export interface SessionEstablished {
@ -209,6 +210,7 @@ export interface TodoItemSummary {
priority: string priority: string
created_at: number created_at: number
updated_at: number updated_at: number
created_by_message_id?: string
} }
export interface TodoList { export interface TodoList {