PicoBot/src/gateway/execution.rs
oudecheng fc3a95b152 feat: 异步子代理 v8 实现与 /stop 取消流程修复
后端核心实现:
- 新增 wait_coordinator:释放/重获取 serial_lock 的 in-tool waiting 模式,替代旧的 break-exit 方案
- 新增 wait_for_subagents 工具:select! 等待子代理完成/用户消息/超时,支持 try_drain 批量消费
- task 工具异步 spawn 路径:CancellationToken 注册表 + RAII guard + Semaphore 并发限流
- process exit 安全检查:pending 子代理存在时抑制 ExecutionCompleted
- 崩溃恢复:启动时标记 running->interrupted,history 加载时对账占位

/stop 取消流程修复:
- wait_coordinator select! 添加 cancel 分支,完整清理状态
- ToolContext 注入 cancel_rx(watch::Receiver clone)
- agent_loop 工具执行 select! 对 wait 工具跳过竞速,防止 drop coordinator 清理逻辑
- 子代理完成状态通过 execution_completed metadata 传播

存储层:
- pending_subagents 表 + 条件 UPDATE
- mark_all_running_as_interrupted 崩溃恢复
2026-08-13 22:12:20 +08:00

766 lines
31 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use std::collections::HashMap;
use std::sync::Arc;
use crate::agent::{
AgentError, AgentProcessResult, CompactionSink, EmittedMessageHandler,
PersistingEmittedMessageHandler, SystemPromptContext,
};
use crate::bus::message::ToolMessageState;
use crate::bus::{ChatMessage, MediaItem, OutboundMessage, SYSTEM_CONTEXT_SCHEDULED_PROMPT};
use crate::config::LLMProviderConfig;
use crate::storage::{ConversationRepository, persistent_session_id};
use async_trait::async_trait;
use tokio::sync::Mutex;
use super::compaction::schedule_background_history_compaction;
use super::message_prepare::enrich_user_content_with_media_refs;
use super::session::Session;
use super::wait_coordinator::SessionWaitCoordinator;
use crate::tools::WaitCoordinator;
/// 空的 EmittedMessageHandler不转发消息仅配合 PersistingEmittedMessageHandler 做持久化。
struct NoOpEmittedMessageHandler;
#[async_trait]
impl EmittedMessageHandler for NoOpEmittedMessageHandler {
async fn handle(&self, _message: ChatMessage) {}
}
/// CompactionSink 实现:在 AgentLoop 内部触发 LLM 压缩时,
/// 把压缩后的消息写回 DB标记原消息 is_compacted=1 + 插入摘要)。
///
/// 不在此处 reload 内存历史——process() 仍在使用局部 messages 变量,
/// 内存历史的刷新由 finalize_result 在 process 返回后统一处理。
pub(crate) struct CompactionSinkImpl {
session: Arc<Mutex<Session>>,
chat_id: String,
topic_id: String,
}
impl CompactionSinkImpl {
pub(crate) fn new(session: Arc<Mutex<Session>>, chat_id: String, topic_id: String) -> Self {
Self {
session,
chat_id,
topic_id,
}
}
}
#[async_trait]
impl CompactionSink for CompactionSinkImpl {
async fn compact(&self, compressed: &[ChatMessage]) -> Result<(), AgentError> {
let mut session_guard = self.session.lock().await;
session_guard.ensure_persistent_session(&self.chat_id)?;
session_guard.ensure_chat_loaded(&self.chat_id, Some(&self.topic_id))?;
let store = session_guard.store();
let session_id = session_guard.persistent_session_id(&self.chat_id);
store
.compact_topic_history(&session_id, &self.topic_id, compressed)
.map_err(|e| AgentError::Other(format!("compact_topic_history error: {}", e)))?;
tracing::info!(
chat_id = %self.chat_id,
topic_id = %self.topic_id,
compressed_msg_count = compressed.len(),
"In-loop LLM compaction committed to DB (original messages retained as is_compacted=1)"
);
Ok(())
}
}
const SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT: &str = "系统说明当前输入来自一次已经触发的定时任务执行。你现在需要执行任务内容本身而不是创建、修改、恢复、暂停或查询新的定时任务。除非当前任务内容明确要求管理调度器否则不要调用任何定时任务管理工具像“每小时”、“每天”、“cron”、“定时”等词只应视为任务背景不应再解释为新的建任务请求。";
pub(crate) fn compose_scheduled_task_system_prompt(system_prompt: Option<&str>) -> String {
match system_prompt
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(system_prompt) => format!(
"{}\n\n任务专属要求:{}",
SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT, system_prompt
),
None => SCHEDULED_TASK_EXECUTION_SYSTEM_PROMPT.to_string(),
}
}
pub(crate) struct AgentExecutionService {
show_tool_results: bool,
}
pub(crate) struct FinalizeAgentResultRequest<'a> {
pub(crate) channel_name: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) user_message: &'a ChatMessage,
pub(crate) result: AgentProcessResult,
pub(crate) metadata: &'a HashMap<String, String>,
pub(crate) suppress_live_tool_calls: bool,
pub(crate) execution_kind: &'a str,
pub(crate) original_topic_id: Option<String>,
}
pub(crate) struct FinalizedAgentResult {
pub(crate) outbound_messages: Vec<OutboundMessage>,
pub(crate) should_schedule_compaction: bool,
}
pub(crate) struct MessageExecutionRequest<'a> {
pub(crate) session: Arc<Mutex<Session>>,
pub(crate) channel_name: &'a str,
pub(crate) sender_id: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) content: &'a str,
pub(crate) media: Vec<MediaItem>,
pub(crate) live_emitter: Option<Arc<dyn EmittedMessageHandler>>,
/// 消息接收时捕获的 topic_id全程显式传递避免从共享状态重复读取竞态
pub(crate) topic_id: Option<String>,
/// 端到端追踪 ID从 InboundMessage 透传,贯穿 agent → tool → outbound
pub(crate) trace_id: &'a str,
}
pub(crate) struct ScheduledExecutionRequest<'a> {
pub(crate) session: Arc<Mutex<Session>>,
pub(crate) channel_name: &'a str,
pub(crate) chat_id: &'a str,
pub(crate) notification_chat_id: Option<&'a str>,
pub(crate) prompt: &'a str,
pub(crate) sender_id: &'a str,
pub(crate) provider_config: LLMProviderConfig,
pub(crate) system_prompt: Option<&'a str>,
pub(crate) metadata: &'a HashMap<String, String>,
pub(crate) fresh_session: bool,
/// 端到端追踪 ID由 ScheduledAgentTaskService 生成)
pub(crate) trace_id: String,
}
impl AgentExecutionService {
pub(crate) fn new(show_tool_results: bool) -> Self {
Self { show_tool_results }
}
pub(crate) fn finalize_result(
&self,
session: &mut Session,
request: FinalizeAgentResultRequest<'_>,
) -> Result<FinalizedAgentResult, AgentError> {
// 判断是否是最新的用户回合
// 直接比较 current_topic(chat_id) 与 original_topic_id
// 这比"检查内存历史最新消息"更可靠,且天然处理"切走又切回"的 case
let is_current_turn = match request.original_topic_id.as_deref() {
Some(orig_tid) => session.current_topic(request.chat_id).as_deref() == Some(orig_tid),
None => true, // 无 topic 时总是视为当前回合
};
if !is_current_turn {
let (latest_user_id, latest_user_preview, compression_in_flight, history_len) = session
.stale_result_diagnostics(
request
.original_topic_id
.as_deref()
.unwrap_or(request.chat_id),
);
tracing::info!(
channel = %request.channel_name,
chat_id = %request.chat_id,
user_message_id = %request.user_message.id,
latest_user_id,
latest_user_preview,
compression_in_flight,
history_len,
execution_kind = %request.execution_kind,
original_topic_id = ?request.original_topic_id,
"User switched topic during agent execution - saving result to original topic"
);
}
// 确定保存消息的话题 ID
// 始终使用执行开始时捕获的 original_topic_id避免从共享状态重复读取竞态
let target_topic_id = request.original_topic_id.as_deref();
// 如果 AgentLoop 内部已触发 LLM 压缩DB 已被 CompactionSink 更新
// (原消息标记 is_compacted=1 + 压缩摘要已插入)。
// 此时 emitted_messages 早已通过 handler 持久化,且作为"最新5个 unit"
// 包含在压缩输出中。直接 append 到内存历史会产生重复,因此从 DB 重新加载。
if request.result.compaction_performed && is_current_turn {
let reload_topic = target_topic_id.unwrap_or(request.chat_id);
if let Err(err) = session.reload_topic_history(request.chat_id, reload_topic) {
tracing::error!(
error = %err,
chat_id = %request.chat_id,
topic_id = %reload_topic,
"Failed to reload topic history after in-loop compaction"
);
}
tracing::info!(
chat_id = %request.chat_id,
topic_id = %reload_topic,
"In-loop compaction was performed; reloaded topic history from DB"
);
} else if let Some(topic_id) = target_topic_id {
if is_current_turn {
// 话题未切换current_topic == original_topic_id安全更新内存历史
if let Err(err) = session
.append_persisted_messages(topic_id, request.result.emitted_messages.clone())
{
tracing::error!(
error = %err,
topic_id = %topic_id,
"Failed to append messages to session history"
);
}
} else {
// 话题已切换,只写 DB 不更新内存(避免污染新话题的历史)
if let Err(err) = session.append_messages_to_topic(
request.chat_id,
topic_id,
&request.result.emitted_messages,
) {
tracing::error!(
error = %err,
topic_id = %topic_id,
"Failed to append messages to topic"
);
}
}
} else if is_current_turn {
// 没有话题直接更新内存历史append_persisted_messages 会处理持久化)
// 无 topic 场景用 chat_id 作为 topic_histories 的回退 key
if let Err(err) = session
.append_persisted_messages(request.chat_id, request.result.emitted_messages.clone())
{
tracing::error!(
error = %err,
chat_id = %request.chat_id,
"Failed to append messages to session history"
);
}
}
// 只有当是最新回合时才发送 outbound 消息给用户
// 如果用户已经切换到其他话题,只保存结果,不发送消息(避免打扰)
let outbound_messages = if is_current_turn {
request
.result
.emitted_messages
.iter()
.filter(|message| {
// 当存在 live_emitter 时,所有消息已在 loop 中实时广播,不需要 post-loop 发送
!request.suppress_live_tool_calls
&& should_display_message_to_user(self.show_tool_results, message)
})
.flat_map(|message| {
OutboundMessage::from_chat_message(
request.channel_name,
request.chat_id,
None, // session_id
None,
request.metadata,
message,
)
})
.collect()
} else {
Vec::new()
};
// 只有当是最新回合且未在 loop 内触发过任何压缩(工程化或 LLM
// 才触发兜底历史压缩。in-loop 已做工程化压缩时跳过——因为兜底基于
// 未压缩历史的 estimate_tokens 判断会不准确,可能冗余触发 LLM 压缩,
// 违背"in-loop 已判断工程化压缩足够则不 LLM 压缩"的意图。
let should_schedule_compaction = is_current_turn
&& !request.result.compaction_performed
&& !request.result.engineering_compaction_applied;
Ok(FinalizedAgentResult {
outbound_messages,
should_schedule_compaction,
})
}
pub(crate) async fn prepare_and_execute_message(
&self,
request: MessageExecutionRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
// 获取该 topic 的串行锁(通过短暂获取 session 锁)
// 同一 topic 的消息处理必须串行执行,防止并发 loop 操作同一历史的不同快照
// 不同 topic 之间互不阻塞,支持多话题并发执行
let (serial_lock, store, lock_key) = {
let mut session_guard = request.session.lock().await;
let lock_key = request
.topic_id
.as_deref()
.unwrap_or(request.chat_id)
.to_string();
session_guard.ensure_sub_done_channel(&lock_key);
(
session_guard.topic_serial_lock(&lock_key),
session_guard.session_store(),
lock_key,
)
};
// 等待该 topic 的前一条消息处理完成(含压缩)
// await 串行锁时不持有 session 锁,其他 topic 的消息可以正常处理
// 使用 lock_owned 获取 OwnedMutexGuard存入 guard_slot 供 wait_coordinator 释放/重获取
// 注意lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用
let serial_guard = serial_lock.clone().lock_owned().await;
// guard_slotwait_coordinator 通过此 slot 释放/重获取 serial_lock。
// 正常执行时 guard 留在 slot 中锁持有wait 工具调用时 take guard 释放锁,
// select! 等待结束后重获取锁并回填新 guard。
// guard_slot 作为 Arc 共享于执行路径与 coordinator二者全部 drop 时 guard 才释放锁。
let guard_slot = Arc::new(Mutex::new(Some(serial_guard)));
let (history, agent, user_message, user_message_count, original_topic_id) = {
let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?;
// 优先使用消息接收时捕获的 topic_id消除 #1 与 #2 之间的竞态
let original_topic_id = match &request.topic_id {
Some(tid) => Some(tid.clone()),
None => session_guard
.current_topic(request.chat_id)
.map(|s| s.to_string()),
};
session_guard.ensure_chat_loaded(request.chat_id, original_topic_id.as_deref())?;
session_guard.ensure_agent_prompt_before_user_message(request.chat_id)?;
let media_refs: Vec<String> = request
.media
.iter()
.map(|media| media.path.clone())
.collect();
#[cfg(debug_assertions)]
if !media_refs.is_empty() {
tracing::debug!(media_count = %request.media.len(), media_refs = ?media_refs, "Adding user message with media");
}
let enriched_content =
enrich_user_content_with_media_refs(request.content, &media_refs)?;
// 先计算 user_message_count在添加新消息之前
// 无 topic 时用 chat_id 作为 topic_histories 的回退 key
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
let history_before = session_guard.get_or_create_history(history_key).clone();
let user_message_count = history_before.iter().filter(|m| m.role == "user").count();
let user_message = session_guard.create_user_message(&enriched_content, media_refs);
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
user_message.clone(),
)?;
// 再获取包含新消息的完整历史记录
let history = session_guard.get_or_create_history(history_key).clone();
session_guard.record_skill_offer(request.chat_id)?;
// 创建 wait 协调器(封装释放/重获取 serial_lock + select! 等待逻辑)。
// 仅主 agent 注入coordinator 通过 guard_slot 释放/重获取 serial_lock
// 使 wait_for_subagents 工具能在等待期间让 process_one 注入用户消息。
let wait_coordinator: Option<Arc<dyn WaitCoordinator>> = {
let coordinator = SessionWaitCoordinator::new(
request.session.clone(),
guard_slot.clone(),
serial_lock.clone(),
store.clone(),
lock_key.clone(),
);
Some(Arc::new(coordinator))
};
let mut agent = session_guard.create_agent(
request.chat_id,
Some(request.sender_id),
Some(&user_message.id),
original_topic_id.as_deref(),
request.trace_id,
wait_coordinator,
)?;
if let Some(handler) = request.live_emitter.clone() {
agent = agent.with_emitted_message_handler(handler);
}
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
)
};
// 构建系统提示词上下文
let system_prompt_context = SystemPromptContext {
session_id: Some(persistent_session_id(request.channel_name, request.chat_id)),
chat_id: request.chat_id.to_string(),
user_message_count,
};
// 构建 CompactionSink在 AgentLoop 内部触发 LLM 压缩时把结果写回 DB。
// topic_id 退化为 chat_id与 history_key 一致)。
let compaction_topic_id = original_topic_id
.clone()
.unwrap_or_else(|| request.chat_id.to_string());
let compaction_sink = CompactionSinkImpl::new(
request.session.clone(),
request.chat_id.to_string(),
compaction_topic_id,
);
let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
.await?;
let mut metadata = HashMap::new();
// 把用户消息的 UUID 回传给前端,前端用此更新本地消息 ID使 todo 点击跳转能匹配
metadata.insert("user_message_id".to_string(), user_message.id.clone());
self.finalize_result_and_schedule_compaction(
request.session.clone(),
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: &metadata,
suppress_live_tool_calls: request.live_emitter.is_some(),
execution_kind: "message",
original_topic_id,
},
)
.await
}
pub(crate) async fn prepare_and_execute_scheduled_task(
&self,
request: ScheduledExecutionRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
// 获取该 topic 的串行锁(与普通消息路径共享,保证串行执行)
// 定时任务由调度器触发,无用户消息竞态;在锁前一次性捕获 topic_id
// 锁后复用同一值作为 original_topic_id保证锁键与写入目标一致。
let (serial_lock, session_store, lock_key, lock_time_topic_id) = {
let mut session_guard = request.session.lock().await;
let tid = session_guard
.current_topic(request.chat_id)
.map(|s| s.to_string());
let lock_key = tid.as_deref().unwrap_or(request.chat_id).to_string();
session_guard.ensure_sub_done_channel(&lock_key);
(
session_guard.topic_serial_lock(&lock_key),
session_guard.session_store(),
lock_key,
tid,
)
};
// 等待该 topic 的前一条消息处理完成(含压缩)
// 使用 lock_owned 获取 OwnedMutexGuard存入 guard_slot 供 wait_coordinator 释放/重获取
// 注意lock_owned 消费 Arc<Self>,需 clone 保留 serial_lock 供 coordinator 使用
let serial_guard = serial_lock.clone().lock_owned().await;
let guard_slot = Arc::new(Mutex::new(Some(serial_guard)));
let (
history,
mut agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
) = {
let mut session_guard = request.session.lock().await;
session_guard.ensure_persistent_session(request.chat_id)?;
// 复用锁前捕获的 topic_id保证锁键与写入目标一致
let original_topic_id = lock_time_topic_id.clone();
// 如果 fresh_session 为 true清理历史内存 + 数据库)
if request.fresh_session {
session_guard.clear_chat_history(request.chat_id, original_topic_id.as_deref())?;
tracing::info!(
chat_id = %request.chat_id,
"Fresh session enabled, history cleared"
);
}
session_guard.ensure_chat_loaded(request.chat_id, original_topic_id.as_deref())?;
session_guard.ensure_agent_prompt_before_user_message(request.chat_id)?;
let scheduled_system_prompt =
compose_scheduled_task_system_prompt(request.system_prompt);
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
ChatMessage::system_with_context(
&scheduled_system_prompt,
Some(SYSTEM_CONTEXT_SCHEDULED_PROMPT.to_string()),
),
)?;
// 先计算 user_message_count在添加新消息之前
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
let history_before = session_guard.get_or_create_history(history_key).clone();
let user_message_count = history_before.iter().filter(|m| m.role == "user").count();
let user_message = session_guard.create_user_message(request.prompt, Vec::new());
session_guard.append_persisted_message(
request.chat_id,
original_topic_id.as_deref(),
user_message.clone(),
)?;
// 再获取包含新消息的完整历史记录
let history = session_guard.get_or_create_history(history_key).clone();
session_guard.record_skill_offer(request.chat_id)?;
// 创建 wait 协调器(与普通消息路径一致,支持定时任务中 spawn 异步子代理)
let wait_coordinator: Option<Arc<dyn WaitCoordinator>> = {
let coordinator = SessionWaitCoordinator::new(
request.session.clone(),
guard_slot.clone(),
serial_lock.clone(),
session_store.clone(),
lock_key.clone(),
);
Some(Arc::new(coordinator))
};
let agent = session_guard.create_agent_with_provider_config(
request.chat_id,
request.notification_chat_id, // 传入真实 chat_id
Some(request.sender_id),
Some(&user_message.id),
request.provider_config.clone(),
original_topic_id.as_deref(),
&request.trace_id,
wait_coordinator,
)?;
// 获取 store 和 session_id用于构造消息持久化 handler
let store = session_guard.store();
let session_id =
crate::storage::persistent_session_id(request.channel_name, request.chat_id);
(
history,
agent,
user_message,
user_message_count,
original_topic_id,
store,
session_id,
)
};
// 定时任务没有 live_emitter需要 PersistingEmittedMessageHandler 来持久化消息
{
let persisting_handler = PersistingEmittedMessageHandler::new(
NoOpEmittedMessageHandler,
store as Arc<dyn ConversationRepository>,
&session_id,
None,
);
agent = agent.with_emitted_message_handler(Arc::new(persisting_handler));
}
// 构建系统提示词上下文
let system_prompt_context = SystemPromptContext {
session_id: Some(persistent_session_id(request.channel_name, request.chat_id)),
chat_id: request.chat_id.to_string(),
user_message_count,
};
// 构建 CompactionSink在 AgentLoop 内部触发 LLM 压缩时把结果写回 DB。
let compaction_topic_id = original_topic_id
.clone()
.unwrap_or_else(|| request.chat_id.to_string());
let compaction_sink = CompactionSinkImpl::new(
request.session.clone(),
request.chat_id.to_string(),
compaction_topic_id,
);
let result = agent
.process(history, Some(&system_prompt_context), Some(&compaction_sink))
.await?;
let outbound_messages = self
.finalize_result_and_schedule_compaction(
request.session.clone(),
FinalizeAgentResultRequest {
channel_name: request.channel_name,
chat_id: request.chat_id,
user_message: &user_message,
result,
metadata: request.metadata,
suppress_live_tool_calls: false,
execution_kind: "scheduled_task",
original_topic_id: original_topic_id.clone(),
},
)
.await?;
// 清理内存历史,释放内存(数据库历史保留)
{
let mut session_guard = request.session.lock().await;
let history_key = original_topic_id.as_deref().unwrap_or(request.chat_id);
session_guard.remove_history(history_key);
tracing::info!(
chat_id = %request.chat_id,
"Scheduled task completed, memory history released"
);
}
Ok(outbound_messages)
}
pub(crate) async fn finalize_result_and_schedule_compaction(
&self,
session: Arc<Mutex<Session>>,
request: FinalizeAgentResultRequest<'_>,
) -> Result<Vec<OutboundMessage>, AgentError> {
let channel_name = request.channel_name.to_string();
let chat_id = request.chat_id.to_string();
let execution_kind = request.execution_kind.to_string();
let topic_id = request.original_topic_id.clone();
let finalized_result = {
let mut session_guard = session.lock().await;
self.finalize_result(&mut session_guard, request)?
};
if finalized_result.should_schedule_compaction {
let compaction_topic_id = topic_id.unwrap_or_else(|| chat_id.clone());
if let Err(error) = schedule_background_history_compaction(
session.clone(),
chat_id.clone(),
compaction_topic_id,
)
.await
{
tracing::warn!(
channel = %channel_name,
chat_id = %chat_id,
execution_kind = %execution_kind,
error = %error,
"Failed to schedule background history compaction"
);
}
}
Ok(finalized_result.outbound_messages)
}
}
pub(crate) fn should_display_message_to_user(
show_tool_results: bool,
message: &ChatMessage,
) -> bool {
if message.role != "tool" {
return true;
}
show_tool_results
|| matches!(
message
.tool_state
.as_ref()
.unwrap_or(&ToolMessageState::Completed),
ToolMessageState::PendingUserAction
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::ChatMessage;
#[test]
fn test_compose_scheduled_task_system_prompt_appends_task_specific_prompt() {
let prompt = compose_scheduled_task_system_prompt(Some(" 只汇报异常 "));
assert!(prompt.contains("当前输入来自一次已经触发的定时任务执行"));
assert!(prompt.contains("任务专属要求:只汇报异常"));
}
#[test]
fn test_compose_scheduled_task_system_prompt_ignores_blank_override() {
let prompt = compose_scheduled_task_system_prompt(Some(" "));
assert!(prompt.contains("当前输入来自一次已经触发的定时任务执行"));
assert!(!prompt.contains("任务专属要求"));
}
#[test]
fn test_should_display_message_to_user_keeps_pending_tool_action_visible() {
let message = ChatMessage::tool_with_state(
"call-1",
"approval",
"需要用户确认",
ToolMessageState::PendingUserAction,
);
assert!(should_display_message_to_user(false, &message));
}
#[test]
fn test_should_display_message_to_user_hides_completed_tool_when_disabled() {
let message = ChatMessage::tool("call-1", "calculator", "2");
assert!(!should_display_message_to_user(false, &message));
assert!(should_display_message_to_user(true, &message));
}
/// 对抗性测试:同一 topic 的串行锁被持有时,第二次获取应阻塞
#[tokio::test]
async fn test_topic_serial_lock_blocks_concurrent_access() {
let lock = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let _guard1 = lock.lock().await;
// 第二次获取应阻塞1ms 超时验证
let result = tokio::time::timeout(std::time::Duration::from_millis(1), lock.lock()).await;
assert!(result.is_err(), "第二次获取同一锁应阻塞");
}
/// 对抗性测试:不同 topic 的串行锁互不影响,可同时获取
#[tokio::test]
async fn test_different_topic_locks_independent() {
let lock_a = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let lock_b = std::sync::Arc::new(tokio::sync::Mutex::new(()));
let _guard_a = lock_a.lock().await;
// 不同锁应立即可获取
let result =
tokio::time::timeout(std::time::Duration::from_millis(100), lock_b.lock()).await;
assert!(result.is_ok(), "不同 topic 的锁应互不影响");
}
/// 对抗性测试错误返回路径锁被正确释放RAII 保证)
#[tokio::test]
async fn test_serial_lock_released_on_error() {
let lock = std::sync::Arc::new(tokio::sync::Mutex::new(()));
// 模拟 prepare_and_execute_message 的错误路径:
// 获取锁 → 返回错误 → 锁应通过 RAII 释放
{
let _serial_guard = lock.lock().await;
// 模拟错误返回(`?` 或 `Err` 分支)
let _result: Result<(), AgentError> = Err(AgentError::Other("simulated".to_string()));
// _serial_guard 在此块结束时 Drop释放锁
}
// 锁应已释放,可再次获取
let result = tokio::time::timeout(std::time::Duration::from_millis(100), lock.lock()).await;
assert!(result.is_ok(), "错误返回后锁应已释放");
}
}