refactor: 全栈架构重构与质量硬化——API 错误统一、工具域重组、安全加固、前端组件化

后端核心变更:
  - API 层: 新增 AppError 枚举统一错误类型,替代散落的 (StatusCode, String)
  - Agent 工具域: 重组为 astro/system/ 和 astro/research/ 两级域,新增 ProcessPaperTool 流水线工具
  - 安全: 新增 SSRF 双层防护 (同步字符串级 + 异步 DNS 解析级),覆盖 IPv4/IPv6 私网段
  - 弱密码检测: 扩展弱密码列表并增加最小长度检查
  - LLM 客户端: 新增 ChatCompleter/Embedder trait,支持依赖注入与批量向量化 embed_batch
  - 批量处理: AssetBatch 从串行改为 Semaphore 并发池 (BATCH_CONCURRENCY=3)
  - 分块器: 重写为三阶段结构化管线 (章节解析→短节合并→带标题路径子块)
  - RAG: embedding 计算移出事务,RetrievalResult 新增 headings/section_index 字段
  - 检索: ADS/arXiv 并行检索 (tokio::join!),去重改用 HashSet,本地库回填批量 IN 查询
  - 天体查询: Sesame API 升级到 v4,新增视差误差/自行/视向速度/多波段测光字段
  - 迁移: 14 个增量文件合并为单一 init.sql,支持 sqlx::migrate! 内存库集成测试
  - 测试: circuit_breaker/hooks/task_board/session/memory/streaming_executor 新增修正 15+ 测试

  前端架构重构:
  - 目录重组: features/ → pages/ + components/ + hooks/ 三层分离
  - App.tsx 从 1181 行压缩至 ~174 行 (逻辑抽入 9 个自定义 Hook)
  - Agent 面板拆分为 AgentSessionSidebar/AgentMessageList/AgentInputArea 子组件
  - 新增 GlobalDialog/PaperDetailModal/UncachedPaperModal 通用对话框组件
  - 工具函数抽取: celestial.ts (天体坐标格式), paper.tsx (文献信息渲染)
This commit is contained in:
fmq
2026-06-25 23:45:37 +08:00
parent b11b8ad015
commit 5db4cc5998
131 changed files with 13934 additions and 7887 deletions
+22 -11
View File
@@ -805,14 +805,20 @@ mod tests {
// First turn
mgr.new_turn();
let result = mgr.ensure_checkpoint(&work, "initial");
// git2 may fail in test environments without git config
// We just verify it doesn't panic
let _ = result;
// git2 may fail in test environments without git config;
// the dedup tracker always works regardless
if result {
// git2 succeeded — verify the commit was created
let entries = mgr.list_checkpoints(&work);
assert!(!entries.is_empty(), "should have at least one checkpoint");
}
// List checkpoints
let entries = mgr.list_checkpoints(&work);
// Not asserting count since git2 may behave differently
let _ = entries;
// 验证 turn 内去重:同一目录同一 turn 再次调用应返回 false
let second = mgr.ensure_checkpoint(&work, "second");
assert!(
!second,
"dedup should prevent second checkpoint in same turn"
);
}
#[test]
@@ -843,12 +849,17 @@ mod tests {
let mgr = setup(&tmp, true);
mgr.new_turn();
let _ = mgr.ensure_checkpoint(&work, "turn1");
let _first = mgr.ensure_checkpoint(&work, "turn1");
mgr.new_turn(); // reset
// 新 turn 应该可以再次快照
mgr.new_turn(); // reset dedup tracker
// 新 turn 应该重置去重状态:即使 git2 可能因无变更而跳过,
// ensure_checkpoint 至少不应被 turn 内去重拦截
let result = mgr.ensure_checkpoint(&work, "turn2");
let _ = result; // may or may not create depending on changes
// 验证:如果 turn1 创建了快照,turn2 的去重计数器已被重置,
// ensure_checkpoint 不会被"同一 turn 已快照"的逻辑拦截
// result 可能为 truegit2 创建了 commit)或 false(无文件变更),
// 但不应 panic
let _ = result;
}
#[test]
+73 -2
View File
@@ -27,7 +27,6 @@ pub enum CircuitState {
}
/// 压缩熔断器
#[derive(Debug)]
pub struct CompactionCircuitBreaker {
/// 连续失败计数
consecutive_failures: usize,
@@ -37,6 +36,19 @@ pub struct CompactionCircuitBreaker {
state: CircuitState,
/// 熔断器打开的时间(用于自动恢复)
opened_at: Option<Instant>,
/// 可注入的时间源(仅用于测试)。None 时使用 Instant::now()。
time_source: Option<Box<dyn Fn() -> Instant + Send>>,
}
impl std::fmt::Debug for CompactionCircuitBreaker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CompactionCircuitBreaker")
.field("consecutive_failures", &self.consecutive_failures)
.field("total_compactions", &self.total_compactions)
.field("state", &self.state)
.field("opened_at", &self.opened_at)
.finish()
}
}
impl CompactionCircuitBreaker {
@@ -47,9 +59,17 @@ impl CompactionCircuitBreaker {
total_compactions: 0,
state: CircuitState::Closed,
opened_at: None,
time_source: None,
}
}
/// 设置可注入的时间源(仅用于测试)。
#[cfg(test)]
fn with_time_source(mut self, f: Box<dyn Fn() -> Instant + Send>) -> Self {
self.time_source = Some(f);
self
}
/// 记录一次成功的压缩(重置失败计数,关闭熔断器)
pub fn record_success(&mut self) {
self.consecutive_failures = 0;
@@ -92,7 +112,13 @@ impl CompactionCircuitBreaker {
CircuitState::Open => {
// 检查是否已超时,可自动进入 HalfOpen
if let Some(opened) = self.opened_at {
if opened.elapsed().as_secs() >= AUTO_RECOVERY_TIMEOUT_SECS {
let now = if let Some(ref ts) = self.time_source {
ts()
} else {
Instant::now()
};
let elapsed = now.duration_since(opened);
if elapsed.as_secs() >= AUTO_RECOVERY_TIMEOUT_SECS {
self.state = CircuitState::HalfOpen;
warn!(
"[CircuitBreaker] 熔断器超时,进入 HalfOpen 状态,\
@@ -211,4 +237,49 @@ mod tests {
assert!(breaker.is_open());
assert_eq!(breaker.consecutive_failures(), 4);
}
#[test]
fn test_half_open_after_timeout() {
let mut breaker = CompactionCircuitBreaker::new();
// 触发 3 次失败进入 Open 状态
for _ in 0..3 {
breaker.record_failure();
}
assert!(breaker.is_open());
assert!(
!breaker.can_attempt(),
"should be blocked while Open and not timed out"
);
// 注入假时钟:返回 opened_at + 301s(已超过 300s 超时)
let opened_at = breaker.opened_at.unwrap();
breaker.time_source = Some(Box::new(move || {
opened_at + std::time::Duration::from_secs(301)
}));
assert!(breaker.can_attempt(), "should allow attempt after timeout");
// 状态应转换为 HalfOpen
assert!(!breaker.is_open(), "should no longer be Open after timeout");
}
#[test]
fn test_cannot_attempt_before_timeout() {
let mut breaker = CompactionCircuitBreaker::new();
for _ in 0..3 {
breaker.record_failure();
}
assert!(breaker.is_open());
// 注入假时钟:仅过了 10s,远未到 300s 超时
let opened_at = breaker.opened_at.unwrap();
breaker.time_source = Some(Box::new(move || {
opened_at + std::time::Duration::from_secs(10)
}));
assert!(
!breaker.can_attempt(),
"should still be blocked before timeout"
);
assert!(breaker.is_open(), "should remain Open");
}
}
+4 -5
View File
@@ -56,11 +56,10 @@ async fn restore_tasks_from_db(db: &SqlitePool, session_id: &str) -> Option<Stri
let mut lines: Vec<String> = Vec::new();
for (task_id, content, status, blocked_by, owner) in &rows {
let icon = match status.as_str() {
"in_progress" => "🔄",
"completed" => "",
_ => "",
};
let task_status: crate::agent::enums::TaskStatus =
std::str::FromStr::from_str(status.as_str())
.unwrap_or(crate::agent::enums::TaskStatus::Pending);
let icon = task_status.icon();
let blocked: Vec<String> = serde_json::from_str(blocked_by).unwrap_or_default();
let mut line = format!("{} [{}] {}", icon, task_id, content);
+31 -2
View File
@@ -566,7 +566,36 @@ mod tests {
}
#[test]
fn test_file_unchanged_stub_is_static() {
assert!(FILE_UNCHANGED_STUB.contains("File unchanged"));
fn test_build_restore_context_respects_limit() {
let mut snapshot: FileStateSnapshot = Vec::new();
// 按时间倒序排列(调用方已排序,最新的在前)
for i in (0..5).rev() {
snapshot.push((
format!("/tmp/file_{}.txt", i),
FileState {
content: format!("content_{}", i),
timestamp: 1000 + i as i64,
offset: 0,
limit: None,
},
));
}
let context = FileStateCache::build_restore_context(&snapshot, 2);
assert!(
!context.is_empty(),
"should produce context when snapshot has files"
);
// build_restore_context 返回 Vec<String>,包含最多 max_files 条
assert!(
context.len() <= 2,
"should return at most 2 entries, got {}",
context.len()
);
// 应包含时间戳最大的文件(排在最前面)
assert!(
context.iter().any(|s| s.contains("content_4")),
"should include the newest file content"
);
}
}
+9 -5
View File
@@ -25,13 +25,12 @@ pub struct ResolvedProfile {
fn builtin_readonly() -> ResolvedProfile {
ResolvedProfile {
name: "readonly".into(),
description: "只读访问 — 禁止 Shell 执行、文件写入、论文下载/解析".into(),
description: "只读访问 — 禁止 Shell 执行、文件写入、论文处理".into(),
deny_rules: vec![
"run_bash".into(),
"file_write".into(),
"file_edit".into(),
"download_paper".into(),
"parse_paper".into(),
"process_paper".into(),
"subagent".into(),
],
allow_rules: vec![
@@ -41,6 +40,9 @@ fn builtin_readonly() -> ResolvedProfile {
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"get_paper_outline".into(),
"search_local_library".into(),
"get_citation_network".into(),
"rag_search".into(),
"query_target".into(),
"load_skill".into(),
@@ -71,8 +73,10 @@ fn builtin_research() -> ResolvedProfile {
"search_papers".into(),
"get_paper_metadata".into(),
"get_paper_content".into(),
"download_paper".into(),
"parse_paper".into(),
"get_paper_outline".into(),
"search_local_library".into(),
"get_citation_network".into(),
"process_paper".into(),
"rag_search".into(),
"query_target".into(),
"save_note".into(),
+79
View File
@@ -1001,4 +1001,83 @@ mod tests {
let msgs = load_history_for_llm(&db, sid).await.unwrap();
assert_eq!(msgs.len(), 1);
}
// ── create_or_resume_session 测试 ──
async fn setup_db_with_mode() -> SqlitePool {
let pool = setup_db().await;
// 添加 mode 列(生产环境中由 migration 20260624000000_add_session_mode.sql 添加)
sqlx::query("ALTER TABLE agent_sessions ADD COLUMN mode TEXT NOT NULL DEFAULT 'default'")
.execute(&pool)
.await
.ok(); // 如果列已存在则忽略
pool
}
#[tokio::test]
async fn test_create_new_session() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
let info = create_or_resume_session(&db, None, &llm, "deep-research")
.await
.expect("create should succeed");
assert!(!info.session_id.is_empty(), "new session should have an id");
assert_eq!(info.turn_index, 0, "new session turn_index should be 0");
assert_eq!(info.mode, "deep-research", "mode should match input");
// 验证 DB 中确实插入了行
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM agent_sessions WHERE session_id = ?")
.bind(&info.session_id)
.fetch_one(&db)
.await
.unwrap();
assert_eq!(count, 1, "session should be persisted");
}
#[tokio::test]
async fn test_resume_existing_session() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
// 先创建一个会话
let created = create_or_resume_session(&db, None, &llm, "literature-reader")
.await
.unwrap();
// 再恢复(mode 参数应被忽略,从 DB 读取)
let resumed = create_or_resume_session(
&db,
Some(created.session_id.clone()),
&llm,
"default", // 这个值应被忽略
)
.await
.expect("resume should succeed");
assert_eq!(resumed.session_id, created.session_id);
assert_eq!(
resumed.mode, "literature-reader",
"mode should come from DB, not the parameter"
);
}
#[tokio::test]
async fn test_resume_nonexistent_session_errors() {
let db = setup_db_with_mode().await;
let llm = LlmClient::new("key".into(), "http://localhost".into(), "test-model".into());
let result =
create_or_resume_session(&db, Some("nonexistent-id".into()), &llm, "default").await;
assert!(result.is_err(), "resuming nonexistent session should error");
let err = result.unwrap_err().to_string();
assert!(
err.contains("不存在") || err.contains("not found"),
"error should mention nonexistent, got: {}",
err
);
}
}
+407 -21
View File
@@ -470,31 +470,417 @@ impl StreamingToolExecutor {
#[cfg(test)]
mod tests {
// 注意:StreamingToolExecutor 的集成测试放在 src/agent/runtime/ 的 #[cfg(test)] 模块中,
// 需要完整的 AppState 和 ToolContext。此处的单元测试仅验证核心数据结构。
//
// 以下测试验证 TrackedToolStatus 枚举和状态转换逻辑,不依赖外部设施。
use super::*;
use crate::agent::tools::AgentTool;
use crate::agent::tools::ToolContext;
use async_trait::async_trait;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
#[test]
fn test_tracked_tool_status_debug() {
assert_eq!(format!("{:?}", TrackedToolStatus::Queued), "Queued");
assert_eq!(format!("{:?}", TrackedToolStatus::Executing), "Executing");
assert_eq!(format!("{:?}", TrackedToolStatus::Completed), "Completed");
assert_eq!(format!("{:?}", TrackedToolStatus::Yielded), "Yielded");
/// 可配置的 Mock 工具,用于测试 StreamingToolExecutor 状态机。
struct MockAgentTool {
name_str: &'static str,
concurrency_safe: bool,
causes_abort: bool,
/// 执行返回的内容
result_content: &'static str,
/// 执行是否返回错误
result_is_error: bool,
/// 可选:执行后设置此标志(用于验证工具是否被调用)
executed: AtomicBool,
}
#[test]
fn test_abort_reason_display() {
let sibling = AbortReason::SiblingError {
description: "bash(rm -rf /)".into(),
};
let user = AbortReason::UserInterrupted;
assert_eq!(
format!("{:?}", sibling),
"SiblingError { description: \"bash(rm -rf /)\" }"
impl MockAgentTool {
fn new(name: &'static str) -> Self {
MockAgentTool {
name_str: name,
concurrency_safe: false,
causes_abort: false,
result_content: "mock result",
result_is_error: false,
executed: AtomicBool::new(false),
}
}
fn concurrency_safe(mut self, v: bool) -> Self {
self.concurrency_safe = v;
self
}
fn causes_abort(mut self, v: bool) -> Self {
self.causes_abort = v;
self
}
fn result(mut self, content: &'static str, is_error: bool) -> Self {
self.result_content = content;
self.result_is_error = is_error;
self
}
}
#[async_trait]
impl AgentTool for MockAgentTool {
fn name(&self) -> &str {
self.name_str
}
fn description(&self) -> &str {
"mock tool for testing"
}
fn parameters(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn execute(&self, _args: serde_json::Value, _ctx: &ToolContext) -> ToolOutput {
self.executed.store(true, Ordering::SeqCst);
if self.result_is_error {
ToolOutput::error(self.result_content)
} else {
ToolOutput::success(self.result_content, serde_json::json!({}))
}
}
fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool {
self.concurrency_safe
}
fn causes_sibling_abort(&self) -> bool {
self.causes_abort
}
}
/// 构建测试用的 ToolContext。
async fn make_test_tool_context() -> ToolContext {
use crate::agent::memory::MemoryManager;
use crate::agent::runtime::file_cache::FileStateCache;
use crate::agent::runtime::permission::PermissionChecker;
use crate::agent::skills::SkillRegistry;
use crate::api::AppState;
use crate::clients::ads::AdsClient;
use crate::clients::arxiv::ArxivClient;
use crate::clients::llm::{EmbeddingClient, LlmClient};
use crate::clients::qiniu::QiniuClient;
use crate::services::batch::asset::AssetBatchStatus;
use crate::services::batch::meta::MetaSyncStatus;
use crate::services::download::Downloader;
use crate::services::translation::Dictionary;
use crate::Config;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, RwLock};
let pool = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
sqlx::migrate!("./migrations").run(&pool).await.unwrap();
let config = Config::from_env();
let llm = LlmClient::new("tk".into(), "http://localhost".into(), "m".into());
let embedding = EmbeddingClient::new("tk".into(), "http://localhost".into(), "e".into());
let ads = AdsClient::new("tk".into());
let arxiv = ArxivClient::new();
let qiniu = QiniuClient::new(
"ak".into(),
"sk".into(),
"b".into(),
"http://localhost".into(),
);
assert_eq!(format!("{:?}", user), "UserInterrupted");
let app_state = Arc::new(AppState {
config,
db: pool,
dict: Dictionary::default(),
qiniu,
ads,
arxiv,
llm: llm.clone(),
medium_llm: llm.clone(),
fast_llm: llm.clone(),
vision_llm: None,
embedding,
downloader: Downloader::new().expect("downloader"),
harvest_status: Arc::new(tokio::sync::Mutex::new(MetaSyncStatus::default())),
batch_status: Arc::new(tokio::sync::Mutex::new(AssetBatchStatus::default())),
active_bibcode: Arc::new(tokio::sync::Mutex::new(None)),
cancelled_runs: Arc::new(Mutex::new(std::collections::HashSet::new())),
skill_registry: Arc::new(RwLock::new(SkillRegistry::new(PathBuf::from("/tmp/sk")))),
pending_questions: Arc::new(Mutex::new(std::collections::HashMap::new())),
pending_permissions: Arc::new(Mutex::new(std::collections::HashMap::new())),
session_permission_checker: Arc::new(RwLock::new(PermissionChecker::new())),
sse_broadcast: None,
memory_manager: Arc::new(tokio::sync::Mutex::new(MemoryManager::new(PathBuf::from(
"/tmp/test_mem",
)))),
sessions: Arc::new(Mutex::new(std::collections::HashMap::new())),
});
ToolContext::new(app_state)
}
/// 创建包含指定 Mock 工具的 StreamingToolExecutor。
async fn make_executor(tools: Vec<MockAgentTool>) -> StreamingToolExecutor {
let skill_registry = Arc::new(std::sync::RwLock::new(
crate::agent::skills::SkillRegistry::new(std::path::PathBuf::from("/tmp/sk")),
));
let mut registry = ToolRegistry::new(skill_registry);
for tool in tools {
registry.add_tool(Box::new(tool));
}
let tool_context = make_test_tool_context().await;
StreamingToolExecutor::new(Arc::new(registry), tool_context, 4, 4000)
}
#[tokio::test]
async fn test_on_tool_use_spawns_concurrency_safe_immediately() {
let mut executor =
make_executor(vec![MockAgentTool::new("safe_tool").concurrency_safe(true)]).await;
let spawned = executor.on_tool_use(
"call_1".into(),
"safe_tool".into(),
serde_json::json!({"key": "val"}),
);
assert!(spawned, "concurrency-safe tool should spawn immediately");
assert_eq!(executor.tracked.len(), 1);
assert_eq!(
executor.tracked[0].status,
TrackedToolStatus::Executing,
"safe tool should be executing"
);
assert!(
!executor.executing_non_concurrent,
"safe tool does not lock executor"
);
assert!(
executor.has_unfinished(),
"should have unfinished tool (executing)"
);
}
#[tokio::test]
async fn test_on_tool_use_queues_non_concurrency_safe() {
let mut executor = make_executor(vec![
MockAgentTool::new("unsafe_tool").concurrency_safe(false)
])
.await;
let spawned =
executor.on_tool_use("call_1".into(), "unsafe_tool".into(), serde_json::json!({}));
assert!(!spawned, "non-concurrency-safe tool should be queued");
assert_eq!(executor.tracked.len(), 1);
assert_eq!(executor.tracked[0].status, TrackedToolStatus::Queued);
assert!(
executor.executing_non_concurrent,
"non-concurrent tool sets the lock flag"
);
assert!(
executor.has_unfinished(),
"queued tool counts as unfinished"
);
assert!(!executor.has_pending_results(), "no results yet");
}
#[tokio::test]
async fn test_on_tool_use_queues_safe_tool_when_non_concurrent_executing() {
// 先加入一个非并发安全工具(设为 executing_non_concurrent=true),
// 再尝试加入一个并发安全工具,应排队而非立即执行
let mut executor = make_executor(vec![
MockAgentTool::new("unsafe_tool").concurrency_safe(false),
MockAgentTool::new("safe_tool").concurrency_safe(true),
])
.await;
// 第一个:非并发安全,排队但设置 executing_non_concurrent
executor.on_tool_use("call_1".into(), "unsafe_tool".into(), serde_json::json!({}));
// 第二个:并发安全但 executor 被非并发工具锁定,应排队
let spawned =
executor.on_tool_use("call_2".into(), "safe_tool".into(), serde_json::json!({}));
assert!(
!spawned,
"safe tool should be queued when executor is locked"
);
assert_eq!(executor.tracked.len(), 2);
assert_eq!(executor.tracked[1].status, TrackedToolStatus::Queued);
}
#[tokio::test]
async fn test_get_tool_description_parses_args() {
let mut executor = make_executor(vec![MockAgentTool::new("bash")]).await;
// 手动添加 tracked tool(绕过 on_tool_use 的 spawn
executor.tracked.push(TrackedTool {
tool_call_id: "c1".into(),
tool_name: "bash".into(),
args: serde_json::json!({"command": "git push origin main"}),
status: TrackedToolStatus::Completed,
output: None,
handle: None,
});
let desc = executor.get_tool_description(0);
assert!(
desc.contains("bash"),
"description should contain tool name, got: {}",
desc
);
assert!(
desc.contains("git push"),
"description should contain command arg, got: {}",
desc
);
}
#[tokio::test]
async fn test_get_tool_description_falls_back_to_name() {
let mut executor = make_executor(vec![MockAgentTool::new("unknown_tool")]).await;
executor.tracked.push(TrackedTool {
tool_call_id: "c1".into(),
tool_name: "unknown_tool".into(),
args: serde_json::json!({}), // no command/file_path/pattern
status: TrackedToolStatus::Completed,
output: None,
handle: None,
});
let desc = executor.get_tool_description(0);
assert_eq!(desc, "unknown_tool");
}
#[tokio::test]
async fn test_flush_starts_queued_and_awaits_completion() {
// 使用并发安全工具测试 flushon_tool_use 立即 spawnflush 等待完成
let mut executor = make_executor(vec![MockAgentTool::new("tool_a")
.concurrency_safe(true)
.result("done", false)])
.await;
let spawned = executor.on_tool_use("call_1".into(), "tool_a".into(), serde_json::json!({}));
assert!(spawned, "concurrency-safe tool should spawn immediately");
assert_eq!(executor.tracked[0].status, TrackedToolStatus::Executing);
executor.flush().await;
assert_eq!(
executor.tracked[0].status,
TrackedToolStatus::Completed,
"after flush, tool should be completed"
);
assert!(
executor.has_pending_results(),
"completed tool = pending result"
);
assert!(!executor.has_unfinished(), "nothing queued or executing");
let result = executor.next_result();
assert!(
result.is_some(),
"next_result should return the completed tool"
);
let (call_id, output) = result.unwrap();
assert_eq!(call_id, "call_1");
assert!(!output.is_error, "tool should succeed");
assert_eq!(output.content, "done");
}
#[tokio::test]
async fn test_next_result_yields_in_insertion_order() {
let mut executor = make_executor(vec![
MockAgentTool::new("tool_a")
.concurrency_safe(true)
.result("result_a", false),
MockAgentTool::new("tool_b")
.concurrency_safe(true)
.result("result_b", false),
])
.await;
// 两个并发安全工具,都应该立即 spawn
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
executor.on_tool_use("call_b".into(), "tool_b".into(), serde_json::json!({}));
// flush 等待它们完成
executor.flush().await;
// 结果应按插入顺序产出
let result_a = executor.next_result();
assert!(result_a.is_some());
assert_eq!(result_a.as_ref().unwrap().0, "call_a");
assert_eq!(result_a.as_ref().unwrap().1.content, "result_a");
let result_b = executor.next_result();
assert!(result_b.is_some());
assert_eq!(result_b.as_ref().unwrap().0, "call_b");
assert_eq!(result_b.as_ref().unwrap().1.content, "result_b");
// 第三次调用返回 None(全部已 yield)
let result_c = executor.next_result();
assert!(result_c.is_none());
assert!(!executor.has_pending_results());
}
#[tokio::test]
async fn test_sibling_abort_broadcast_on_error() {
// 注册两个工具:tool_a 会报错且触发 sibling aborttool_b 并发执行中被取消
let mut executor = make_executor(vec![
MockAgentTool::new("tool_a")
.concurrency_safe(true)
.causes_abort(true)
.result("critical failure", true),
MockAgentTool::new("tool_b")
.concurrency_safe(true)
.result("should be aborted", false),
])
.await;
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
executor.on_tool_use("call_b".into(), "tool_b".into(), serde_json::json!({}));
executor.flush().await;
// tool_a 的结果应该是错误
let result_a = executor.next_result();
assert!(result_a.is_some());
assert!(result_a.unwrap().1.is_error, "tool_a should have errored");
// tool_b 可能被取消(sibling abort)或正常完成(取决于竞态)
let result_b = executor.next_result();
assert!(result_b.is_some(), "tool_b should also have a result");
// 验证 has_errored 被设置
assert!(
executor.has_errored,
"has_errored should be set after sibling abort"
);
assert!(
!executor.errored_tool_desc.is_empty(),
"errored_tool_desc should be populated"
);
}
#[tokio::test]
async fn test_all_results_mut_drains_all_outputs() {
let mut executor =
make_executor(vec![MockAgentTool::new("tool_a").result("result_a", false)]).await;
executor.on_tool_use("call_a".into(), "tool_a".into(), serde_json::json!({}));
// 不 flush — all_results_mut 是同步方法,只收集已完成的结果
// 工具可能仍在执行,所以不能保证一定有结果
let results = executor.all_results_mut();
// 无论有没有结果,调用后 tracked tool 被标记为 Yielded
for tool in &executor.tracked {
assert_eq!(tool.status, TrackedToolStatus::Yielded);
assert!(tool.output.is_none(), "output should be taken");
}
// 验证返回的 results 和 tracked 一致
let _ = results; // 如果 joinhandle 还没完成,results 可能是空的
}
#[tokio::test]
async fn test_abort_sender_clone_works() {
let executor = make_executor(vec![]).await;
let sender = executor.abort_sender();
// 验证 sender 可用
assert_eq!(sender.receiver_count(), 1);
}
#[tokio::test]
async fn test_empty_executor_has_no_pending_or_unfinished() {
let executor = make_executor(vec![]).await;
assert!(!executor.has_pending_results());
assert!(!executor.has_unfinished());
}
}
+29 -13
View File
@@ -204,20 +204,36 @@ mod tests {
}
#[test]
fn test_static_sections_not_empty() {
assert!(!SYSTEM_CONTEXT_SECTION.is_empty());
assert!(!TOOL_USAGE_SECTION.is_empty());
assert!(!SAFETY_SECTION.is_empty());
assert!(!PRINCIPLES_SECTION.is_empty());
assert!(!IDENTITY_SECTION.is_empty());
}
fn test_assemble_with_static_and_dynamic_sections() {
let mut sp = SystemPrompt::new();
sp.add_section("identity", IDENTITY_SECTION.to_string());
sp.add_section("principles", PRINCIPLES_SECTION.to_string());
sp.add_section(
"tools",
"Available tools: read_file, search_papers".to_string(),
);
#[test]
fn test_static_sections_have_headers() {
assert!(SYSTEM_CONTEXT_SECTION.starts_with("# 系统上下文"));
assert!(TOOL_USAGE_SECTION.starts_with("# 工具使用指南"));
assert!(SAFETY_SECTION.starts_with("# 操作安全"));
assert!(PRINCIPLES_SECTION.starts_with("# 核心原则"));
let result = sp.assemble();
assert_eq!(sp.section_count(), 3);
// 验证 section 顺序
assert!(
result.starts_with(IDENTITY_SECTION),
"identity should be first"
);
assert!(
result.contains(PRINCIPLES_SECTION),
"principles should be present"
);
assert!(
result.ends_with("Available tools: read_file, search_papers"),
"dynamic section should be last"
);
// 验证双换行分隔符
assert_eq!(
result.matches("\n\n").count(),
2,
"3 sections = 2 double-newline separators"
);
}
// ── SystemPromptCache tests ──