f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
530 lines
18 KiB
Rust
530 lines
18 KiB
Rust
use std::sync::{Arc, Mutex};
|
|
|
|
use async_trait::async_trait;
|
|
use nomifun_ai_agent::agent_task::{AgentInstance, IAgentTask};
|
|
use nomifun_ai_agent::protocol::events::FinishEventData;
|
|
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
|
|
use nomifun_ai_agent::{AgentSendError, AgentStreamEvent, IMockAgent, IWorkerTaskManager};
|
|
use nomifun_api_types::WebSocketMessage;
|
|
use nomifun_channel::channel_settings::ChannelSettingsService;
|
|
use nomifun_channel::message_service::ChannelMessageService;
|
|
use nomifun_channel::types::PluginType;
|
|
use nomifun_common::{AgentKillReason, AgentType, AppError, ConversationStatus, TimestampMs};
|
|
use nomifun_conversation::ConversationService;
|
|
use nomifun_conversation::skill_resolver::{ResolvedAgentSkill, SkillResolver};
|
|
use nomifun_db::models::AssistantSessionRow;
|
|
use nomifun_db::{
|
|
SqliteAcpSessionRepository, SqliteAgentMetadataRepository, SqliteChannelRepository,
|
|
SqliteClientPreferenceRepository, SqliteConversationRepository, init_database_memory,
|
|
};
|
|
use nomifun_realtime::EventBroadcaster;
|
|
use tokio::sync::broadcast;
|
|
|
|
struct TestBroadcaster {
|
|
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
|
}
|
|
|
|
impl TestBroadcaster {
|
|
fn new() -> Self {
|
|
Self {
|
|
events: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl EventBroadcaster for TestBroadcaster {
|
|
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
|
self.events.lock().unwrap().push(event);
|
|
}
|
|
}
|
|
|
|
struct NoopSkillResolver;
|
|
|
|
#[async_trait]
|
|
impl SkillResolver for NoopSkillResolver {
|
|
async fn auto_inject_names(&self) -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
|
|
async fn resolve_skills(&self, _names: &[String]) -> Vec<ResolvedAgentSkill> {
|
|
Vec::new()
|
|
}
|
|
|
|
async fn link_workspace_skills(
|
|
&self,
|
|
_workspace: &std::path::Path,
|
|
_rel_dirs: &[&str],
|
|
_skills: &[ResolvedAgentSkill],
|
|
) -> usize {
|
|
0
|
|
}
|
|
}
|
|
|
|
struct ScriptedAgent {
|
|
conversation_id: String,
|
|
event_tx: broadcast::Sender<AgentStreamEvent>,
|
|
}
|
|
|
|
impl ScriptedAgent {
|
|
fn new(conversation_id: &str) -> Self {
|
|
let (event_tx, _) = broadcast::channel(16);
|
|
Self {
|
|
conversation_id: conversation_id.to_owned(),
|
|
event_tx,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IAgentTask for ScriptedAgent {
|
|
fn agent_type(&self) -> AgentType {
|
|
AgentType::Nomi
|
|
}
|
|
|
|
fn conversation_id(&self) -> &str {
|
|
&self.conversation_id
|
|
}
|
|
|
|
fn workspace(&self) -> &str {
|
|
"/tmp/nomifun-channel-test"
|
|
}
|
|
|
|
fn status(&self) -> Option<ConversationStatus> {
|
|
Some(ConversationStatus::Finished)
|
|
}
|
|
|
|
fn last_activity_at(&self) -> TimestampMs {
|
|
0
|
|
}
|
|
|
|
fn subscribe(&self) -> broadcast::Receiver<AgentStreamEvent> {
|
|
self.event_tx.subscribe()
|
|
}
|
|
|
|
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
|
|
let _ = self.event_tx.send(AgentStreamEvent::Finish(FinishEventData::default()));
|
|
Ok(())
|
|
}
|
|
|
|
async fn cancel(&self) -> Result<(), AppError> {
|
|
Ok(())
|
|
}
|
|
|
|
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl IMockAgent for ScriptedAgent {}
|
|
|
|
struct RecordingTaskManager {
|
|
agents: Mutex<std::collections::HashMap<String, AgentInstance>>,
|
|
}
|
|
|
|
impl RecordingTaskManager {
|
|
fn new() -> Self {
|
|
Self {
|
|
agents: Mutex::new(std::collections::HashMap::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IWorkerTaskManager for RecordingTaskManager {
|
|
fn get_task(&self, conversation_id: &str) -> Option<AgentInstance> {
|
|
self.agents.lock().unwrap().get(conversation_id).cloned()
|
|
}
|
|
|
|
async fn get_or_build_task(
|
|
&self,
|
|
conversation_id: &str,
|
|
_options: BuildTaskOptions,
|
|
) -> Result<AgentInstance, AppError> {
|
|
let mut agents = self.agents.lock().unwrap();
|
|
if let Some(agent) = agents.get(conversation_id) {
|
|
return Ok(agent.clone());
|
|
}
|
|
|
|
let agent = AgentInstance::Mock(Arc::new(ScriptedAgent::new(conversation_id)));
|
|
agents.insert(conversation_id.to_owned(), agent.clone());
|
|
Ok(agent)
|
|
}
|
|
|
|
fn kill(&self, conversation_id: &str, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
|
|
self.agents.lock().unwrap().remove(conversation_id);
|
|
Ok(())
|
|
}
|
|
|
|
fn kill_and_wait(
|
|
&self,
|
|
conversation_id: &str,
|
|
reason: Option<AgentKillReason>,
|
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
|
let _ = self.kill(conversation_id, reason);
|
|
Box::pin(std::future::ready(()))
|
|
}
|
|
|
|
fn clear(&self) {
|
|
self.agents.lock().unwrap().clear();
|
|
}
|
|
|
|
fn active_count(&self) -> usize {
|
|
self.agents.lock().unwrap().len()
|
|
}
|
|
|
|
fn collect_idle(&self, _idle_threshold_ms: TimestampMs) -> Vec<String> {
|
|
Vec::new()
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn send_to_agent_warms_cold_task_before_returning_stream_subscription() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let pool = db.pool().clone();
|
|
|
|
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
|
let conversation_svc = Arc::new(ConversationService::new(
|
|
std::env::temp_dir(),
|
|
Arc::new(TestBroadcaster::new()),
|
|
Arc::new(NoopSkillResolver),
|
|
Arc::clone(&task_manager),
|
|
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
|
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
|
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
|
));
|
|
|
|
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
|
SqliteClientPreferenceRepository::new(pool.clone()),
|
|
)));
|
|
let message_svc = ChannelMessageService::new(
|
|
conversation_svc,
|
|
Arc::clone(&task_manager),
|
|
settings,
|
|
Arc::new(SqliteChannelRepository::new(pool)),
|
|
"system_default_user".to_owned(),
|
|
);
|
|
|
|
let session = AssistantSessionRow {
|
|
id: "session-1".to_owned(),
|
|
user_id: "channel-user-1".to_owned(),
|
|
agent_type: "nomi".to_owned(),
|
|
conversation_id: None,
|
|
workspace: None,
|
|
chat_id: Some("7088048016".to_owned()),
|
|
channel_id: None,
|
|
created_at: 1,
|
|
last_activity: 1,
|
|
};
|
|
|
|
for platform in [
|
|
PluginType::Telegram,
|
|
PluginType::Lark,
|
|
PluginType::Dingtalk,
|
|
PluginType::Weixin,
|
|
] {
|
|
let result = message_svc.send_to_agent(&session, "hello", platform).await.unwrap();
|
|
|
|
assert!(
|
|
result.stream_rx.is_some(),
|
|
"channel relay must have an agent stream receiver after cold start for {platform:?}"
|
|
);
|
|
assert!(task_manager.get_task(&result.conversation_id).is_some());
|
|
}
|
|
}
|
|
|
|
// ── Fix 3/4 support: last_user_text + is_conversation_busy ──────────────
|
|
|
|
struct TestStack {
|
|
conversation_svc: Arc<ConversationService>,
|
|
message_svc: ChannelMessageService,
|
|
runtime: Arc<nomifun_conversation::runtime_state::ConversationRuntimeStateService>,
|
|
channel_repo: Arc<SqliteChannelRepository>,
|
|
}
|
|
|
|
fn build_stack(pool: nomifun_db::SqlitePool) -> TestStack {
|
|
let task_manager: Arc<dyn IWorkerTaskManager> = Arc::new(RecordingTaskManager::new());
|
|
let runtime = Arc::new(nomifun_conversation::runtime_state::ConversationRuntimeStateService::default());
|
|
let conversation_svc = Arc::new(
|
|
ConversationService::new(
|
|
std::env::temp_dir(),
|
|
Arc::new(TestBroadcaster::new()),
|
|
Arc::new(NoopSkillResolver),
|
|
Arc::clone(&task_manager),
|
|
Arc::new(SqliteConversationRepository::new(pool.clone())),
|
|
Arc::new(SqliteAgentMetadataRepository::new(pool.clone())),
|
|
Arc::new(SqliteAcpSessionRepository::new(pool.clone())),
|
|
)
|
|
.with_runtime_state(Arc::clone(&runtime)),
|
|
);
|
|
|
|
let settings = Arc::new(ChannelSettingsService::new(Arc::new(
|
|
SqliteClientPreferenceRepository::new(pool.clone()),
|
|
)));
|
|
let channel_repo = Arc::new(SqliteChannelRepository::new(pool));
|
|
let message_svc = ChannelMessageService::new(
|
|
Arc::clone(&conversation_svc),
|
|
Arc::clone(&task_manager),
|
|
settings,
|
|
channel_repo.clone(),
|
|
"system_default_user".to_owned(),
|
|
);
|
|
|
|
TestStack {
|
|
conversation_svc,
|
|
message_svc,
|
|
runtime,
|
|
channel_repo,
|
|
}
|
|
}
|
|
|
|
fn make_session(conversation_id: Option<i64>) -> AssistantSessionRow {
|
|
AssistantSessionRow {
|
|
id: "session-1".to_owned(),
|
|
user_id: "channel-user-1".to_owned(),
|
|
agent_type: "nomi".to_owned(),
|
|
conversation_id,
|
|
workspace: None,
|
|
chat_id: Some("7088048016".to_owned()),
|
|
channel_id: None,
|
|
created_at: 1,
|
|
last_activity: 1,
|
|
}
|
|
}
|
|
|
|
/// Waits for the background turn spawned by `send_message` to release its
|
|
/// runtime claim so the next send doesn't hit the turn-conflict guard.
|
|
async fn wait_until_idle(svc: &Arc<ConversationService>, conversation_id: &str) {
|
|
use nomifun_api_types::ConversationRuntimeStateKind;
|
|
for _ in 0..500 {
|
|
let summary = svc.runtime_summary_for(conversation_id).await;
|
|
if summary.state == ConversationRuntimeStateKind::Idle {
|
|
return;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
|
}
|
|
panic!("conversation {conversation_id} never became idle");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn last_user_text_returns_latest_user_prompt() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
|
|
// First prompt creates the conversation; second one is the newest.
|
|
let session = make_session(None);
|
|
let first = stack
|
|
.message_svc
|
|
.send_to_agent(&session, "first prompt", PluginType::Telegram)
|
|
.await
|
|
.unwrap();
|
|
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
|
|
|
// SendResult.conversation_id is a String (Option A); the session FK is i64.
|
|
let bound_session = make_session(Some(first.conversation_id.parse::<i64>().unwrap()));
|
|
stack
|
|
.message_svc
|
|
.send_to_agent(&bound_session, "second prompt", PluginType::Telegram)
|
|
.await
|
|
.unwrap();
|
|
wait_until_idle(&stack.conversation_svc, &first.conversation_id).await;
|
|
|
|
let text = stack.message_svc.last_user_text(&first.conversation_id).await.unwrap();
|
|
assert_eq!(text.as_deref(), Some("second prompt"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn last_user_text_none_for_unknown_conversation() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
|
|
// Unknown conversation maps to a lookup error, not a silent None.
|
|
let result = stack.message_svc.last_user_text("missing-conv").await;
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn is_conversation_busy_reflects_turn_claim() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
|
|
let session = make_session(None);
|
|
let sent = stack
|
|
.message_svc
|
|
.send_to_agent(&session, "hello", PluginType::Telegram)
|
|
.await
|
|
.unwrap();
|
|
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
|
|
|
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
|
|
|
// Claiming the turn is exactly what send_message does while a prompt is
|
|
// in flight → the channel guard must report busy.
|
|
let _claim = stack.runtime.try_claim_turn(&sent.conversation_id).unwrap();
|
|
assert!(stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
|
|
|
drop(_claim);
|
|
assert!(!stack.message_svc.is_conversation_busy(&sent.conversation_id).await);
|
|
}
|
|
|
|
// ── Channel companion binding resolution + single-session routing ──────────────
|
|
|
|
/// Profile stub: maps each companion id to a pre-seeded single-session
|
|
/// conversation id (what `CompanionManager.create` would return in production),
|
|
/// records every `ensure_companion_session` call, and uses `companion_y` as the
|
|
/// legacy/default per-platform fallback. An empty `sessions` map models a
|
|
/// companion with no chat model configured (ensure returns `None`).
|
|
struct StubProfile {
|
|
sessions: std::collections::HashMap<String, i64>,
|
|
calls: Mutex<Vec<String>>,
|
|
}
|
|
|
|
impl StubProfile {
|
|
fn new(sessions: std::collections::HashMap<String, i64>) -> Self {
|
|
Self {
|
|
sessions,
|
|
calls: Mutex::new(Vec::new()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl nomifun_channel::message_service::MasterAgentProfile for StubProfile {
|
|
async fn companion_model(&self, _companion_id: &str) -> Option<nomifun_common::ProviderWithModel> {
|
|
None
|
|
}
|
|
async fn master_companion_id(&self, _platform: &str) -> Option<String> {
|
|
Some("companion_y".to_owned())
|
|
}
|
|
async fn companion_exists(&self, _companion_id: &str) -> bool {
|
|
true
|
|
}
|
|
async fn ensure_companion_session(&self, companion_id: &str) -> Option<i64> {
|
|
self.calls.lock().unwrap().push(companion_id.to_owned());
|
|
self.sessions.get(companion_id).copied()
|
|
}
|
|
}
|
|
|
|
/// Seed a companion's single-session conversation (the row `CompanionManager`
|
|
/// would own), returning its i64 id.
|
|
async fn seed_companion_session(svc: &Arc<ConversationService>, companion_id: &str) -> i64 {
|
|
let req = nomifun_api_types::CreateConversationRequest {
|
|
r#type: AgentType::Nomi,
|
|
name: Some(format!("和 {companion_id} 聊天")),
|
|
model: Some(nomifun_common::ProviderWithModel {
|
|
provider_id: "p".to_owned(),
|
|
model: "m".to_owned(),
|
|
use_model: Some("m".to_owned()),
|
|
}),
|
|
source: None,
|
|
channel_chat_id: None,
|
|
extra: serde_json::json!({ "companionSession": true, "companionId": companion_id }),
|
|
};
|
|
svc.create("system_default_user", req).await.unwrap().id
|
|
}
|
|
|
|
async fn bind_channel_to_companion(repo: &Arc<SqliteChannelRepository>, channel_id: &str, companion_id: &str) {
|
|
use nomifun_db::IChannelRepository;
|
|
let now = nomifun_common::now_ms();
|
|
repo.upsert_plugin(&nomifun_db::models::ChannelPluginRow {
|
|
id: channel_id.to_owned(),
|
|
r#type: "telegram".to_owned(),
|
|
name: "Telegram Bot".to_owned(),
|
|
enabled: true,
|
|
config: "enc".to_owned(),
|
|
status: None,
|
|
last_connected: None,
|
|
companion_id: Some(companion_id.to_owned()),
|
|
bot_key: Some("42".to_owned()),
|
|
created_at: now,
|
|
updated_at: now,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
}
|
|
|
|
/// The channel row's own companion binding wins over the profile fallback, and
|
|
/// either way the turn is routed INTO that companion's single session (not a
|
|
/// freshly-minted channel-master conversation).
|
|
#[tokio::test]
|
|
async fn channel_companion_turn_routes_into_companion_single_session() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
|
|
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
|
let conv_y = seed_companion_session(&stack.conversation_svc, "companion_y").await;
|
|
let sessions = std::collections::HashMap::from([
|
|
("companion_x".to_owned(), conv_x),
|
|
("companion_y".to_owned(), conv_y),
|
|
]);
|
|
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
|
|
|
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
|
|
|
// Bound channel → channel companion (companion_x) wins; the turn runs on
|
|
// companion_x's single session conversation, NOT a new channel conversation.
|
|
let mut bound = make_session(None);
|
|
bound.channel_id = Some("achn_test".to_owned());
|
|
let sent = message_svc.send_to_agent(&bound, "hi", PluginType::Telegram).await.unwrap();
|
|
assert_eq!(sent.conversation_id, conv_x.to_string());
|
|
wait_until_idle(&stack.conversation_svc, &sent.conversation_id).await;
|
|
|
|
// No channel binding → profile fallback companion (companion_y) → its session.
|
|
let mut unbound = make_session(None);
|
|
unbound.id = "session-2".to_owned();
|
|
unbound.chat_id = Some("other-chat".to_owned());
|
|
let sent = message_svc.send_to_agent(&unbound, "hi", PluginType::Telegram).await.unwrap();
|
|
assert_eq!(sent.conversation_id, conv_y.to_string());
|
|
}
|
|
|
|
/// Two different IM chats bound to the SAME companion both land in that
|
|
/// companion's ONE session — the unification guarantee. No standalone
|
|
/// channel-master conversation is created for either.
|
|
#[tokio::test]
|
|
async fn companion_im_turns_share_one_session() {
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
|
|
let conv_x = seed_companion_session(&stack.conversation_svc, "companion_x").await;
|
|
let sessions = std::collections::HashMap::from([("companion_x".to_owned(), conv_x)]);
|
|
let message_svc = stack.message_svc.with_master_profile(Arc::new(StubProfile::new(sessions)));
|
|
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
|
|
|
let mut chat_a = make_session(None);
|
|
chat_a.channel_id = Some("achn_test".to_owned());
|
|
chat_a.chat_id = Some("chat-A".to_owned());
|
|
let a = message_svc.send_to_agent(&chat_a, "hi from A", PluginType::Telegram).await.unwrap();
|
|
wait_until_idle(&stack.conversation_svc, &a.conversation_id).await;
|
|
|
|
let mut chat_b = make_session(None);
|
|
chat_b.id = "session-b".to_owned();
|
|
chat_b.channel_id = Some("achn_test".to_owned());
|
|
chat_b.chat_id = Some("chat-B".to_owned());
|
|
let b = message_svc.send_to_agent(&chat_b, "hi from B", PluginType::Telegram).await.unwrap();
|
|
|
|
assert_eq!(a.conversation_id, conv_x.to_string());
|
|
assert_eq!(b.conversation_id, conv_x.to_string(), "both IM chats must share the companion's single session");
|
|
}
|
|
|
|
/// A companion with no chat model (ensure returns None) refuses the turn with a
|
|
/// distinct error instead of silently minting a leaking standalone conversation.
|
|
#[tokio::test]
|
|
async fn companion_without_model_refuses_turn() {
|
|
use nomifun_channel::error::ChannelError;
|
|
|
|
let db = init_database_memory().await.unwrap();
|
|
let stack = build_stack(db.pool().clone());
|
|
// Empty sessions map → ensure_companion_session returns None for every companion.
|
|
let message_svc = stack
|
|
.message_svc
|
|
.with_master_profile(Arc::new(StubProfile::new(std::collections::HashMap::new())));
|
|
bind_channel_to_companion(&stack.channel_repo, "achn_test", "companion_x").await;
|
|
|
|
let mut bound = make_session(None);
|
|
bound.channel_id = Some("achn_test".to_owned());
|
|
let err = message_svc
|
|
.send_to_agent(&bound, "hi", PluginType::Telegram)
|
|
.await
|
|
.expect_err("a model-less companion must refuse the turn");
|
|
assert!(matches!(err, ChannelError::CompanionNotReady(_)));
|
|
}
|