f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
401 lines
15 KiB
Rust
401 lines
15 KiB
Rust
//! Integration tests for the ACP prompt pipeline.
|
|
//!
|
|
//! Unlike acp_agent_integration.rs, these tests do not exercise
|
|
//! AcpAgentManager or the JSON-RPC protocol. They construct a
|
|
//! PromptPipeline with the two built-in hooks and invoke
|
|
//! pre_send against a real PromptCtx, asserting the observable
|
|
//! prompt transformation.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use nomifun_ai_agent::capability::prompt_pipeline::{PromptCtx, PromptPipeline};
|
|
use nomifun_ai_agent::factory::acp_assembler::{AcpSessionParams, WorkspaceInfo, assemble_acp_params};
|
|
use nomifun_ai_agent::manager::acp::{
|
|
AcpSession, KnowledgeContextHook, ModelIdentityReminderHook, SessionNewPreludeHook,
|
|
};
|
|
use nomifun_ai_agent::registry::AgentRegistry;
|
|
use nomifun_ai_agent::shared_kernel::ModelId;
|
|
use nomifun_ai_agent::{AcpBuildExtra, AcpSkillManager, AgentRuntime};
|
|
use nomifun_db::{SqliteAgentMetadataRepository, init_database_memory};
|
|
|
|
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
|
|
async fn fixture_params(
|
|
backend: &str,
|
|
preset_context: Option<&str>,
|
|
is_custom_workspace: bool,
|
|
) -> Arc<AcpSessionParams> {
|
|
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 config = 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: None,
|
|
agent_name: None,
|
|
custom_agent_id: None,
|
|
preset_context: preset_context.map(str::to_owned),
|
|
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,
|
|
};
|
|
|
|
Arc::new(
|
|
assemble_acp_params(
|
|
"conv-pp-test".into(),
|
|
WorkspaceInfo {
|
|
path: "/tmp".into(),
|
|
is_custom: is_custom_workspace,
|
|
},
|
|
metadata,
|
|
nomifun_common::CommandSpec {
|
|
command: "/usr/bin/true".into(),
|
|
args: vec![],
|
|
env: vec![],
|
|
cwd: None,
|
|
},
|
|
config,
|
|
Vec::new(),
|
|
None,
|
|
std::env::temp_dir(),
|
|
)
|
|
.await,
|
|
)
|
|
}
|
|
|
|
/// Like [`fixture_params`] but with a single mounted knowledge base, so the
|
|
/// assembled params carry a non-empty `knowledge_context`. preset_context is
|
|
/// `None` and workspace custom — the knowledge section is independent of both.
|
|
async fn fixture_params_with_knowledge(backend: &str) -> Arc<AcpSessionParams> {
|
|
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 config = AcpBuildExtra {
|
|
backend: Some(backend.to_owned()),
|
|
knowledge_mounts: vec![nomifun_api_types::KnowledgeMountInfo {
|
|
id: "kb_1".into(),
|
|
name: "领域知识".into(),
|
|
description: "团队约定".into(),
|
|
rel_path: ".nomi/knowledge/领域知识".into(),
|
|
toc: vec!["concepts/术语.md — 术语表".into()],
|
|
summary: Some("Covers domain terms.".into()),
|
|
live_sources: vec![],
|
|
}],
|
|
..Default::default()
|
|
};
|
|
|
|
Arc::new(
|
|
assemble_acp_params(
|
|
"conv-pp-test".into(),
|
|
WorkspaceInfo {
|
|
path: "/tmp".into(),
|
|
is_custom: true,
|
|
},
|
|
metadata,
|
|
nomifun_common::CommandSpec {
|
|
command: "/usr/bin/true".into(),
|
|
args: vec![],
|
|
env: vec![],
|
|
cwd: None,
|
|
},
|
|
config,
|
|
Vec::new(),
|
|
None,
|
|
std::env::temp_dir(),
|
|
)
|
|
.await,
|
|
)
|
|
}
|
|
|
|
fn fixture_skill_manager() -> Arc<AcpSkillManager> {
|
|
let tmp = tempfile::TempDir::new().unwrap();
|
|
let paths = Arc::new(nomifun_extension::resolve_skill_paths(tmp.path(), tmp.path()));
|
|
// tmp dir needs to live until the test finishes.
|
|
// mem::forget is acceptable in test code — we just don't need the Drop cleanup.
|
|
std::mem::forget(tmp);
|
|
AcpSkillManager::new(paths)
|
|
}
|
|
|
|
fn fixture_runtime() -> AgentRuntime {
|
|
AgentRuntime::new("conv-pp-test", "/tmp", 64)
|
|
}
|
|
|
|
fn make_pipeline() -> PromptPipeline {
|
|
// Mirror the real registration order in AcpAgentManager::new.
|
|
PromptPipeline::new(vec![
|
|
Arc::new(KnowledgeContextHook),
|
|
Arc::new(SessionNewPreludeHook),
|
|
Arc::new(ModelIdentityReminderHook),
|
|
])
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
/// First prompt after session/new: prelude block injected, flag consumed.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn brand_new_first_prompt_injects_preset_context() {
|
|
let params = fixture_params("claude", Some("Rule A"), true).await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
|
|
// Simulate: open_session_new just succeeded.
|
|
session.mark_pending_session_new_prelude();
|
|
|
|
let pipeline = make_pipeline();
|
|
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "hello".into()).await;
|
|
assert!(out.contains("[Assistant Rules]"), "prelude block missing: {out}");
|
|
assert!(out.contains("Rule A"), "preset_context missing: {out}");
|
|
assert!(out.ends_with("hello"), "user content should be at the end: {out}");
|
|
|
|
// Flag must have been consumed.
|
|
assert!(
|
|
!session.take_pending_session_new_prelude(),
|
|
"pending_session_new_prelude must be false after pre_send consumed it"
|
|
);
|
|
}
|
|
|
|
/// Second prompt: no prelude, no reminder — pure passthrough.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn second_prompt_is_passthrough() {
|
|
let params = fixture_params("claude", Some("Rule A"), true).await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
session.mark_pending_session_new_prelude();
|
|
|
|
let pipeline = make_pipeline();
|
|
|
|
// First prompt consumes the flag.
|
|
{
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
let _ = pipeline.pre_send(&mut ctx, "first".into()).await;
|
|
}
|
|
|
|
// Second prompt: flag already consumed.
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
let out = pipeline.pre_send(&mut ctx, "second".into()).await;
|
|
assert_eq!(out, "second", "no prelude / no reminder expected on second turn");
|
|
}
|
|
|
|
/// Resume path: no mark_pending_session_new_prelude — prompt must be unchanged.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn resume_path_does_not_inject() {
|
|
let params = fixture_params("claude", Some("Rule A"), true).await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
|
|
// Resume: session opened by open_session_resume which does NOT call
|
|
// mark_pending_session_new_prelude. The flag stays false.
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
|
|
let pipeline = make_pipeline();
|
|
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "continue the story".into()).await;
|
|
assert_eq!(out, "continue the story");
|
|
}
|
|
|
|
/// L2 fix: the knowledge retrieval-protocol section is delivered on a RESUMED
|
|
/// session (open_session_resume marks pending_knowledge_prelude) even though the
|
|
/// new-session [Assistant Rules] prelude is NOT. This is what makes a
|
|
/// resumed/restarted ACP session — or one rebuilt after a 挂载知识库 change —
|
|
/// actually trigger retrieval.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn knowledge_section_delivered_on_resume() {
|
|
let params = fixture_params_with_knowledge("claude").await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
|
|
// Resume: open_session_resume marks the knowledge prelude but NOT the
|
|
// new-session prelude.
|
|
session.mark_pending_knowledge_prelude();
|
|
|
|
let pipeline = make_pipeline();
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "用领域知识回答".into()).await;
|
|
assert!(out.contains("[Knowledge Bases]"), "knowledge block missing: {out}");
|
|
assert!(
|
|
out.contains("## Knowledge bases (extended knowledge source)"),
|
|
"retrieval protocol section missing: {out}"
|
|
);
|
|
assert!(out.contains("Retrieval protocol"), "retrieval protocol missing: {out}");
|
|
assert!(out.contains("领域知识"), "mounted base name missing: {out}");
|
|
// The new-session rules prelude must NOT appear on a resumed session.
|
|
assert!(!out.contains("[Assistant Rules]"), "rules prelude must not inject on resume: {out}");
|
|
assert!(out.ends_with("用领域知识回答"), "user content must survive at the end: {out}");
|
|
|
|
// Flag consumed — a later turn in the same session is a passthrough.
|
|
let mut ctx2 = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
let out2 = pipeline.pre_send(&mut ctx2, "再问一句".into()).await;
|
|
assert_eq!(out2, "再问一句", "knowledge section must be one-shot per session open");
|
|
}
|
|
|
|
/// Without the pending_knowledge_prelude flag the section is not injected, even
|
|
/// when bases are mounted (e.g. an ordinary mid-session turn).
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn knowledge_section_skipped_without_flag() {
|
|
let params = fixture_params_with_knowledge("claude").await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
|
|
let pipeline = make_pipeline();
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "hello".into()).await;
|
|
assert_eq!(out, "hello", "no knowledge flag → passthrough");
|
|
}
|
|
|
|
/// Pending model notice: reminder prepended, then drained so second call is clean.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn pending_model_notice_triggers_reminder_prepend() {
|
|
let params = fixture_params("claude", None, true).await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
|
|
// Simulate set_model reconciled successfully and stuck the notice.
|
|
session.set_pending_model_notice(ModelId::new("claude-opus-4"));
|
|
|
|
let pipeline = make_pipeline();
|
|
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "go".into()).await;
|
|
assert!(out.contains("<system-reminder>"), "reminder missing: {out}");
|
|
assert!(out.ends_with("go"), "user content must survive at the end: {out}");
|
|
|
|
// Second call: notice already drained — no reminder.
|
|
let mut ctx2 = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
let out2 = pipeline.pre_send(&mut ctx2, "next".into()).await;
|
|
assert_eq!(out2, "next");
|
|
}
|
|
|
|
/// Both flags set: reminder (outermost) wraps the prelude block.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
async fn both_flags_prepend_reminder_outermost() {
|
|
let params = fixture_params("claude", Some("Rule A"), true).await;
|
|
let skill_manager = fixture_skill_manager();
|
|
let runtime = fixture_runtime();
|
|
let mut session = AcpSession::new(None, None, HashMap::new());
|
|
session.mark_pending_session_new_prelude();
|
|
session.set_pending_model_notice(ModelId::new("claude-opus-4"));
|
|
|
|
let pipeline = make_pipeline();
|
|
|
|
let mut ctx = PromptCtx {
|
|
session: &mut session,
|
|
params: ¶ms,
|
|
skill_manager: &skill_manager,
|
|
runtime: &runtime,
|
|
};
|
|
|
|
let out = pipeline.pre_send(&mut ctx, "hi".into()).await;
|
|
let reminder_idx = out.find("<system-reminder>").expect("reminder must be present");
|
|
let rules_idx = out.find("[Assistant Rules]").expect("rules block must be present");
|
|
assert!(
|
|
reminder_idx < rules_idx,
|
|
"reminder must sit outside (before) the assistant rules block:\n{out}"
|
|
);
|
|
assert!(out.ends_with("hi"));
|
|
}
|
|
|
|
/// Skeleton: unlock once inject_first_message_prefix surfaces errors.
|
|
#[tokio::test(flavor = "current_thread")]
|
|
#[ignore = "SessionNewPreludeHook relies on inject_first_message_prefix which currently swallows I/O errors internally; unlocking this test requires surfacing a fallible boundary"]
|
|
async fn prelude_io_failure_emits_prompt_hook_warning() {
|
|
// When inject_first_message_prefix exposes an error path, the hook
|
|
// should call emit_hook_warning("session_new_prelude", ...) and
|
|
// return the user content unchanged. Subscribers on runtime.subscribe()
|
|
// must then receive an AgentStreamEvent::AcpPromptHookWarning whose
|
|
// payload deserializes to AcpPromptHookWarningPayload with
|
|
// hook == "session_new_prelude".
|
|
let _ = fixture_params("claude", Some("ctx"), true).await;
|
|
}
|