pmacs/tests/m9_6_acceptance.rs

1542 lines
51 KiB
Rust

// 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<String> = 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<String> = 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<String> = 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 <name> arg <field>: expected <type>, got "<value>"`.
#[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 <type>` 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::<bool>()
.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::<bool>()
.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<String> = 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<String> = 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<String> = 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<String> = 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 <name> 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<String> = 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<String> = 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<String> = 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;