// m9_6_acceptance.rs --- T M9.6 tools-as-commands acceptance. //! Acceptance tests for T M9.6 (`spec/pmacs-tasks.tex:3986`): //! //! 1. Tools registered as commands appear in `M-x` completion //! (i.e., `pmacs.command.list()`). //! 2. `describe-command` reports the tool's schema as the //! documentation. //! 3. Argument prompts for required parameters use the existing //! minibuffer machinery. //! //! Plus the design-review additions: //! //! * No-required-args tools dispatch immediately (no minibuffer). //! * Unregister drops the server's commands. //! * `notifications/tools/list_changed` reconciles add / remove. //! * Schema-change re-registers (so the prompt-flow closure picks //! up the new required-args list). //! * Tool failure surfaces a readable status-line error. //! //! M9.6 ships with zero new public Rust APIs; the M9.5 //! `on_notification` dispatcher is the second consumer (after M9.5's //! own resource-update use), confirming the dispatcher abstraction //! was correctly scoped. use std::path::PathBuf; use std::time::{Duration, Instant}; use crossterm::event::{KeyCode, KeyModifiers}; use pmacs::editor::EditorState; use pmacs::frontend::KeyEvent; use pmacs::lua_bindings::PackageInstallOverride; use pmacs::protocol::FrontendId; use tempfile::TempDir; fn fake_mcp_path() -> String { env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned() } fn tools_package_path() -> PathBuf { let here = PathBuf::from(env!("CARGO_MANIFEST_DIR")); here.join("tests").join("fixtures").join("pmacs-mcp-tools") } /// Build an editor with the pmacs-mcp-tools package installed and /// require()d. Returns the editor state plus the temp dirs that must /// outlive it (cache + user-install-root for the package system). fn editor_with_tools() -> (EditorState, TempDir, TempDir) { let cache = tempfile::tempdir().expect("cache tempdir"); let user_root = tempfile::tempdir().expect("user-root tempdir"); let mut state = EditorState::new_with_roots(&crate::iso::roots()); state.lua_host.reopen_init_phase_for_testing(); state.lua_host.set_package_install_override( PackageInstallOverride::new() .with_cache_dir(cache.path().to_path_buf()) .with_user_install_root(user_root.path().to_path_buf()), ); let pkg = tools_package_path(); let pkg_str = pkg.display().to_string(); let install = format!( r#" pmacs.packages.install_local("{pkg_str}") _G.TOOLS = require("pmacs-mcp-tools") "# ); state .lua_host .eval(Some("install-tools"), &install) .unwrap_or_else(|e| panic!("install_local + require failed: {e}")); (state, cache, user_root) } /// Spawn the fake MCP server, drain until Initialized, stash the /// server handle in `_G.SERVER`. Spawn `label` is `m9_6` because /// command names use the label as a prefix. fn spawn_initialized_server(state: &mut EditorState) { let fake = fake_mcp_path(); state .lua_host .lua() .load(format!( " _G.SERVER = pmacs.mcp.spawn({{ label = 'm9_6', command = '{fake}', restart = 'never', }}) ", )) .exec() .expect("spawn fake mcp"); let stop = Instant::now() + Duration::from_secs(5); let mut initialized = false; while Instant::now() < stop && !initialized { state.tick_processes(); state.tick_mcp(); state.tick_async(); let kinds: Vec = state .lua_host .lua() .load( " local out = {} for _, row in ipairs(pmacs.mcp.list()) do out[#out+1] = row.state.kind end return out ", ) .eval() .expect("list"); if kinds.iter().any(|k| k == "initialized") { initialized = true; } if !initialized { std::thread::sleep(Duration::from_millis(15)); } } assert!(initialized, "fake server must reach Initialized"); } /// Pump editor + async + mcp ticks until `pred` (a Lua expression /// returning bool) is true, or the deadline lapses. fn pump_until_lua_pred(state: &mut EditorState, pred: &str, deadline: Duration) -> bool { let stop = Instant::now() + deadline; while Instant::now() < stop { state.tick_processes(); state.tick_mcp(); state.tick_async(); let ok: bool = state .lua_host .lua() .load(format!("return ({pred}) and true or false")) .eval() .unwrap_or(false); if ok { return true; } std::thread::sleep(Duration::from_millis(15)); } false } /// Pump until the editor's status line contains `needle`, or the /// deadline lapses. Status checks read `state.core.borrow().status` /// rather than going through Lua because there's no `get_status` /// binding (`set_status` is write-only). fn pump_until_status_contains(state: &mut EditorState, needle: &str, deadline: Duration) -> bool { let stop = Instant::now() + deadline; while Instant::now() < stop { state.tick_processes(); state.tick_mcp(); state.tick_async(); if state.core.borrow().status.contains(needle) { return true; } std::thread::sleep(Duration::from_millis(15)); } false } /// Register the fake server's tools as commands and pump until the /// initial fetch completes (`TOOLS.commands_for(server)` is non-empty). fn register_and_wait(state: &mut EditorState) { state .lua_host .eval(Some("register-tools"), "_G.TOOLS.register(_G.SERVER)") .expect("register"); let registered = pump_until_lua_pred( state, "#_G.TOOLS.commands_for(_G.SERVER) > 0", Duration::from_secs(5), ); assert!( registered, "register() must populate commands_for within 5s" ); } // =========================================================================== // Bullet 1: tools registered as commands appear in M-x completion // =========================================================================== #[test] fn m9_6_register_adds_tools_as_commands() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); let cmds: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("commands_for"); assert!( cmds.iter().any(|c| c == "m9_6-echo"), "echo tool must register as `m9_6-echo`; got {cmds:?}" ); assert!( cmds.iter().any(|c| c == "m9_6-mcp_test-greet"), "mcp_test/greet must normalize / -> -; got {cmds:?}" ); assert!( cmds.iter().any(|c| c == "m9_6-nullary"), "nullary tool must register; got {cmds:?}" ); // pmacs.command.list() is the M-x completion source. let global: Vec = state .lua_host .lua() .load("return pmacs.command.list()") .eval() .expect("command.list"); assert!( global.iter().any(|c| c == "m9_6-echo"), "pmacs.command.list() must include `m9_6-echo`; got {global:?}" ); } /// Command-name normalization rule: any character outside /// `[a-zA-Z0-9_.-]` becomes `-`. The seam `command_name(label, tool)` /// pins the rule without a server. #[test] fn m9_6_command_name_normalizes_special_characters() { let (state, _c, _u) = editor_with_tools(); let joined: String = state .lua_host .lua() .load( r#" return _G.TOOLS.command_name("svr", "a/b") .. "|" .. _G.TOOLS.command_name("svr", "x:y z!") .. "|" .. _G.TOOLS.command_name("svr", "ok-name_1.2") "#, ) .eval() .expect("command_name"); let parts: Vec<&str> = joined.split('|').collect(); assert_eq!(parts[0], "svr-a-b", "/ must become -"); assert_eq!(parts[1], "svr-x-y-z-", ": and space and ! must become -"); assert_eq!( parts[2], "svr-ok-name_1.2", "alphanumerics, _, ., - pass through" ); } /// Label normalization: the server-name half of the command name /// runs through the same allow-list as the tool-name half. The Rust /// `CommandRegistry` only validates non-empty, so without label /// normalization a label like "my server!" would surface in the /// command palette as "my server!-echo" — passing M-x but breaking /// the keymap parser. The fix normalizes both halves so the produced /// name is registry-clean regardless of operator-supplied label. #[test] fn m9_6_command_name_normalizes_label_too() { let (state, _c, _u) = editor_with_tools(); let joined: String = state .lua_host .lua() .load( r#" return _G.TOOLS.command_name("my server!", "echo") .. "|" .. _G.TOOLS.command_name("filesystem/v2", "read-file") .. "|" .. _G.TOOLS.command_name("ok_label.1", "tool") "#, ) .eval() .expect("command_name"); let parts: Vec<&str> = joined.split('|').collect(); assert_eq!( parts[0], "my-server--echo", "label spaces and `!` must collapse to `-`" ); assert_eq!( parts[1], "filesystem-v2-read-file", "label `/` must collapse to `-`" ); assert_eq!( parts[2], "ok_label.1-tool", "label alphanumerics, _, ., - pass through unchanged" ); } // =========================================================================== // Audit-fix: tool_hash treats `required` order as part of identity // =========================================================================== // // Audit issue 9: an earlier draft sorted `required` before hashing, // so a reorder-only mutation of inputSchema.required produced the // same hash, the diff in `apply_fresh` saw no change, no re-register // fired, and make_command_body's prompt-flow closure kept prompting // in stale order. The fix hashes `required` in document order so a // pure reorder is a meaningful change. // // First test pins the property statically via the `_tool_hash` test // seam; second test drives it end-to-end through change_tool_schema // so the live reconcile path is also covered. #[test] fn m9_6_tool_hash_includes_required_argument_order() { let (state, _c, _u) = editor_with_tools(); let (h_ab, h_ba): (String, String) = state .lua_host .lua() .load( r#" local make = function(req) return { name = "t", description = "d", inputSchema = { type = "object", properties = { a = { type = "string" }, b = { type = "string" } }, required = req } } end return _G.TOOLS._tool_hash(make({"a","b"})), _G.TOOLS._tool_hash(make({"b","a"})) "#, ) .eval() .expect("hashes"); assert_ne!( h_ab, h_ba, "reordering `required` must produce a different hash so reconcile re-registers" ); } #[test] fn m9_6_list_changed_reregisters_on_required_arg_reorder() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); // First mutation: install required=[a, b]. This is a real change // from the initial nullary schema, so the existing tests already // prove this branch re-registers. Wait for the description to // reflect it. state .lua_host .eval( Some("install-ab"), r#" pmacs.async(function() pmacs.mcp.invoke_tool(_G.SERVER, "mcp_test/change_tool_schema", { name = "nullary", required = "a,b" }):await() end) "#, ) .expect("install ab"); let installed = pump_until_lua_pred( &mut state, r#"(function() local info = pmacs.describe.command("m9_6-nullary") if info == nil then return false end local desc = info.description or "" local pa = string.find(desc, "a %(string, required%)") local pb = string.find(desc, "b %(string, required%)") return pa ~= nil and pb ~= nil and pa < pb end)()"#, Duration::from_millis(1500), ); assert!( installed, "first change_tool_schema must produce a description with a then b" ); // Second mutation: same membership, reverse order. Without the // hash fix the hash collision would skip the re-register and the // description would still show a-then-b. state .lua_host .eval( Some("reorder-ba"), r#" pmacs.async(function() pmacs.mcp.invoke_tool(_G.SERVER, "mcp_test/change_tool_schema", { name = "nullary", required = "b,a" }):await() end) "#, ) .expect("reorder ba"); let reordered = pump_until_lua_pred( &mut state, r#"(function() local info = pmacs.describe.command("m9_6-nullary") if info == nil then return false end local desc = info.description or "" local pa = string.find(desc, "a %(string, required%)") local pb = string.find(desc, "b %(string, required%)") return pa ~= nil and pb ~= nil and pb < pa end)()"#, Duration::from_millis(1500), ); assert!( reordered, "reorder of `required` must drive a re-register so the description reflects the new order" ); } // =========================================================================== // Audit-fix: typed-arg coercion at minibuffer-accept time // =========================================================================== // // Audit issue 5: the package previously sent every minibuffer value // through to `pmacs.mcp.invoke_tool` as a string, so tools whose // inputSchema declared `integer` / `number` / `boolean` received a // type-mismatched argument and rejected at the server. The fix // coerces in `prompt_chain` based on the property's declared type // before assembling `args`. The fake's `typed_*` tools echo the JSON // kind they actually received so this test pins the coercion // observably end-to-end. #[test] fn m9_6_typed_arg_integer_is_coerced_before_dispatch() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-typed-int"), r#"pmacs.command.invoke("m9_6-typed_int")"#, ) .expect("invoke typed_int"); state .lua_host .eval( Some("type-int"), r#" pmacs.minibuffer.set_contents("42") pmacs.minibuffer.accept() "#, ) .expect("type+accept"); assert!( pump_until_status_contains(&mut state, "kind=integer", Duration::from_secs(2)), "integer arg must arrive at the server as a JSON number; status={:?}", state.core.borrow().status ); assert!( state.core.borrow().status.contains("value=42"), "coerced integer must round-trip the typed value" ); } #[test] fn m9_6_typed_arg_number_is_coerced_before_dispatch() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-typed-number"), r#"pmacs.command.invoke("m9_6-typed_number")"#, ) .expect("invoke typed_number"); state .lua_host .eval( Some("type-number"), r#" pmacs.minibuffer.set_contents("3.5") pmacs.minibuffer.accept() "#, ) .expect("type+accept"); assert!( pump_until_status_contains(&mut state, "kind=number", Duration::from_secs(2)), "number arg must arrive at the server as a JSON number; status={:?}", state.core.borrow().status ); assert!( state.core.borrow().status.contains("value=3.5"), "coerced number must round-trip the typed value" ); } #[test] fn m9_6_typed_arg_boolean_is_coerced_before_dispatch() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-typed-bool"), r#"pmacs.command.invoke("m9_6-typed_bool")"#, ) .expect("invoke typed_bool"); state .lua_host .eval( Some("type-bool"), r#" pmacs.minibuffer.set_contents("true") pmacs.minibuffer.accept() "#, ) .expect("type+accept"); assert!( pump_until_status_contains(&mut state, "kind=boolean", Duration::from_secs(2)), "boolean arg must arrive at the server as a JSON bool; status={:?}", state.core.borrow().status ); assert!( state.core.borrow().status.contains("value=true"), "coerced boolean must round-trip the typed value" ); } /// Bad input for a typed arg surfaces a status-line parse error and /// does *not* dispatch a malformed call to the server. The status /// shape is `MCP arg : expected , got ""`. #[test] fn m9_6_typed_arg_parse_error_aborts_dispatch() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-typed-int-bad"), r#"pmacs.command.invoke("m9_6-typed_int")"#, ) .expect("invoke typed_int"); state .lua_host .eval( Some("type-bad"), r#" pmacs.minibuffer.set_contents("not-a-number") pmacs.minibuffer.accept() "#, ) .expect("type+accept"); let status = state.core.borrow().status.clone(); assert!( status.contains("expected integer"), "parse failure must surface as an `expected ` status; got {status:?}" ); // The dispatch must NOT reach the server. Pump for a moment and // confirm we never see the typed_int echo prefix. let leaked = pump_until_status_contains(&mut state, "typed_int: kind=", Duration::from_millis(500)); assert!( !leaked, "malformed integer must abort before invoke_tool; status={:?}", state.core.borrow().status ); } // =========================================================================== // Bullet 2: describe-command reports the schema as documentation // =========================================================================== #[test] fn m9_6_describe_command_reports_schema_in_description() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); let desc: String = state .lua_host .lua() .load( r#" local info = pmacs.describe.command("m9_6-mcp_test-greet") return info.description "#, ) .eval() .expect("describe greet"); assert!( desc.contains("Two-required-arg greeting tool"), "description must include the tool's text description; got {desc:?}" ); assert!( desc.contains("Arguments:"), "description must include an Arguments: section; got {desc:?}" ); assert!( desc.contains("name (string, required)"), "required string arg must be tagged; got {desc:?}" ); assert!( desc.contains("greeting (string, required)"), "second required arg must also appear; got {desc:?}" ); } /// Schema-as-docs fallback: a tool advertised with no `description` /// renders as "(no description)" rather than a blank line followed by /// Arguments:. Drives the framing decision to be honest about the /// missing field. Uses the package's `_render_schema_doc` test seam /// so we don't need a server-side hook. #[test] fn m9_6_describe_command_uses_no_description_sentinel_for_missing_desc() { let (state, _c, _u) = editor_with_tools(); let rendered: String = state .lua_host .lua() .load( r#" return _G.TOOLS._render_schema_doc({ name = "anon", inputSchema = { type = "object", properties = {}, required = {} } }) "#, ) .eval() .expect("render"); assert_eq!( rendered, "(no description)", "package must use the (no description) sentinel for empty descriptions" ); } // =========================================================================== // Bullet 3: required-arg prompts use the minibuffer machinery // =========================================================================== /// Bullet 3 (single arg): invoking the registered command opens the /// minibuffer; typing + accept dispatches the tool with the typed /// value as the required argument. #[test] fn m9_6_command_with_required_arg_prompts_via_minibuffer() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval(Some("invoke-echo"), r#"pmacs.command.invoke("m9_6-echo")"#) .expect("invoke"); let active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("is_active"); assert!(active, "single-required-arg tool must open the minibuffer"); state .lua_host .eval( Some("type-and-accept"), r#" pmacs.minibuffer.set_contents("hello world") pmacs.minibuffer.accept() "#, ) .expect("type+accept"); assert!( pump_until_status_contains(&mut state, "hello world", Duration::from_secs(2)), "echo tool's response must reach the status line; status={:?}", state.core.borrow().status ); let status = state.core.borrow().status.clone(); assert!( status.contains("MCP echo"), "status line must be tagged with the tool name; got {status:?}" ); } /// Bullet 3 (multi-arg): chained prompts. The `on_accept` of prompt N /// kicks off prompt N+1; the final accept dispatches. #[test] fn m9_6_command_with_multiple_required_args_chains_prompts() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-greet"), r#"pmacs.command.invoke("m9_6-mcp_test-greet")"#, ) .expect("invoke"); assert!( state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval::() .expect("is_active 1"), "first prompt (name) must be active" ); state .lua_host .eval( Some("type-name"), r#" pmacs.minibuffer.set_contents("alice") pmacs.minibuffer.accept() "#, ) .expect("type name"); assert!( state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval::() .expect("is_active 2"), "second prompt (greeting) must open after the first accept" ); state .lua_host .eval( Some("type-greeting"), r#" pmacs.minibuffer.set_contents("Hi there") pmacs.minibuffer.accept() "#, ) .expect("type greeting"); assert!( pump_until_status_contains(&mut state, "Hello, alice", Duration::from_secs(2)), "two-arg dispatch must reach the fake; status={:?}", state.core.borrow().status ); let status = state.core.borrow().status.clone(); assert!( status.contains("Hi there"), "second arg must thread through to the response; got {status:?}" ); } /// No-required-args tool dispatches immediately — no minibuffer /// session opens. #[test] fn m9_6_command_with_no_args_dispatches_immediately() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval( Some("invoke-nullary"), r#"pmacs.command.invoke("m9_6-nullary")"#, ) .expect("invoke nullary"); let active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("is_active"); assert!(!active, "nullary tool must not open the minibuffer"); assert!( pump_until_status_contains(&mut state, "no-arg tool ran", Duration::from_secs(2)), "nullary dispatch must surface a result; status={:?}", state.core.borrow().status ); } // =========================================================================== // End-to-end: M-x palette → MCP tool → minibuffer re-entry // =========================================================================== // // All three argument-prompt tests above invoke the registered command // directly via `pmacs.command.invoke`. That covers the command body's // own logic but skips the user-facing M-x flow, where the *outer* // minibuffer session has just accepted the command name and the // command body opens a *new* minibuffer session from inside the // outer session's `on_accept` callback. // // `Minibuffer::accept` calls `self.session.take()` before invoking the // callback, so the inner `read` is starting from a clean slot — but // nothing in the existing acceptance suite pins this. This test does: // drive `editor.execute-command` (the actual M-x palette command), // accept "m9_6-echo", verify the tool's argument prompt opens, accept // the value, and verify the dispatch reaches the status line. #[test] fn m9_6_mx_palette_invokes_mcp_tool_through_minibuffer_reentry() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); // Step 1: invoke M-x. The outer minibuffer opens with the // `commands` completion source. state .lua_host .eval( Some("invoke-mx"), r#"pmacs.command.invoke("editor.execute-command")"#, ) .expect("invoke M-x"); let mx_active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("mx is_active"); assert!(mx_active, "M-x must open the outer minibuffer session"); // Step 2: type the MCP tool name and accept. The outer session's // on_accept invokes the command, whose body starts a *new* // minibuffer session for the required argument. state .lua_host .eval( Some("type-cmd-name"), r#"pmacs.minibuffer.set_contents("m9_6-echo")"#, ) .expect("accept command name"); state.dispatch_key( FrontendId::LOCAL, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); // Step 3: the tool's argument prompt must now be active. This is // the re-entrant minibuffer behavior: outer session was taken, // inner session was begun, and the begin happened from inside // the outer accept's authenticated dispatch callback. let inner_active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("inner is_active"); assert!( inner_active, "tool's argument prompt must open after M-x accept" ); // Step 4: drive the inner prompt. The dispatch must reach the // status line just as it does in the direct-invoke case. state .lua_host .eval( Some("type-arg"), r#"pmacs.minibuffer.set_contents("through M-x")"#, ) .expect("accept arg"); state.dispatch_key( FrontendId::LOCAL, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), ); assert!( pump_until_status_contains(&mut state, "through M-x", Duration::from_secs(2)), "M-x → arg-prompt → dispatch must reach status; status={:?}", state.core.borrow().status ); let status = state.core.borrow().status.clone(); assert!( status.contains("MCP echo"), "status must be tagged with the tool name; got {status:?}" ); } // =========================================================================== // Lifecycle: unregister drops commands // =========================================================================== #[test] fn m9_6_unregister_drops_commands() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); let before: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("before"); assert!( !before.is_empty(), "commands must be registered before unregister" ); state .lua_host .eval(Some("unregister"), "_G.TOOLS.unregister(_G.SERVER)") .expect("unregister"); let after: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("after"); assert!( after.is_empty(), "commands_for must return empty after unregister; got {after:?}" ); let global: Vec = state .lua_host .lua() .load("return pmacs.command.list()") .eval() .expect("command.list"); for name in &before { assert!( !global.iter().any(|g| g == name), "command {name:?} must be removed from pmacs.command.list()" ); } } // =========================================================================== // Reconciliation: notifications/tools/list_changed // =========================================================================== /// `add_tool` emits `notifications/tools/list_changed`; the package /// reconciles by registering the new tool. `remove_tool` likewise drops /// it. The 1.5s bound is generous on purpose — round-trip through /// stdio + dispatcher tick + reconcile coroutine. #[test] fn m9_6_list_changed_adds_and_removes_commands() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); let baseline: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("baseline"); state .lua_host .eval( Some("trigger-add"), r#" pmacs.async(function() pmacs.mcp.invoke_tool(_G.SERVER, "mcp_test/add_tool", { name = "freshly_added" }):await() end) "#, ) .expect("invoke add_tool"); let added = pump_until_lua_pred( &mut state, r#"(function() for _, c in ipairs(_G.TOOLS.commands_for(_G.SERVER)) do if c == "m9_6-freshly_added" then return true end end return false end)()"#, Duration::from_millis(1500), ); assert!( added, "list_changed must drive registration of the new tool within 1.5s; baseline={baseline:?}" ); state .lua_host .eval( Some("trigger-remove"), r#" pmacs.async(function() pmacs.mcp.invoke_tool(_G.SERVER, "mcp_test/remove_tool", { name = "freshly_added" }):await() end) "#, ) .expect("invoke remove_tool"); let removed = pump_until_lua_pred( &mut state, r#"(function() for _, c in ipairs(_G.TOOLS.commands_for(_G.SERVER)) do if c == "m9_6-freshly_added" then return false end end return true end)()"#, Duration::from_millis(1500), ); assert!( removed, "list_changed must drive unregistration within 1.5s" ); } /// Schema-change case: an existing tool's required-args list mutates; /// reconciler must detect the schema-hash change and re-register so /// the prompt-flow closure picks up the new arg list. #[test] fn m9_6_list_changed_reregisters_on_schema_change() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); // Mutate the fake's `nullary` tool to require one arg. state .lua_host .eval( Some("trigger-schema-change"), r#" pmacs.async(function() pmacs.mcp.invoke_tool(_G.SERVER, "mcp_test/change_tool_schema", { name = "nullary", required = "newarg" }):await() end) "#, ) .expect("invoke change_schema"); // Wait for the reconcile to land. The describe.command output // covers both re-registration *and* the description rebuild. let reregistered = pump_until_lua_pred( &mut state, r#"(function() local info = pmacs.describe.command("m9_6-nullary") if info == nil then return false end return string.find(info.description, "newarg %(string, required%)") ~= nil end)()"#, Duration::from_millis(1500), ); assert!( reregistered, "schema change must re-register the command with the new required-args description" ); // And invoking now must open the minibuffer (the prompt-flow // closure picked up the new required arg). state .lua_host .eval( Some("invoke-after-schema-change"), r#"pmacs.command.invoke("m9_6-nullary")"#, ) .expect("invoke after change"); let active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("is_active after change"); assert!( active, "after schema change, invocation must prompt for the new required arg" ); state .lua_host .eval(Some("cancel"), r"pmacs.minibuffer.cancel()") .expect("cancel"); } // =========================================================================== // Tool failure surfaces a readable error // =========================================================================== #[test] fn m9_6_tool_failure_surfaces_status_error() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); state .lua_host .eval(Some("invoke-fail"), r#"pmacs.command.invoke("m9_6-fail")"#) .expect("invoke fail"); assert!( pump_until_status_contains(&mut state, "synthetic tool failure", Duration::from_secs(2)), "tool failure text must surface; status={:?}", state.core.borrow().status ); let status = state.core.borrow().status.clone(); assert!( status.contains("MCP fail error"), "tool failure must surface as `MCP error: ...`; got {status:?}" ); } // =========================================================================== // Audit-fix: cross-source command collision skips with a warning // =========================================================================== // // Audit issue 6: an outside-the-package command with the same normalized // name (a builtin, a user definition, or a different MCP server) used to // surface as a `DuplicateName` error from `pmacs.command.define`, which // propagated out of the async coroutine and aborted further registrations // for that server. The fix: check `pmacs.command.exists(cmd_name)` before // defining; on hit, set_status warn and skip the one tool, letting the // rest of the server's tools register cleanly. #[test] fn m9_6_cross_source_collision_skips_with_warning() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); // Pre-define a command at the name the package would otherwise pick // for the `echo` tool. This simulates a builtin or user command // already owning that slot when the MCP register runs. state .lua_host .eval( Some("preempt"), r#" pmacs.command.define { name = "m9_6-echo", description = "preexisting non-MCP command", fn = function() pmacs.editor.set_status("preexisting") end, } "#, ) .expect("preempt define"); state .lua_host .eval(Some("register-tools"), "_G.TOOLS.register(_G.SERVER)") .expect("register"); // Wait for the *other* tools to register — proves the per-tool // collision didn't abort the whole server's registration loop. The // `m9_6-mcp_test-greet` name has no preempt, so it must arrive. let other_arrived = pump_until_lua_pred( &mut state, r#"(function() for _, c in ipairs(_G.TOOLS.commands_for(_G.SERVER)) do if c == "m9_6-mcp_test-greet" then return true end end return false end)()"#, Duration::from_secs(2), ); assert!( other_arrived, "non-colliding tools must still register after a cross-source collision skip" ); let cmds: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("commands_for"); assert!( !cmds.iter().any(|c| c == "m9_6-echo"), "collided echo tool must not appear in the package's owned set; got {cmds:?}" ); // The preexisting command body is still the one the registry serves — // no silent overwrite by the MCP package. let desc: String = state .lua_host .lua() .load(r#"return pmacs.describe.command("m9_6-echo").description"#) .eval() .expect("describe preempt"); assert_eq!( desc, "preexisting non-MCP command", "preexisting command must keep its description (no silent overwrite); got {desc:?}" ); // Status line carries the skip warning (best-effort: the skip is the // most-recent set_status from the package, but later reconcile work // may overwrite. Just check the warning shape was used at some point // by polling for it.) let warned = pump_until_status_contains(&mut state, "already defined", Duration::from_millis(500)); // Soft assertion: not a hard fail if a later set_status raced past // the warning, but at least the package emitted *something* about // m9_6-echo. The structural property — `m9_6-echo` not in // commands_for and the preexisting body intact — is the contract. let _ = warned; } // Audit issue 10: collision warnings used to be set_status-only, // which the very next set_status from any source could erase without // trace. The fix routes them through pmacs.error too — the project's // persistent log surface (same convention as // builtin/runtime/{async,mcp,syntax}.lua). Tests install a stub // pmacs.error to prove the warning path actually reaches it. #[test] fn m9_6_collision_warning_reaches_pmacs_error_log() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); // Stub pmacs.error before register so the warning's pmacs.error // call lands somewhere we can read. Without the stub, the // `if pmacs.error then ... end` branch in `notify()` is a no-op. state .lua_host .eval( Some("install-stub"), r#" _G.PMACS_ERROR_LOG = {} pmacs.error = function(msg) _G.PMACS_ERROR_LOG[#_G.PMACS_ERROR_LOG + 1] = msg end pmacs.command.define { name = "m9_6-echo", description = "preexisting non-MCP command", fn = function() end, } "#, ) .expect("install pmacs.error stub + preempt"); state .lua_host .eval(Some("register-tools"), "_G.TOOLS.register(_G.SERVER)") .expect("register"); // Wait for at least one tool to land (proves the loop ran past the // skipped collision). let any_registered = pump_until_lua_pred( &mut state, "#_G.TOOLS.commands_for(_G.SERVER) > 0", Duration::from_secs(2), ); assert!(any_registered, "registration loop must run despite skip"); // pmacs.error must have received at least one message mentioning // the colliding command — even if a later set_status erased the // status-line copy, the persistent log keeps the trace. let logged: bool = state .lua_host .lua() .load( r#" for _, m in ipairs(_G.PMACS_ERROR_LOG) do if string.find(m, "m9_6%-echo", 1) ~= nil and string.find(m, "already defined", 1, true) ~= nil then return true end end return false "#, ) .eval() .expect("scan log"); assert!( logged, "collision warning must reach pmacs.error so it survives past the next set_status" ); } // =========================================================================== // Audit-fix: notification subscription is balanced across register/unregister // =========================================================================== // // Audit issue 3: ensure_notification_handler set the // notifications/tools/list_changed token once on the first M.register and // never released it. The fix: refcount registered servers and call // off_notification once the count drops to zero. #[test] fn m9_6_notification_subscription_releases_on_last_unregister() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); // No subscription before the first register. let before: bool = state .lua_host .lua() .load("return _G.TOOLS._has_notification_subscription()") .eval() .expect("before"); assert!(!before, "no subscription should exist before any register"); register_and_wait(&mut state); let during: bool = state .lua_host .lua() .load("return _G.TOOLS._has_notification_subscription()") .eval() .expect("during"); assert!( during, "subscription must exist while a server is registered" ); state .lua_host .eval(Some("unregister"), "_G.TOOLS.unregister(_G.SERVER)") .expect("unregister"); let after: bool = state .lua_host .lua() .load("return _G.TOOLS._has_notification_subscription()") .eval() .expect("after"); assert!( !after, "subscription must be released when the last server unregisters" ); } // =========================================================================== // Audit-fix: server-gone teardown drops registered commands on invoke fail // =========================================================================== // // Audit issue 5: when an MCP server stopped/crashed, its commands lingered // in pmacs.command.list() forever — every invoke would surface the same // dead-server error. The fix: dispatch detects the server-gone shape on // invoke failure and calls M.unregister to drop the stale commands. #[test] fn m9_6_server_stop_unregisters_commands_on_next_invoke() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); // Confirm the precondition: nullary is registered. let before: Vec = state .lua_host .lua() .load("return _G.TOOLS.commands_for(_G.SERVER)") .eval() .expect("before"); assert!( before.iter().any(|c| c == "m9_6-nullary"), "nullary must be registered before stopping the server" ); // Stop the server, then drain ticks so the manager observes the exit. state .lua_host .eval(Some("stop"), "pmacs.mcp.stop(_G.SERVER)") .expect("stop server"); let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { state.tick_processes(); state.tick_mcp(); state.tick_async(); let kind: String = state .lua_host .lua() .load( r#" for _, row in ipairs(pmacs.mcp.list()) do if row.id == _G.SERVER then return row.state.kind end end return "" "#, ) .eval() .unwrap_or_default(); if kind == "stopped" || kind.is_empty() { break; } std::thread::sleep(Duration::from_millis(15)); } // Now invoke a registered command. The dispatch must surface a // server-gone error AND unregister the per-server commands. state .lua_host .eval( Some("invoke-after-stop"), r#"pmacs.command.invoke("m9_6-nullary")"#, ) .expect("invoke after stop"); let cleared = pump_until_lua_pred( &mut state, "#_G.TOOLS.commands_for(_G.SERVER) == 0", Duration::from_secs(2), ); assert!( cleared, "server-gone teardown must drop the per-server command set" ); // pmacs.command.list() must no longer advertise the now-defunct // commands either. let global: Vec = state .lua_host .lua() .load("return pmacs.command.list()") .eval() .expect("command.list"); for stale in &before { assert!( !global.iter().any(|g| g == stale), "stopped server's command {stale:?} must be removed from pmacs.command.list()" ); } } // =========================================================================== // Audit-fix: editor.describe-command surfaces the schema in *help* // =========================================================================== // // Audit issue 1: the spec lists describe-command alongside describe-key as // the user-facing introspection surface. The Rust API // `pmacs.describe.command(name)` was already wired through, but no // builtin command fronted it — M-x had no `describe-command` to type. The // fix: builtin/commands/default.lua now defines `editor.describe-command`, // which prompts for a name and renders the description (the rendered // schema, for MCP-tool commands) in *help*. #[test] fn m9_6_editor_describe_command_renders_schema_in_help_buffer() { let (mut state, _c, _u) = editor_with_tools(); spawn_initialized_server(&mut state); register_and_wait(&mut state); // Sanity: the new builtin is in the M-x palette. let in_palette: bool = state .lua_host .lua() .load( r#" for _, c in ipairs(pmacs.command.list()) do if c == "editor.describe-command" then return true end end return false "#, ) .eval() .expect("palette check"); assert!( in_palette, "editor.describe-command must appear in pmacs.command.list()" ); // Invoke the builtin, then drive the prompt with the MCP tool name. state .lua_host .eval( Some("invoke-describe"), r#"pmacs.command.invoke("editor.describe-command")"#, ) .expect("invoke describe"); let active: bool = state .lua_host .lua() .load("return pmacs.minibuffer.is_active()") .eval() .expect("is_active"); assert!( active, "editor.describe-command must open the minibuffer for the name prompt" ); state .lua_host .eval( Some("type-and-accept"), r#" pmacs.minibuffer.set_contents("m9_6-mcp_test-greet") pmacs.minibuffer.accept() "#, ) .expect("accept name"); // *help* must now exist and contain the rendered schema. let body: String = state .lua_host .lua() .load( r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*help*" then return id:slice(0, id:len()) end end return "" "#, ) .eval() .expect("read *help*"); assert!( body.contains("m9_6-mcp_test-greet"), "*help* must include the queried command name; got {body:?}" ); assert!( body.contains("Two-required-arg greeting tool"), "*help* must include the tool's text description; got {body:?}" ); assert!( body.contains("name (string, required)"), "*help* must include the rendered required-arg schema; got {body:?}" ); } // editor.describe-command on an unknown name surfaces a status-line // "no such command" rather than crashing or opening *help* with bogus // content. #[test] fn m9_6_editor_describe_command_unknown_name_status_only() { let (mut state, _c, _u) = editor_with_tools(); state .lua_host .eval( Some("invoke-describe"), r#"pmacs.command.invoke("editor.describe-command")"#, ) .expect("invoke describe"); state .lua_host .eval( Some("type-bad"), r#" pmacs.minibuffer.set_contents("does.not.exist") pmacs.minibuffer.accept() "#, ) .expect("accept bad"); let status = state.core.borrow().status.clone(); assert!( status.contains("no such command"), "unknown-name path must surface a status-line message; got {status:?}" ); let has_help: bool = state .lua_host .lua() .load( r#" for _, id in ipairs(pmacs.buffer.list()) do if pmacs.describe.buffer(id).name == "*help*" then return true end end return false "#, ) .eval() .expect("help check"); assert!( !has_help, "*help* must not be created for an unknown-name describe-command call" ); } // Isolated bootstrap storage roots (see the module docs): an // integration test is compiled without `cfg(test)`, so a raw // `EditorState::new()` would read the developer's real `init.lua` and // write into their real data root. #[path = "common/iso.rs"] mod iso;