Skip to content

Commit dce8053

Browse files
authored
fix(app): reuse conversation service for channel messages (#531)
## Summary - Reuse `AppServices.conversation_service` when channel messages need to create conversations, so assistant-backed channel conversations use the same assistant-aware assembly path as the main app. - Keep channel settings service assembly separate because it still resolves per-plugin assistant and model preferences. - Add an app-layer regression for Weixin assistant bindings covering assistant snapshot persistence, `aionrs` type resolution, and reuse of an existing channel `conversation_id`. ## Validation - `just push -u origin aionissue/task-task-20260628-001-001` completed successfully, including migration immutability checks, cargo fix/clippy fix, formatting, full `nextest` workspace run, and branch push. - `nextest` summary from the native push gate: 6535 tests passed, 18 skipped. - Recorded focused checks also passed: app regression, app channel tests, channel message service integration tests, affected-crate clippy, format check, full `aionui-app`, full `aionui-channel`, and full workspace tests. ## Notes - No database migration or schema change. - Real Weixin end-to-end validation is still recommended before release. - Cron keeps its independent `ConversationService` assembly path by scope decision; this PR does not change it. Co-authored-by: zynx <>
1 parent 85086cf commit dce8053

1 file changed

Lines changed: 224 additions & 63 deletions

File tree

crates/aionui-app/src/router/state.rs

Lines changed: 224 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,47 @@ pub fn build_mcp_state(services: &AppServices) -> McpRouterState {
446446
}
447447
}
448448

449+
fn build_channel_settings_service(
450+
services: &AppServices,
451+
) -> Arc<aionui_channel::channel_settings::ChannelSettingsService> {
452+
let pref_repo: Arc<dyn aionui_db::IClientPreferenceRepository> =
453+
Arc::new(SqliteClientPreferenceRepository::new(services.database.pool().clone()));
454+
455+
Arc::new(
456+
aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo)
457+
.with_agent_metadata_repo(Arc::new(SqliteAgentMetadataRepository::new(
458+
services.database.pool().clone(),
459+
)))
460+
.with_assistant_repos(
461+
Arc::new(SqliteAssistantDefinitionRepository::new(
462+
services.database.pool().clone(),
463+
)),
464+
Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())),
465+
),
466+
)
467+
}
468+
469+
async fn build_channel_message_service(
470+
services: &AppServices,
471+
channel_settings: Arc<aionui_channel::channel_settings::ChannelSettingsService>,
472+
) -> Arc<aionui_channel::message_service::ChannelMessageService> {
473+
let owner_user_id = services
474+
.user_repo
475+
.get_primary_webui_user()
476+
.await
477+
.ok()
478+
.flatten()
479+
.map(|u| u.id)
480+
.unwrap_or_else(|| "system_default_user".to_string());
481+
482+
Arc::new(aionui_channel::message_service::ChannelMessageService::new(
483+
Arc::new(services.conversation_service.clone()),
484+
services.worker_task_manager.clone(),
485+
channel_settings,
486+
owner_user_id,
487+
))
488+
}
489+
449490
/// Build the default `ChannelRouterState` and orchestrator components.
450491
pub async fn build_channel_state(
451492
services: &AppServices,
@@ -476,22 +517,8 @@ pub async fn build_channel_state(
476517
let plugin_factory: Arc<aionui_channel::manager::PluginFactory> =
477518
Arc::new(Box::new(aionui_channel::plugins::create_plugin));
478519

479-
// Build channel settings service for per-plugin agent/model configuration
480-
let pref_pool = services.database.pool().clone();
481-
let pref_repo: Arc<dyn aionui_db::IClientPreferenceRepository> =
482-
Arc::new(SqliteClientPreferenceRepository::new(pref_pool));
483-
let channel_settings = Arc::new(
484-
aionui_channel::channel_settings::ChannelSettingsService::new(pref_repo)
485-
.with_agent_metadata_repo(Arc::new(SqliteAgentMetadataRepository::new(
486-
services.database.pool().clone(),
487-
)))
488-
.with_assistant_repos(
489-
Arc::new(SqliteAssistantDefinitionRepository::new(
490-
services.database.pool().clone(),
491-
)),
492-
Arc::new(SqliteAssistantOverlayRepository::new(services.database.pool().clone())),
493-
),
494-
);
520+
// Build channel settings service for per-plugin agent/model configuration.
521+
let channel_settings = build_channel_settings_service(services);
495522

496523
// Build orchestrator dependencies
497524
let action_executor = Arc::new(aionui_channel::action::ActionExecutor::new(
@@ -500,53 +527,7 @@ pub async fn build_channel_state(
500527
Arc::clone(&channel_settings),
501528
));
502529

503-
let conv_repo: Arc<dyn aionui_db::IConversationRepository> = Arc::new(
504-
aionui_db::SqliteConversationRepository::new(services.database.pool().clone()),
505-
);
506-
let skill_resolver = Arc::new(aionui_conversation::skill_resolver::ExtensionSkillResolver::new(
507-
services.skill_paths.clone(),
508-
services.skill_repo.clone(),
509-
));
510-
let agent_metadata_repo: Arc<dyn aionui_db::IAgentMetadataRepository> = Arc::new(
511-
aionui_db::SqliteAgentMetadataRepository::new(services.database.pool().clone()),
512-
);
513-
let acp_session_repo: Arc<dyn aionui_db::IAcpSessionRepository> = Arc::new(
514-
aionui_db::SqliteAcpSessionRepository::new(services.database.pool().clone()),
515-
);
516-
let conversation_svc = Arc::new(
517-
ConversationService::new(
518-
services.work_dir.clone(),
519-
services.event_bus.clone(),
520-
skill_resolver,
521-
services.worker_task_manager.clone(),
522-
conv_repo,
523-
agent_metadata_repo,
524-
acp_session_repo,
525-
)
526-
.with_runtime_state(services.conversation_runtime_state.clone()),
527-
);
528-
conversation_svc.with_mcp_server_repo(Arc::new(aionui_db::SqliteMcpServerRepository::new(
529-
services.database.pool().clone(),
530-
)));
531-
if let Some(hook) = services.task_manager_delete_hook.clone() {
532-
conversation_svc.with_delete_hook(hook);
533-
}
534-
535-
let owner_user_id = services
536-
.user_repo
537-
.get_primary_webui_user()
538-
.await
539-
.ok()
540-
.flatten()
541-
.map(|u| u.id)
542-
.unwrap_or_else(|| "system_default_user".to_string());
543-
544-
let message_service = Arc::new(aionui_channel::message_service::ChannelMessageService::new(
545-
conversation_svc,
546-
services.worker_task_manager.clone(),
547-
Arc::clone(&channel_settings),
548-
owner_user_id,
549-
));
530+
let message_service = build_channel_message_service(services, Arc::clone(&channel_settings)).await;
550531

551532
let orchestrator = aionui_channel::orchestrator::ChannelOrchestrator::new(
552533
action_executor,
@@ -807,9 +788,189 @@ pub fn build_ws_state(services: &AppServices) -> WsHandlerState {
807788
mod tests {
808789
use super::*;
809790

791+
use std::sync::Arc;
792+
810793
use crate::AppConfig;
794+
use aionui_ai_agent::types::{BuildTaskOptions, SendMessageData};
795+
use aionui_ai_agent::{
796+
AgentError, AgentInstance, AgentSendError, AgentStreamEvent, IAgentTask, IMockAgent, IWorkerTaskManager,
797+
WorkerTaskManagerImpl,
798+
};
799+
use aionui_channel::types::PluginType;
800+
use aionui_common::{AgentKillReason, AgentType, ConversationStatus, TimestampMs};
801+
use aionui_db::models::{AssistantSessionRow, UpsertAssistantDefinitionParams};
802+
use aionui_db::{
803+
IAssistantDefinitionRepository, IClientPreferenceRepository, IConversationRepository,
804+
SqliteAssistantDefinitionRepository, SqliteClientPreferenceRepository, SqliteConversationRepository,
805+
};
811806
use aionui_extension::{ExtensionSource, ScanPath};
812807

808+
struct ChannelStateNoopAgent {
809+
conversation_id: String,
810+
workspace: String,
811+
}
812+
813+
#[async_trait::async_trait]
814+
impl IAgentTask for ChannelStateNoopAgent {
815+
fn agent_type(&self) -> AgentType {
816+
AgentType::Aionrs
817+
}
818+
819+
fn conversation_id(&self) -> &str {
820+
&self.conversation_id
821+
}
822+
823+
fn workspace(&self) -> &str {
824+
&self.workspace
825+
}
826+
827+
fn status(&self) -> Option<ConversationStatus> {
828+
Some(ConversationStatus::Finished)
829+
}
830+
831+
fn last_activity_at(&self) -> TimestampMs {
832+
0
833+
}
834+
835+
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentStreamEvent> {
836+
let (tx, _) = tokio::sync::broadcast::channel(1);
837+
tx.subscribe()
838+
}
839+
840+
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
841+
Ok(())
842+
}
843+
844+
async fn cancel(&self) -> Result<(), AgentError> {
845+
Ok(())
846+
}
847+
848+
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AgentError> {
849+
Ok(())
850+
}
851+
}
852+
853+
impl IMockAgent for ChannelStateNoopAgent {}
854+
855+
fn mock_worker_task_manager() -> Arc<dyn IWorkerTaskManager> {
856+
let factory = Arc::new(|opts: BuildTaskOptions| {
857+
Box::pin(async move {
858+
Ok(AgentInstance::Mock(Arc::new(ChannelStateNoopAgent {
859+
conversation_id: opts.conversation_id().to_owned(),
860+
workspace: opts.context.workspace.path,
861+
})))
862+
}) as futures_util::future::BoxFuture<'static, Result<AgentInstance, AgentError>>
863+
});
864+
865+
Arc::new(WorkerTaskManagerImpl::new(factory))
866+
}
867+
868+
fn channel_state_assistant_definition() -> UpsertAssistantDefinitionParams<'static> {
869+
UpsertAssistantDefinitionParams {
870+
id: "asstdef-channel-state-aionrs",
871+
assistant_id: "bare-channel-aionrs",
872+
source: "generated",
873+
owner_type: "system",
874+
source_ref: Some("bare-channel-aionrs"),
875+
source_version: None,
876+
source_hash: None,
877+
name: "Bare Channel Aionrs",
878+
name_i18n: "{}",
879+
description: Some("Channel state regression assistant"),
880+
description_i18n: "{}",
881+
avatar_type: "emoji",
882+
avatar_value: Some("A"),
883+
agent_id: "632f31d2",
884+
rule_resource_type: "inline",
885+
rule_resource_ref: None,
886+
rule_inline_content: Some(""),
887+
recommended_prompts: "[]",
888+
recommended_prompts_i18n: "{}",
889+
default_model_mode: "auto",
890+
default_model_value: None,
891+
default_permission_mode: "auto",
892+
default_permission_value: None,
893+
default_skills_mode: "auto",
894+
default_skill_ids: "[]",
895+
custom_skill_names: "[]",
896+
default_disabled_builtin_skill_ids: "[]",
897+
default_mcps_mode: "auto",
898+
default_mcp_ids: "[]",
899+
}
900+
}
901+
902+
#[tokio::test]
903+
async fn build_channel_message_service_uses_app_conversation_service_for_assistant_bindings() {
904+
let db = aionui_db::init_database_memory().await.unwrap();
905+
let services = AppServices::from_config(db, &AppConfig::default())
906+
.await
907+
.unwrap()
908+
.with_worker_task_manager(mock_worker_task_manager());
909+
910+
let pool = services.database.pool().clone();
911+
let definition_repo = SqliteAssistantDefinitionRepository::new(pool.clone());
912+
definition_repo
913+
.upsert(&channel_state_assistant_definition())
914+
.await
915+
.unwrap();
916+
917+
let pref_repo = SqliteClientPreferenceRepository::new(pool.clone());
918+
pref_repo
919+
.upsert_batch(&[(
920+
"assistant.weixin.agent",
921+
r#"{"assistant_id":"bare-channel-aionrs","name":"Weixin Aionrs"}"#,
922+
)])
923+
.await
924+
.unwrap();
925+
926+
let settings = build_channel_settings_service(&services);
927+
let message_service = build_channel_message_service(&services, settings).await;
928+
let session = AssistantSessionRow {
929+
id: "session-channel-state".to_owned(),
930+
user_id: "channel-user-state".to_owned(),
931+
agent_type: "aionrs".to_owned(),
932+
conversation_id: None,
933+
workspace: None,
934+
chat_id: Some("wx-chat-state".to_owned()),
935+
created_at: 1,
936+
last_activity: 1,
937+
};
938+
939+
let first = message_service
940+
.send_to_agent(&session, "hello", PluginType::Weixin)
941+
.await
942+
.unwrap();
943+
944+
let conversation_repo = SqliteConversationRepository::new(pool);
945+
let snapshot = conversation_repo
946+
.get_assistant_snapshot(&first.conversation_id)
947+
.await
948+
.unwrap()
949+
.expect("channel-created conversation should persist assistant snapshot");
950+
let conversation = conversation_repo
951+
.get(&first.conversation_id)
952+
.await
953+
.unwrap()
954+
.expect("channel-created conversation should be persisted");
955+
956+
assert_eq!(snapshot.assistant_id, "bare-channel-aionrs");
957+
assert_eq!(snapshot.agent_id, "632f31d2");
958+
assert_eq!(conversation.r#type, AgentType::Aionrs.serde_name());
959+
assert_eq!(conversation.name, "Weixin Aionrs");
960+
961+
let second_session = AssistantSessionRow {
962+
conversation_id: Some(first.conversation_id.clone()),
963+
..session
964+
};
965+
let second = message_service
966+
.send_to_agent(&second_session, "again", PluginType::Weixin)
967+
.await
968+
.unwrap();
969+
assert_eq!(second.conversation_id, first.conversation_id);
970+
971+
services.database.close().await;
972+
}
973+
813974
#[tokio::test]
814975
async fn build_extension_states_uses_host_app_version_for_engine_filtering() {
815976
let tmp = tempfile::TempDir::new().unwrap();

0 commit comments

Comments
 (0)