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

218 lines
8.5 KiB
Rust

//! End-to-end guard that IDMM ACTUALLY INTERVENES — not just that its config
//! round-trips (which `idmm_e2e.rs` already covers).
//!
//! REGRESSION CONTEXT: 智能决策「无法主动触发决策 / 完全不可用」recurred several times
//! (wrong-instance supervision hook — 6f7df38f; desktopGateway-as-routing —
//! 74d85a5c + b2777ddd, fixed by ef487298; the on-arm pending-confirmation gap)
//! WITHOUT a single failing test, because the only IDMM integration tests
//! covered config persistence and never the arm → detect → intervene path.
//!
//! This test reproduces the user's exact gesture — a plain desktop nomi
//! conversation whose agent is BLOCKED on a tool-permission "选择项" that was
//! emitted BEFORE 智能决策 was enabled — and asserts the decision watch recovers
//! that pending confirmation on arm and auto-confirms it. `observe()` only sees
//! FUTURE events (it missed the pre-arm permission), so the on-arm
//! `pending_signal` lane is the ONLY one that can recover it; before the fix it
//! scanned persisted chat TEXT only and never saw a structured confirmation, so
//! the agent stayed blocked forever and IDMM was silent. It fails if any link in
//! arm / on_turn_start / ensure / pending_signal / policy / inject breaks.
mod common;
use std::sync::{Arc, Mutex};
use axum::http::StatusCode;
use serde_json::json;
use tower::ServiceExt;
use nomifun_ai_agent::types::{BuildTaskOptions, SendMessageData};
use nomifun_ai_agent::{
AgentInstance, AgentSendError, AgentStreamEvent, IAgentTask, IMockAgent, IWorkerTaskManager, WorkerTaskManagerImpl,
};
use nomifun_app::{AppConfig, AppServices, create_router};
use nomifun_common::{
AgentKillReason, AgentType, AppError, Confirmation, ConfirmationOption, ConversationStatus, TimestampMs, now_ms,
};
use common::{body_json, json_with_token, setup_and_login};
/// A mock agent permanently BLOCKED on one safe (read-only) tool confirmation —
/// the structured "选择项" the agent emitted before the watch armed. Records
/// every `confirm()` it receives so the test can assert IDMM answered it.
struct BlockedOnConfirmationAgent {
conversation_id: String,
confirmed: Arc<Mutex<Vec<String>>>,
}
#[async_trait::async_trait]
impl IAgentTask for BlockedOnConfirmationAgent {
fn agent_type(&self) -> AgentType {
AgentType::Nomi
}
fn conversation_id(&self) -> &str {
&self.conversation_id
}
fn workspace(&self) -> &str {
"/tmp/test"
}
fn status(&self) -> Option<ConversationStatus> {
None
}
fn last_activity_at(&self) -> TimestampMs {
now_ms()
}
fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentStreamEvent> {
// No live sender → the observe() live lane sees a closed stream, so the
// ONLY way IDMM can act is the on-arm pending_signal lane (the point).
let (tx, _) = tokio::sync::broadcast::channel(1);
tx.subscribe()
}
async fn send_message(&self, _data: SendMessageData) -> Result<(), AgentSendError> {
Ok(())
}
async fn cancel(&self) -> Result<(), AppError> {
Ok(())
}
fn kill(&self, _reason: Option<AgentKillReason>) -> Result<(), AppError> {
Ok(())
}
}
#[async_trait::async_trait]
impl IMockAgent for BlockedOnConfirmationAgent {
fn get_confirmations(&self) -> Vec<Confirmation> {
vec![Confirmation {
id: "conf_1".into(),
call_id: "call_42".into(),
title: Some("读取 package.json".into()),
action: None,
description: "允许读取该文件?".into(),
// read-only → command_type_is_safe → the rule tier auto-confirms the
// safe "proceed once" option without needing a backup model.
command_type: Some("read".into()),
options: vec![
ConfirmationOption {
label: "允许一次".into(),
value: json!("proceed_once"),
params: None,
},
ConfirmationOption {
label: "拒绝".into(),
value: json!("cancel"),
params: None,
},
],
}]
}
fn confirm(
&self,
_msg_id: &str,
call_id: &str,
_data: serde_json::Value,
_always_allow: bool,
) -> Result<(), AppError> {
self.confirmed.lock().unwrap().push(call_id.to_string());
Ok(())
}
}
/// Build an app whose agent factory returns a `BlockedOnConfirmationAgent`,
/// sharing a recorder so the test can observe IDMM's auto-confirm.
async fn build_app_blocked_on_confirmation() -> (axum::Router, AppServices, Arc<Mutex<Vec<String>>>) {
let confirmed: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let confirmed_factory = confirmed.clone();
let db = nomifun_db::init_database_memory().await.unwrap();
let factory: Arc<
dyn Fn(BuildTaskOptions) -> futures_util::future::BoxFuture<'static, Result<AgentInstance, AppError>>
+ Send
+ Sync,
> = Arc::new(move |opts: BuildTaskOptions| {
let confirmed = confirmed_factory.clone();
Box::pin(async move {
Ok(AgentInstance::Mock(Arc::new(BlockedOnConfirmationAgent {
conversation_id: opts.conversation_id,
confirmed,
})))
})
});
let wtm: Arc<dyn IWorkerTaskManager> = Arc::new(WorkerTaskManagerImpl::new(factory));
let services = AppServices::from_config(db, &AppConfig::default())
.await
.unwrap()
.with_worker_task_manager(wtm);
let router = create_router(&services).await;
(router, services, confirmed)
}
#[tokio::test]
async fn idmm_recovers_and_confirms_on_arm_pending_tool_confirmation() {
let (mut app, services, confirmed) = build_app_blocked_on_confirmation().await;
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
// A plain desktop nomi conversation (no channel/companion markers, no
// channel_chat_id → is_plain_desktop / not-routed → IDMM may auto-answer).
let conv = {
let body = json!({ "type": "nomi", "name": "idmm-intervene", "extra": { "workspace": "/project" } });
let resp = app
.clone()
.oneshot(json_with_token("POST", "/api/conversations", body, &token, &csrf))
.await
.unwrap();
assert!(resp.status().is_success(), "create conversation failed: {}", resp.status());
body_json(resp).await["data"]["id"].as_i64().unwrap().to_string()
};
// Enable 决策值守 at the rule tier — a safe read-only confirmation is
// auto-confirmed by the rule tier (no backup model required).
let body = json!({
"kind": "conversation",
"target_id": conv,
"decision_watch": { "enabled": true, "tier": "rule_only" }
});
let resp = app
.clone()
.oneshot(json_with_token("POST", "/api/idmm", body, &token, &csrf))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "enabling the decision watch should succeed");
// Send a message: this builds + registers the (already confirmation-blocked)
// agent task and fires on_turn_start, which arms IDMM. The supervisor's
// pending_signal must recover the live pending confirmation and auto-confirm
// it — the agent emitted no future events (closed stream), so the on-arm lane
// is the only one that can act.
let body = json!({ "content": "帮我写一个贪吃蛇游戏,并在每个设计环节都回复我" });
let resp = app
.clone()
.oneshot(json_with_token(
"POST",
&format!("/api/conversations/{conv}/messages"),
body,
&token,
&csrf,
))
.await
.unwrap();
assert!(
resp.status().is_success(),
"send_message should accept the turn, got {}",
resp.status()
);
// Arming + pending_signal + inject happen on a detached task; poll for the
// auto-confirm (no backoff on the on-arm pending decision, so it is prompt).
let mut answered = false;
for _ in 0..80 {
if confirmed.lock().unwrap().iter().any(|c| c == "call_42") {
answered = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(
answered,
"IDMM must recover the on-arm pending tool-confirmation and auto-confirm call_42 (decision watch was inert on pending confirmations); confirmed={:?}",
confirmed.lock().unwrap()
);
}