Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-ai-agent/tests/acp_agent_integration.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

423 lines
16 KiB
Rust

//! Integration tests for AcpAgentManager.
//!
//! **Status: TEMPORARILY IGNORED** — These tests use mock shell scripts that
//! produce line-delimited JSON on stdout. After the ACP SDK integration
//! (replacing raw JSON-over-stdio with `agent-client-protocol` JSON-RPC),
//! `AcpAgentManager::new()` now performs an SDK `initialize` handshake that
//! mock shell scripts cannot respond to.
//!
//! To re-enable these tests, the mock scripts need to be replaced with a
//! minimal JSON-RPC responder that handles `initialize`, `session/new`,
//! `session/prompt`, and `session/update` notifications.
//!
//! Tests are serialized via `SERIAL_LOCK` to avoid OS-level resource
//! contention from parallel subprocess spawning (pipes, I/O scheduling).
// Pre-existing: serial() MutexGuard held across await points is intentional —
// it serializes test execution. Useless .into() is a pre-existing nit.
#![allow(clippy::await_holding_lock, clippy::useless_conversion)]
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use nomifun_ai_agent::factory::acp_assembler::{WorkspaceInfo, assemble_acp_params};
use nomifun_ai_agent::manager::acp::AcpAgentManager;
use nomifun_ai_agent::registry::AgentRegistry;
use nomifun_ai_agent::{AgentInstance, AgentStreamEvent, IAgentTask};
use nomifun_common::ConversationStatus;
use nomifun_db::{SqliteAgentMetadataRepository, init_database_memory};
use tokio::sync::broadcast;
/// Timeout for receiving events from the relay.
const EVENT_TIMEOUT: Duration = Duration::from_secs(5);
/// Serialize integration tests to avoid OS-level resource contention
/// from parallel subprocess spawning (pipes, I/O scheduling).
static SERIAL_LOCK: Mutex<()> = Mutex::new(());
/// Acquire the serial lock (panics on poison).
fn serial() -> MutexGuard<'static, ()> {
SERIAL_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
/// Create an AcpAgentManager wrapping a mock shell script.
///
/// Returns the Arc-wrapped manager and a pre-subscribed event receiver
/// (subscribed BEFORE the relay starts, so no events are missed).
async fn make_mock_agent(script: &str, backend: &str) -> (Arc<AcpAgentManager>, broadcast::Receiver<AgentStreamEvent>) {
let temp_dir = std::env::temp_dir();
let script_path = temp_dir.join(format!(
"mock_acp_{}_{}.sh",
std::process::id(),
nomifun_common::now_ms()
));
std::fs::write(&script_path, format!("#!/bin/sh\n{script}")).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let config = nomifun_ai_agent::AcpBuildExtra {
desktop_gateway: false,
gateway_mcp_config: None,
open_mcp_config: None,
computer_mcp_config: None,
browser_mcp_config: None,
agent_id: None,
backend: Some(backend.to_owned()),
cli_path: Some(script_path.to_string_lossy().into_owned()),
agent_name: None,
custom_agent_id: None,
preset_context: None,
skills: vec![],
preset_assistant_id: None,
session_mode: None,
current_model_id: None,
cron_job_id: None,
team_mcp_stdio_config: None,
guide_mcp_config: None,
requirement_mcp_config: None,
knowledge_mcp_config: None,
mcp_server_ids: None,
session_mcp_servers: vec![],
user_id: None,
companion_id: None,
channel_platform: None,
knowledge_mounts: vec![],
knowledge_writeback: false,
knowledge_writeback_mode: None,
knowledge_writeback_eagerness: None,
};
let tmp_skills = tempfile::TempDir::new().unwrap();
let skill_paths = std::sync::Arc::new(nomifun_extension::resolve_skill_paths(
tmp_skills.path(),
tmp_skills.path(),
));
let skill_manager = nomifun_ai_agent::AcpSkillManager::new(skill_paths);
let db = init_database_memory().await.unwrap();
let repo = Arc::new(SqliteAgentMetadataRepository::new(db.pool().clone()));
let registry = AgentRegistry::new(repo);
registry.hydrate().await.unwrap();
let metadata = registry
.find_builtin_by_backend(backend)
.await
.expect("seeded backend row must exist");
let catalog_tx = registry.catalog_sender();
let params = Arc::new(
assemble_acp_params(
"test-conv-1".into(),
WorkspaceInfo {
path: "/tmp".into(),
is_custom: true,
},
metadata,
nomifun_common::CommandSpec {
command: script_path.into(),
args: vec![],
env: vec![],
cwd: None,
},
config,
Vec::new(),
None,
std::env::temp_dir(),
)
.await,
);
let (manager, _, _) = AcpAgentManager::build(params, skill_manager, &catalog_tx)
.await
.expect("Failed to spawn mock ACP agent");
let arc = Arc::new(manager);
// Subscribe to typed events BEFORE starting handler to capture all events
let rx = arc.subscribe();
arc.start_permission_handler();
(arc, rx)
}
/// Wait until a specific event type is received, returning all collected events.
async fn wait_for_event(
rx: &mut broadcast::Receiver<AgentStreamEvent>,
predicate: impl Fn(&AgentStreamEvent) -> bool,
) -> Vec<AgentStreamEvent> {
let mut events = Vec::new();
loop {
match tokio::time::timeout(EVENT_TIMEOUT, rx.recv()).await {
Ok(Ok(event)) => {
let matched = predicate(&event);
events.push(event);
if matched {
return events;
}
}
Ok(Err(broadcast::error::RecvError::Closed)) => {
panic!(
"Event channel closed before target event. Received: {:?}",
events.iter().map(event_type_name).collect::<Vec<_>>()
)
}
Ok(Err(broadcast::error::RecvError::Lagged(n))) => {
eprintln!("Warning: receiver lagged by {n} events");
continue;
}
Err(_) => panic!(
"Timed out waiting for target event. Received: {:?}",
events.iter().map(event_type_name).collect::<Vec<_>>()
),
}
}
}
/// Get a short name for the event type (for debug output).
fn event_type_name(event: &AgentStreamEvent) -> &'static str {
match event {
AgentStreamEvent::Start(_) => "Start",
AgentStreamEvent::Text(_) => "Text",
AgentStreamEvent::Tips(_) => "Tips",
AgentStreamEvent::ToolCall(_) => "ToolCall",
AgentStreamEvent::ToolGroup(_) => "ToolGroup",
AgentStreamEvent::AgentStatus(_) => "AgentStatus",
AgentStreamEvent::Thinking(_) => "Thinking",
AgentStreamEvent::Plan(_) => "Plan",
AgentStreamEvent::Permission(_) => "Permission",
AgentStreamEvent::AcpPermission(_) => "AcpPermission",
AgentStreamEvent::AcpToolCall(_) => "AcpToolCall",
AgentStreamEvent::AvailableCommands(_) => "AvailableCommands",
AgentStreamEvent::SkillSuggest(_) => "SkillSuggest",
AgentStreamEvent::CronTrigger(_) => "CronTrigger",
AgentStreamEvent::AcpModelInfo(_) => "AcpModelInfo",
AgentStreamEvent::AcpModeInfo(_) => "AcpModeInfo",
AgentStreamEvent::AcpConfigOption(_) => "AcpConfigOption",
AgentStreamEvent::AcpSessionInfo(_) => "AcpSessionInfo",
AgentStreamEvent::AcpContextUsage(_) => "AcpContextUsage",
AgentStreamEvent::AcpPromptHookWarning(_) => "AcpPromptHookWarning",
AgentStreamEvent::TurnCompleted(_) => "TurnCompleted",
AgentStreamEvent::Finish(_) => "Finish",
AgentStreamEvent::Error(_) => "Error",
AgentStreamEvent::System(_) => "System",
AgentStreamEvent::RequestTrace(_) => "RequestTrace",
AgentStreamEvent::SlashCommandsUpdated(_) => "SlashCommandsUpdated",
AgentStreamEvent::SessionAssigned(_) => "SessionAssigned",
}
}
#[test]
fn acp_build_extra_populates_skills_from_extra_json() {
let json = serde_json::json!({
"backend": "claude",
"skills": ["cron", "pdf"],
});
let extra: nomifun_ai_agent::AcpBuildExtra = serde_json::from_value(json).unwrap();
assert_eq!(extra.skills, vec!["cron".to_owned(), "pdf".to_owned()]);
}
// -- Tests --
// All tests below are #[ignore] because make_mock_agent() spawns shell scripts
// that cannot respond to the SDK's JSON-RPC `initialize` handshake.
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_type_is_acp() {
let _guard = serial();
let (agent, _rx) = make_mock_agent(r#"echo '{"type":"finish","data":{}}'"#, "claude").await;
assert_eq!(agent.agent_type(), nomifun_common::AgentType::Acp);
assert_eq!(agent.conversation_id(), "test-conv-1");
assert_eq!(agent.workspace(), "/tmp");
assert_eq!(agent.backend(), Some("claude"));
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_receives_stream_events() {
let _guard = serial();
let (_agent, mut rx) = make_mock_agent(
r#"echo '{"type":"start","data":{"session_id":"sess-1"}}' && echo '{"type":"text","data":{"content":"Hello"}}' && echo '{"type":"finish","data":{"session_id":"sess-1"}}'"#,
"claude",
)
.await;
// Wait for finish event, collecting all events along the way
let events = wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Finish(_))).await;
assert!(events.len() >= 2, "Expected at least 2 events, got {}", events.len());
let has_start = events.iter().any(|e| matches!(e, AgentStreamEvent::Start(_)));
let has_text = events.iter().any(|e| matches!(e, AgentStreamEvent::Text(_)));
assert!(has_start, "Should have received Start event");
assert!(has_text, "Should have received Text event");
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_session_id_captured_from_start() {
let _guard = serial();
let (agent, mut rx) = make_mock_agent(
r#"echo '{"type":"start","data":{"session_id":"sess-abc"}}' && sleep 1"#,
"claude",
)
.await;
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Start(_))).await;
let session_id = agent.session_id().await;
assert_eq!(session_id, Some("sess-abc".into()));
agent.kill(None).unwrap();
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_status_transitions() {
let _guard = serial();
let (agent, mut rx) = make_mock_agent(
r#"sleep 0.1 && echo '{"type":"start","data":{}}' && sleep 0.3 && echo '{"type":"finish","data":{}}'"#,
"claude",
)
.await;
// Initial status: None
assert_eq!(agent.status(), None);
// Wait for Start event
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Start(_))).await;
assert_eq!(agent.status(), Some(ConversationStatus::Running));
// Wait for Finish event
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Finish(_))).await;
assert_eq!(agent.status(), Some(ConversationStatus::Finished));
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_error_event_sets_finished() {
let _guard = serial();
let (agent, mut rx) = make_mock_agent(
r#"echo '{"type":"start","data":{}}' && sleep 0.1 && echo '{"type":"error","data":{"message":"timeout"}}'"#,
"claude",
)
.await;
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Error(_))).await;
assert_eq!(agent.status(), Some(ConversationStatus::Finished));
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_model_info_captured() {
let _guard = serial();
let (agent, mut rx) = make_mock_agent(
r#"echo '{"type":"acp_model_info","data":{"current_model_id":"claude-sonnet-4","current_model_label":"Claude Sonnet 4","available_models":[{"id":"claude-sonnet-4","label":"Claude Sonnet 4"},{"id":"claude-opus-4","label":"Claude Opus 4"}],"can_switch":true,"source":"models","source_detail":"acp-models"}}' && sleep 0.5"#,
"claude",
)
.await;
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::AcpModelInfo(_))).await;
// Route through the public `AgentInstance` API rather than reaching
// into the private `AcpAgentManager::model()`: the ai-agent crate only
// exposes `AgentInstance` to downstream callers, so tests should
// exercise the same surface.
let instance = AgentInstance::Acp(agent.clone());
let resp = instance.get_model().await.expect("get_model should succeed");
let info = resp.model_info.expect("Model info should be captured");
assert_eq!(info.current_model_id.as_deref(), Some("claude-sonnet-4"));
assert_eq!(info.available_models.len(), 2);
assert_eq!(info.available_models[0].label, "Claude Sonnet 4");
agent.kill(None).unwrap();
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_kill_terminates_process() {
let _guard = serial();
let (agent, _rx) = make_mock_agent(r#"trap '' TERM; while true; do sleep 1; done"#, "claude").await;
assert!(agent.last_activity_at() > 0);
agent.kill(Some(nomifun_common::AgentKillReason::IdleTimeout)).unwrap();
tokio::time::sleep(Duration::from_millis(1000)).await;
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_last_activity_updates() {
let _guard = serial();
let (agent, _rx) = make_mock_agent(r#"sleep 10"#, "claude").await;
let initial = agent.last_activity_at();
assert!(initial > 0);
let now = nomifun_common::now_ms();
assert!(now - initial < 5000, "Last activity should be recent");
agent.kill(None).unwrap();
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_text_content_received() {
let _guard = serial();
let (_agent, mut rx) = make_mock_agent(
r#"echo '{"type":"text","data":{"content":"Hello from ACP"}}'"#,
"claude",
)
.await;
match tokio::time::timeout(EVENT_TIMEOUT, rx.recv()).await {
Ok(Ok(AgentStreamEvent::Text(data))) => {
assert_eq!(data.content, "Hello from ACP");
}
other => panic!("Expected Text event, got {:?}", other),
}
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_agent_status_event_captures_session() {
let _guard = serial();
let (agent, mut rx) = make_mock_agent(
r#"echo '{"type":"agent_status","data":{"backend":"claude","status":"running","session_id":"sess-xyz"}}' && sleep 1"#,
"claude",
)
.await;
wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::AgentStatus(_))).await;
let session = agent.session_id().await;
assert_eq!(session, Some("sess-xyz".into()));
agent.kill(None).unwrap();
}
#[tokio::test]
#[ignore = "requires JSON-RPC mock agent"]
async fn acp_agent_multiple_event_types() {
let _guard = serial();
let (_agent, mut rx) = make_mock_agent(
r#"echo '{"type":"start","data":{"session_id":"sess-multi"}}' && echo '{"type":"thinking","data":{"content":"Analyzing...","subject":"code","duration":100,"status":"in_progress"}}' && echo '{"type":"text","data":{"content":"Result"}}' && echo '{"type":"finish","data":{"session_id":"sess-multi"}}'"#,
"claude",
)
.await;
let events = wait_for_event(&mut rx, |e| matches!(e, AgentStreamEvent::Finish(_))).await;
assert!(events.len() >= 4, "Expected 4+ events, got {}", events.len());
assert!(matches!(&events[0], AgentStreamEvent::Start(d) if d.session_id == Some("sess-multi".into())));
assert!(matches!(&events[1], AgentStreamEvent::Thinking(d) if d.content == "Analyzing..."));
assert!(matches!(&events[2], AgentStreamEvent::Text(d) if d.content == "Result"));
assert!(matches!(&events[3], AgentStreamEvent::Finish(d) if d.session_id == Some("sess-multi".into())));
}