test(mcp): drive session re-attach and RAG attach filter guards through their real entry points
- use_session_applies_persisted_mcp_tools_immediately now loads a session file from disk through the real use_session, proving the post-assignment filter refresh applies a re-attached session's persisted allowlist. - New use_rag_does_not_drop_role_filters loads a yaml-driver RAG through the real use_rag and proves the tool-scope rebuild recomputes the role's filter layer instead of dropping it. - Seed the process-wide client/model registries in a pre-main ctor (new ctor dev-dependency) so model resolution is deterministic across test orderings; the seed exposes only an embedding model so tests that assert 'no chat model available' keep their premise.
This commit is contained in:
Generated
+23
@@ -1685,6 +1685,7 @@ dependencies = [
|
|||||||
"colored",
|
"colored",
|
||||||
"comfy-table",
|
"comfy-table",
|
||||||
"crossterm 0.29.0",
|
"crossterm 0.29.0",
|
||||||
|
"ctor",
|
||||||
"dirs",
|
"dirs",
|
||||||
"duckdb",
|
"duckdb",
|
||||||
"duct",
|
"duct",
|
||||||
@@ -1907,6 +1908,16 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctor"
|
||||||
|
version = "1.0.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
|
||||||
|
dependencies = [
|
||||||
|
"link-section",
|
||||||
|
"linktime-proc-macro",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ctutils"
|
name = "ctutils"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -3833,6 +3844,18 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "link-section"
|
||||||
|
version = "0.19.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linktime-proc-macro"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "linux-raw-sys"
|
name = "linux-raw-sys"
|
||||||
version = "0.4.15"
|
version = "0.4.15"
|
||||||
|
|||||||
+2
-1
@@ -143,6 +143,7 @@ arboard = { version = "3.3.0", default-features = false }
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
ctor = "1.0.13"
|
||||||
pretty_assertions = "1.4.0"
|
pretty_assertions = "1.4.0"
|
||||||
rmcp = { version = "3.1.2", features = ["server"] }
|
rmcp = { version = "3.1.2", features = ["server"] }
|
||||||
serial_test = "3"
|
serial_test = "3"
|
||||||
@@ -154,4 +155,4 @@ path = "src/main.rs"
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
strip = true
|
strip = true
|
||||||
opt-level = "z"
|
opt-level = "z"
|
||||||
|
|||||||
@@ -4901,6 +4901,39 @@ mod tests {
|
|||||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||||
use std::{env, mem};
|
use std::{env, mem};
|
||||||
|
|
||||||
|
// `list_client_names` / `list_all_models` cache the first AppConfig they
|
||||||
|
// see in process-wide OnceLocks. Several tests reach them through configs
|
||||||
|
// with an empty client list, which would permanently pin every later
|
||||||
|
// model lookup in this process to "unknown model" and make any test that
|
||||||
|
// needs a resolvable model dependent on test ordering. Seed the caches
|
||||||
|
// before any test runs with one client, "test-seeded", exposing a single
|
||||||
|
// embedding model. Deliberately NO chat model: some tests assert that no
|
||||||
|
// chat model is available, and chat lookups don't need one — a
|
||||||
|
// "test-seeded:<anything>" chat id resolves through the create-from-name
|
||||||
|
// fallback because the client name is registered.
|
||||||
|
//
|
||||||
|
// `unsafe` is ctor's required acknowledgment that this runs before main;
|
||||||
|
// the body only allocates and initializes OnceLocks, both of which are
|
||||||
|
// sound pre-main.
|
||||||
|
#[ctor::ctor(unsafe)]
|
||||||
|
fn seed_model_registries() {
|
||||||
|
use crate::client::{ClientConfig, ModelData, list_all_models, list_client_names};
|
||||||
|
|
||||||
|
let mut client = ClientConfig::default();
|
||||||
|
if let ClientConfig::OpenAIConfig(config) = &mut client {
|
||||||
|
config.name = Some("test-seeded".to_string());
|
||||||
|
let mut embedder = ModelData::new("test-embedder");
|
||||||
|
embedder.model_type = "embedding".to_string();
|
||||||
|
config.models = vec![embedder];
|
||||||
|
}
|
||||||
|
let config = AppConfig {
|
||||||
|
clients: vec![client],
|
||||||
|
..AppConfig::default()
|
||||||
|
};
|
||||||
|
let _ = list_client_names(&config);
|
||||||
|
let _ = list_all_models(&config);
|
||||||
|
}
|
||||||
|
|
||||||
struct TestConfigDirGuard {
|
struct TestConfigDirGuard {
|
||||||
key: String,
|
key: String,
|
||||||
previous: Option<std::ffi::OsString>,
|
previous: Option<std::ffi::OsString>,
|
||||||
@@ -8199,25 +8232,23 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn use_session_applies_persisted_mcp_tools_immediately() {
|
fn use_session_applies_persisted_mcp_tools_immediately() {
|
||||||
// Mirrors use_session's ordering hazard: the tool-scope rebuild runs
|
// use_session rebuilds the tool scope BEFORE `self.session` is
|
||||||
// before `self.session` is assigned, so a re-attached session's
|
// assigned, so a re-attached session's persisted allowlist is
|
||||||
// persisted map is only enforced by the post-assignment refresh.
|
// enforced only by the filter refresh that runs after the
|
||||||
// (Session::load_from_ctx needs live model resolution, so the
|
// assignment. Drive the real use_session against a session file on
|
||||||
// persisted session is round-tripped through serde directly.)
|
// disk to prove that refresh happens.
|
||||||
let _guard = TestConfigDirGuard::new();
|
let _guard = TestConfigDirGuard::new();
|
||||||
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||||
let mut persisted = Session::default();
|
let session_path = ctx.session_file("persisted");
|
||||||
persisted.set_mcp_tools(Some(gh_get_only_map()));
|
ensure_parent_exists(&session_path).unwrap();
|
||||||
let yaml = serde_yaml::to_string(&persisted).unwrap();
|
write(
|
||||||
let reloaded: Session = serde_yaml::from_str(&yaml).unwrap();
|
&session_path,
|
||||||
|
"model: test-seeded:test-chat\nmessages: []\nmcp_tools:\n gh:\n - get_*\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let app = ctx.app.config.clone();
|
||||||
|
|
||||||
run_async(ctx.refresh_tool_scope(utils::create_abort_signal())).unwrap();
|
run_async(ctx.use_session(&app, Some("persisted"), utils::create_abort_signal())).unwrap();
|
||||||
assert!(
|
|
||||||
!ctx.tool_scope.mcp_runtime.tool_filters.contains_key("gh"),
|
|
||||||
"the pre-assignment rebuild cannot see the session layer"
|
|
||||||
);
|
|
||||||
ctx.session = Some(reloaded);
|
|
||||||
ctx.refresh_mcp_tool_filters();
|
|
||||||
|
|
||||||
let filter = ctx
|
let filter = ctx
|
||||||
.tool_scope
|
.tool_scope
|
||||||
@@ -8229,6 +8260,45 @@ mod tests {
|
|||||||
assert!(!filter.allows("delete_repo"));
|
assert!(!filter.allows("delete_repo"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[serial]
|
||||||
|
fn use_rag_does_not_drop_role_filters() {
|
||||||
|
// Attaching a RAG rebuilds the tool scope from scratch, and the
|
||||||
|
// rebuilt McpRuntime starts with no filters — so the rebuild must
|
||||||
|
// recompute the declarative filter layers or the active role's
|
||||||
|
// allowlist silently disappears. Uses the yaml driver so the load
|
||||||
|
// stays on the local filesystem; the externally-backed attach path
|
||||||
|
// needs a live vector store and cannot run here, but it funnels
|
||||||
|
// through the same tool-scope refresh.
|
||||||
|
let _guard = TestConfigDirGuard::new();
|
||||||
|
let mut ctx = RequestContext::new(mcp_app_state(&["gh"]), WorkingMode::Cmd);
|
||||||
|
let mut role = Role::new("dev", "prompt");
|
||||||
|
role.set_mcp_tools(Some(gh_get_only_map()));
|
||||||
|
ctx.role = Some(role);
|
||||||
|
ctx.refresh_mcp_tool_filters();
|
||||||
|
assert!(ctx.tool_scope.mcp_runtime.tool_filters.contains_key("gh"));
|
||||||
|
|
||||||
|
let rag_path = ctx.rag_file("kb");
|
||||||
|
ensure_parent_exists(&rag_path).unwrap();
|
||||||
|
write(
|
||||||
|
&rag_path,
|
||||||
|
"driver: yaml\nembedding_model: test-seeded:test-embedder\nchunk_size: 512\nchunk_overlap: 64\ntop_k: 5\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
run_async(ctx.use_rag(Some("kb"), utils::create_abort_signal())).unwrap();
|
||||||
|
|
||||||
|
assert!(ctx.rag.is_some(), "the RAG must actually load");
|
||||||
|
let filter = ctx
|
||||||
|
.tool_scope
|
||||||
|
.mcp_runtime
|
||||||
|
.tool_filters
|
||||||
|
.get("gh")
|
||||||
|
.expect("attaching a RAG must not drop the role's filter layer");
|
||||||
|
assert!(filter.allows("get_issue"));
|
||||||
|
assert!(!filter.allows("delete_repo"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[serial]
|
#[serial]
|
||||||
fn use_agent_applies_agent_filters_without_a_session() {
|
fn use_agent_applies_agent_filters_without_a_session() {
|
||||||
|
|||||||
Reference in New Issue
Block a user