1132 lines
37 KiB
Rust
1132 lines
37 KiB
Rust
// m9_8_acceptance.rs --- T M9.8 AI-assistance package acceptance.
|
|
|
|
//! Acceptance tests for T M9.8 (`spec/pmacs-tasks.tex:4022`):
|
|
//!
|
|
//! 1. Single-buffer context: command on a function selects the
|
|
//! function as context, sends a prompt, displays response.
|
|
//! 2. Multi-buffer context: command on a project selects relevant
|
|
//! files, sends a combined prompt, displays response.
|
|
//! 3. Configurable MCP server: changing the configured server
|
|
//! changes the model without code changes.
|
|
//!
|
|
//! Plus the architectural-claim tests:
|
|
//!
|
|
//! * Server pluggability: configure to server A, invoke; re-configure
|
|
//! to server B, invoke *same command*, verify the response now
|
|
//! comes from B. No code changes between the two invocations.
|
|
//! * Composition with M9.7: ai.* commands land their results in the
|
|
//! M9.7 `*mcp:<label>:<prompt>*` buffer namespace via the
|
|
//! `pmacs-mcp-prompts.render` public function (promoted from
|
|
//! internal on M9.8's request as the second consumer).
|
|
//!
|
|
//! M9.8 ships zero new public Rust APIs and zero new public
|
|
//! `pmacs.mcp.*` Lua functions. The package's only AI-domain
|
|
//! dependency is the configured MCP server it talks to; no model-
|
|
//! specific code. The M9.5 dispatcher → M9.6 → M9.7 → M9.8
|
|
//! composition chain validates "AI is a transport binding, not a
|
|
//! feature."
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use pmacs::editor::EditorState;
|
|
use pmacs::lua_bindings::PackageInstallOverride;
|
|
use tempfile::TempDir;
|
|
|
|
fn fake_mcp_path() -> String {
|
|
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
|
|
}
|
|
|
|
fn ai_package_path() -> PathBuf {
|
|
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
here.join("tests").join("fixtures").join("pmacs-mcp-ai")
|
|
}
|
|
|
|
fn prompts_package_path() -> PathBuf {
|
|
let here = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
here.join("tests")
|
|
.join("fixtures")
|
|
.join("pmacs-mcp-prompts")
|
|
}
|
|
|
|
/// Build an editor with the pmacs-mcp-prompts AND pmacs-mcp-ai
|
|
/// packages installed and require()d (the AI package depends on the
|
|
/// prompts package via require). Returns the editor state plus the
|
|
/// temp dirs that must outlive it.
|
|
fn editor_with_ai() -> (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 prompts_pkg = prompts_package_path().display().to_string();
|
|
let ai_pkg = ai_package_path().display().to_string();
|
|
let install = format!(
|
|
r#"
|
|
pmacs.packages.install_local("{prompts_pkg}")
|
|
pmacs.packages.install_local("{ai_pkg}")
|
|
_G.PROMPTS = require("pmacs-mcp-prompts")
|
|
_G.AI = require("pmacs-mcp-ai")
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("install-ai"), &install)
|
|
.unwrap_or_else(|e| panic!("install_local + require failed: {e}"));
|
|
(state, cache, user_root)
|
|
}
|
|
|
|
fn spawn_initialized_server_with_label(state: &mut EditorState, label: &str, slot: &str) {
|
|
let fake = fake_mcp_path();
|
|
state
|
|
.lua_host
|
|
.lua()
|
|
.load(format!(
|
|
"
|
|
_G.{slot} = pmacs.mcp.spawn({{
|
|
label = '{label}',
|
|
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(format!(
|
|
"
|
|
local out = {{}}
|
|
for _, row in ipairs(pmacs.mcp.list()) do
|
|
if row.label == '{label}' then out[#out+1] = row.state.kind end
|
|
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 with label {label} must reach Initialized"
|
|
);
|
|
}
|
|
|
|
fn spawn_initialized_server(state: &mut EditorState) {
|
|
spawn_initialized_server_with_label(state, "m9_8", "SERVER");
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn buffer_body(state: &mut EditorState, name: &str) -> Option<String> {
|
|
let body: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load(format!(
|
|
r#"
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "{name}" then
|
|
return id:slice(0, id:len())
|
|
end
|
|
end
|
|
return ""
|
|
"#
|
|
))
|
|
.eval()
|
|
.ok()?;
|
|
if body.is_empty() { None } else { Some(body) }
|
|
}
|
|
|
|
fn configure_default(state: &mut EditorState, server_label: &str) {
|
|
let cmd = format!(
|
|
r#"
|
|
_G.AI.configure {{
|
|
server_label = "{server_label}",
|
|
prompts = {{
|
|
fn = "review_function",
|
|
project = "review_project",
|
|
ask = "ask_freeform",
|
|
}},
|
|
}}
|
|
"#
|
|
);
|
|
state
|
|
.lua_host
|
|
.eval(Some("configure"), &cmd)
|
|
.expect("configure");
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Configure / re-configure / unconfigure lifecycle
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn m9_8_configure_defines_three_commands() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
let cmds: Vec<String> = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.command.list()")
|
|
.eval()
|
|
.expect("command.list");
|
|
for expected in ["ai.ask-about-function", "ai.ask-about-project", "ai.ask"] {
|
|
assert!(
|
|
cmds.iter().any(|c| c == expected),
|
|
"{expected} must be defined after configure; got {cmds:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn m9_8_unconfigure_drops_commands() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
state
|
|
.lua_host
|
|
.eval(Some("unconfigure"), "_G.AI.unconfigure()")
|
|
.expect("unconfigure");
|
|
|
|
let cmds: Vec<String> = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return pmacs.command.list()")
|
|
.eval()
|
|
.expect("command.list");
|
|
for unwanted in ["ai.ask-about-function", "ai.ask-about-project", "ai.ask"] {
|
|
assert!(
|
|
!cmds.iter().any(|c| c == unwanted),
|
|
"{unwanted} must be removed by unconfigure; got {cmds:?}"
|
|
);
|
|
}
|
|
let cfg_nil: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return _G.AI._config() == nil")
|
|
.eval()
|
|
.expect("config nil");
|
|
assert!(cfg_nil, "unconfigure must clear `_config`");
|
|
}
|
|
|
|
// `configure` with an empty `prompts = {}` table leaves the
|
|
// per-flow prompt slots unset. Each ai.* command must surface a
|
|
// helpful status naming the missing slot rather than failing
|
|
// opaquely.
|
|
#[test]
|
|
fn m9_8_invoke_with_missing_prompt_slot_surfaces_helpful_status() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("partial-configure"),
|
|
r#"_G.AI.configure { server_label = "m9_8", prompts = {} }"#,
|
|
)
|
|
.expect("partial configure");
|
|
state
|
|
.lua_host
|
|
.eval(Some("invoke"), r#"pmacs.command.invoke("ai.ask")"#)
|
|
.expect("invoke");
|
|
let status = state.core.borrow().status.clone();
|
|
assert!(
|
|
status.contains("no `prompts.ask`"),
|
|
"missing-prompt path must surface a helpful status; got {status:?}"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Bullet 1: single-buffer context (function selection via tree-sitter)
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn m9_8_ask_about_function_sends_function_context() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
// Create a Rust buffer with two functions; place cursor inside
|
|
// the second one. After parse, the enclosing-function walk must
|
|
// pick the second function's source as the context.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("setup-buffer"),
|
|
r#"
|
|
local source = "fn alpha() {\n let x: i32 = 1;\n}\n\nfn beta() {\n let y: i32 = 2;\n}\n"
|
|
local buf = pmacs.buffer.from_bytes("demo.rs", source)
|
|
pmacs.window.switch_buffer(buf)
|
|
pmacs.parse._parse_now(buf, "rust")
|
|
-- Place cursor inside `fn beta()`'s body. The "let y" line
|
|
-- starts at byte ~50; we just walk forward to land in the
|
|
-- function. Buffer-start is byte 0; move to a known offset
|
|
-- via the cursor seam isn't exposed, so step via move_down.
|
|
for _ = 1, 5 do pmacs.editor.move_down() end
|
|
for _ = 1, 4 do pmacs.editor.move_right() end
|
|
"#,
|
|
)
|
|
.expect("setup buffer");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke"),
|
|
r#"pmacs.command.invoke("ai.ask-about-function")"#,
|
|
)
|
|
.expect("invoke");
|
|
|
|
// Result lands in *mcp:m9_8:review_function* (the M9.7 buffer
|
|
// namespace, demonstrating composition).
|
|
let appeared = pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:review_function*" then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
assert!(appeared, "review_function result buffer must be created");
|
|
|
|
let body = buffer_body(&mut state, "*mcp:m9_8:review_function*").expect("read body");
|
|
assert!(
|
|
body.contains("fn beta()"),
|
|
"function context must include the enclosing function source; got {body:?}"
|
|
);
|
|
assert!(
|
|
!body.contains("fn alpha()"),
|
|
"context must be the *enclosing* function only — alpha must not leak in; got {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("demo.rs"),
|
|
"file_path arg must thread to the response; got {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn m9_8_ask_about_function_no_enclosing_node_surfaces_status() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
// Buffer with no functions at all. Cursor at start.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("setup-buffer"),
|
|
r#"
|
|
local buf = pmacs.buffer.from_bytes("empty.rs", "// just a comment\n")
|
|
pmacs.window.switch_buffer(buf)
|
|
pmacs.parse._parse_now(buf, "rust")
|
|
"#,
|
|
)
|
|
.expect("setup buffer");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke"),
|
|
r#"pmacs.command.invoke("ai.ask-about-function")"#,
|
|
)
|
|
.expect("invoke");
|
|
let status = state.core.borrow().status.clone();
|
|
assert!(
|
|
status.contains("no enclosing function"),
|
|
"no-enclosing-function path must surface a helpful status; got {status:?}"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Bullet 2: multi-buffer context (project files as structured array)
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn m9_8_ask_about_project_sends_files_array() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("open-buffers"),
|
|
r#"
|
|
-- Three file-backed buffers — should all be sent.
|
|
pmacs.buffer.from_bytes("a.rs", "fn a() {}")
|
|
pmacs.buffer.from_bytes("b.rs", "fn b() {}")
|
|
pmacs.buffer.from_bytes("c.lua", "function c() end")
|
|
-- A star-buffer that MUST be excluded (the Q2 rule).
|
|
pmacs.buffer.create("*help*")
|
|
"#,
|
|
)
|
|
.expect("open buffers");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke"),
|
|
r#"pmacs.command.invoke("ai.ask-about-project")"#,
|
|
)
|
|
.expect("invoke");
|
|
|
|
let appeared = pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:review_project*" then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
assert!(appeared, "review_project result buffer must be created");
|
|
|
|
let body = buffer_body(&mut state, "*mcp:m9_8:review_project*").expect("read body");
|
|
assert!(
|
|
body.contains("## a.rs") && body.contains("## b.rs") && body.contains("## c.lua"),
|
|
"all three file-backed buffers must appear in the project review body; got {body:?}"
|
|
);
|
|
assert!(
|
|
!body.contains("*help*"),
|
|
"star-buffers must be excluded from project context; got {body:?}"
|
|
);
|
|
assert!(
|
|
body.contains("fn a()") && body.contains("fn b()") && body.contains("function c()"),
|
|
"file contents must thread through; got {body:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn m9_8_collect_project_files_seam_excludes_star_buffers() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("open-buffers"),
|
|
r#"
|
|
pmacs.buffer.from_bytes("real.rs", "content")
|
|
pmacs.buffer.create("*help*")
|
|
pmacs.buffer.create("*scratch*")
|
|
pmacs.buffer.create("*mcp:foo:bar*")
|
|
"#,
|
|
)
|
|
.expect("open buffers");
|
|
|
|
let names: Vec<String> = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r"
|
|
local out = {}
|
|
for _, entry in ipairs(_G.AI._collect_project_files()) do
|
|
out[#out+1] = entry.path
|
|
end
|
|
return out
|
|
",
|
|
)
|
|
.eval()
|
|
.expect("collect");
|
|
|
|
assert!(
|
|
names.iter().any(|n| n == "real.rs"),
|
|
"file-backed buffer must be present; got {names:?}"
|
|
);
|
|
for star in ["*help*", "*scratch*", "*mcp:foo:bar*"] {
|
|
assert!(
|
|
!names.iter().any(|n| n == star),
|
|
"{star} must be excluded; got {names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Bullet 3: configurable server (the architecturally important test)
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn m9_8_server_pluggability_a_then_b_routes_command_without_code_changes() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
// Two distinct fake-MCP servers, different labels. Each fake will
|
|
// respond to `ask_freeform` echoing the question back; we route
|
|
// through the AI package's same `ai.ask` command and verify the
|
|
// result buffer for each runs against a *different* label-prefixed
|
|
// buffer name.
|
|
spawn_initialized_server_with_label(&mut state, "model_a", "SERVER_A");
|
|
spawn_initialized_server_with_label(&mut state, "model_b", "SERVER_B");
|
|
|
|
// Configure to A; invoke; expect *mcp:model_a:ask_freeform*.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("configure-a"),
|
|
r#"
|
|
_G.AI.configure {
|
|
server_label = "model_a",
|
|
prompts = { ask = "ask_freeform" },
|
|
}
|
|
pmacs.command.invoke("ai.ask")
|
|
pmacs.minibuffer.set_contents("hello A")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke A");
|
|
|
|
let landed_a = pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:model_a:ask_freeform*" then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
assert!(
|
|
landed_a,
|
|
"first invoke must land in the model_a-labelled result buffer"
|
|
);
|
|
|
|
// Re-configure to B; invoke the SAME COMMAND; expect a *new*
|
|
// result buffer at *mcp:model_b:ask_freeform*. Crucially: no code
|
|
// changes between the two ai.ask invocations — only configure.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("configure-b"),
|
|
r#"
|
|
_G.AI.configure {
|
|
server_label = "model_b",
|
|
prompts = { ask = "ask_freeform" },
|
|
}
|
|
pmacs.command.invoke("ai.ask")
|
|
pmacs.minibuffer.set_contents("hello B")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke B");
|
|
|
|
let landed_b = pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:model_b:ask_freeform*" then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
assert!(
|
|
landed_b,
|
|
"second invoke (after re-configure) must land in the model_b-labelled buffer — server pluggability"
|
|
);
|
|
|
|
// Both buffers exist concurrently — re-configure didn't kill the
|
|
// first result. The user can compare runs.
|
|
let a_still_there = buffer_body(&mut state, "*mcp:model_a:ask_freeform*").is_some();
|
|
assert!(
|
|
a_still_there,
|
|
"re-configure must not destroy the prior server's result buffer"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn m9_8_reconfigure_replaces_label_without_redefining_commands() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
// Snapshot command count before re-configure.
|
|
let count_before: i64 = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local n = 0
|
|
for _, c in ipairs(pmacs.command.list()) do
|
|
if c == "ai.ask-about-function" or c == "ai.ask-about-project" or c == "ai.ask" then
|
|
n = n + 1
|
|
end
|
|
end
|
|
return n
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("count before");
|
|
assert_eq!(count_before, 3, "all three ai.* commands must be defined");
|
|
|
|
// Re-configure with a different label.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("reconfigure"),
|
|
r#"_G.AI.configure { server_label = "m9_8_alt", prompts = { ask = "ask_freeform" } }"#,
|
|
)
|
|
.expect("reconfigure");
|
|
|
|
let count_after: i64 = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local n = 0
|
|
for _, c in ipairs(pmacs.command.list()) do
|
|
if c == "ai.ask-about-function" or c == "ai.ask-about-project" or c == "ai.ask" then
|
|
n = n + 1
|
|
end
|
|
end
|
|
return n
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("count after");
|
|
assert_eq!(
|
|
count_after, 3,
|
|
"re-configure must NOT redefine commands (still all three present)"
|
|
);
|
|
|
|
let cfg_label: String = state
|
|
.lua_host
|
|
.lua()
|
|
.load("return _G.AI._config().server_label")
|
|
.eval()
|
|
.expect("read label");
|
|
assert_eq!(
|
|
cfg_label, "m9_8_alt",
|
|
"re-configure must update the server label"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Composition: ai.* lands in M9.7's buffer namespace via M.render
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
#[allow(
|
|
clippy::too_many_lines,
|
|
reason = "two-path composition test (M9.7 path + M9.8 path) needs both setups inline; splitting hides the per-path side effects the test is verifying"
|
|
)]
|
|
fn m9_8_composes_with_m9_7_render_into_same_buffer() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
|
|
// Register M9.7's auto-prompt commands too, so the same prompt is
|
|
// reachable from both M9.8's `ai.ask` and M9.7's `m9_8-ask_freeform`.
|
|
state
|
|
.lua_host
|
|
.eval(Some("register-prompts"), "_G.PROMPTS.register(_G.SERVER)")
|
|
.expect("register");
|
|
pump_until_lua_pred(
|
|
&mut state,
|
|
"#_G.PROMPTS.commands_for(_G.SERVER) > 0",
|
|
Duration::from_secs(2),
|
|
);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
// Invoke via M9.7's auto-registered command first.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-via-m9_7"),
|
|
r#"
|
|
pmacs.command.invoke("m9_8-ask_freeform")
|
|
pmacs.minibuffer.set_contents("from m9_7 path")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke via m9_7");
|
|
pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:ask_freeform*" then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
|
|
let count_after_first: i64 = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local n = 0
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:ask_freeform*" then n = n + 1 end
|
|
end
|
|
return n
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("count after first");
|
|
assert_eq!(
|
|
count_after_first, 1,
|
|
"exactly one result buffer after first"
|
|
);
|
|
|
|
// Now invoke via M9.8's `ai.ask`. The result must land in the
|
|
// SAME buffer — M9.8 composes with M9.7's render, not parallel.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-via-m9_8"),
|
|
r#"
|
|
pmacs.command.invoke("ai.ask")
|
|
pmacs.minibuffer.set_contents("from m9_8 path")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke via m9_8");
|
|
// Wait for the M9.8-path response to land — body should contain
|
|
// "from m9_8 path", which proves the M9.8 invoke ran AND landed
|
|
// in the same buffer (not a new one).
|
|
let landed_m9_8 = pump_until_lua_pred(
|
|
&mut state,
|
|
r#"(function()
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:ask_freeform*" then
|
|
local body = id:slice(0, id:len())
|
|
if string.find(body, "from m9_8 path", 1, true) ~= nil then
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
return false
|
|
end)()"#,
|
|
Duration::from_secs(2),
|
|
);
|
|
assert!(
|
|
landed_m9_8,
|
|
"M9.8's ai.ask must repaint the same *mcp:m9_8:ask_freeform* buffer with the new response"
|
|
);
|
|
|
|
// After the second invoke settles, the count of matching buffers
|
|
// must still be exactly one — composition (not parallel).
|
|
let single_buffer: i64 = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local n = 0
|
|
for _, id in ipairs(pmacs.buffer.list()) do
|
|
if pmacs.describe.buffer(id).name == "*mcp:m9_8:ask_freeform*" then n = n + 1 end
|
|
end
|
|
return n
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("count single");
|
|
assert_eq!(
|
|
single_buffer, 1,
|
|
"M9.8's ai.ask must reuse the same buffer M9.7 created — composition (got {single_buffer} buffers)"
|
|
);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tree-sitter walk seam
|
|
// ===========================================================================
|
|
|
|
#[test]
|
|
fn m9_8_find_enclosing_function_seam_returns_deepest_match() {
|
|
let (state, _c, _u) = editor_with_ai();
|
|
|
|
let (kind, language): (String, String) = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local source = "fn outer() {\n fn inner() { let x = 1; }\n}\n"
|
|
local buf = pmacs.buffer.from_bytes("nested.rs", source)
|
|
pmacs.window.switch_buffer(buf)
|
|
pmacs.parse._parse_now(buf, "rust")
|
|
-- Byte position: the `let x = 1` text is around byte 30+.
|
|
-- find_enclosing_function should pick `inner`, not `outer`.
|
|
local target_text = "let x"
|
|
local source_text = source
|
|
local idx = string.find(source_text, target_text)
|
|
local node, lang = _G.AI._find_enclosing_function(buf, idx - 1)
|
|
if node == nil then return "nil", lang or "" end
|
|
return node:type(), lang
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("walk");
|
|
assert_eq!(kind, "function_item", "must find a Rust function_item");
|
|
assert_eq!(language, "rust", "must report rust as language");
|
|
|
|
// Verify it's the *inner* function by checking its byte range
|
|
// doesn't span the whole buffer.
|
|
let inner_only: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local source = "fn outer() {\n fn inner() { let x = 1; }\n}\n"
|
|
local buf = pmacs.window.buffer()
|
|
pmacs.parse._parse_now(buf, "rust")
|
|
local idx = string.find(source, "let x") - 1
|
|
local node = _G.AI._find_enclosing_function(buf, idx)
|
|
-- inner's range is shorter than the whole source.
|
|
return node:end_byte() - node:start_byte() < #source
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("inner");
|
|
assert!(inner_only, "deepest match must pick `inner`, not `outer`");
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Error paths
|
|
// ===========================================================================
|
|
|
|
// Configure to a label that no live server advertises. Commands
|
|
// register, but invoke can't resolve the label and surfaces a helpful
|
|
// status. The complementary "configured server vanishes mid-flight"
|
|
// path is exercised by `m9_8_server_gone_clears_config`.
|
|
#[test]
|
|
fn m9_8_invoke_with_bogus_server_label_surfaces_status() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("configure-bogus"),
|
|
r#"_G.AI.configure { server_label = "no-such-server", prompts = { ask = "ask_freeform" } }"#,
|
|
)
|
|
.expect("configure");
|
|
state
|
|
.lua_host
|
|
.eval(Some("invoke"), r#"pmacs.command.invoke("ai.ask")"#)
|
|
.expect("invoke");
|
|
let status = state.core.borrow().status.clone();
|
|
assert!(
|
|
status.contains("no MCP server with label"),
|
|
"missing-server path must surface a helpful status; got {status:?}"
|
|
);
|
|
}
|
|
|
|
// F2: When the configured server vanishes mid-dispatch (`get_prompt`
|
|
// fails with "unknown server" / "not ready for requests"), `dispatch`
|
|
// clears `_config` so subsequent invocations surface the
|
|
// configure-needed message rather than the same dead-server error on
|
|
// every retry. Pinned by stop'ing the server, then invoking ai.ask
|
|
// and asserting `_config()` is now nil.
|
|
#[test]
|
|
fn m9_8_server_gone_clears_config() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
assert!(
|
|
!state
|
|
.lua_host
|
|
.lua()
|
|
.load("return _G.AI._config() == nil")
|
|
.eval::<bool>()
|
|
.expect("config not nil precondition"),
|
|
"config must be set before stopping server"
|
|
);
|
|
|
|
// Stop the server and pump until the manager observes the exit.
|
|
state
|
|
.lua_host
|
|
.eval(Some("stop"), "pmacs.mcp.stop(_G.SERVER)")
|
|
.expect("stop server");
|
|
let stop = Instant::now() + Duration::from_secs(2);
|
|
while Instant::now() < stop {
|
|
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));
|
|
}
|
|
|
|
// Invoke ai.ask. Dispatch must observe server-gone and clear
|
|
// `_config`. Provide minibuffer input so ask_body's chain proceeds
|
|
// to dispatch (otherwise it just opens the minibuffer and waits).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke-after-stop"),
|
|
r#"
|
|
pmacs.command.invoke("ai.ask")
|
|
pmacs.minibuffer.set_contents("anything")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke after stop");
|
|
let cleared = pump_until_lua_pred(&mut state, "_G.AI._config() == nil", Duration::from_secs(2));
|
|
assert!(
|
|
cleared,
|
|
"server-gone error must clear _config so the next invoke prompts the user to reconfigure"
|
|
);
|
|
}
|
|
|
|
// F4: Boundary — tree-sitter `end_byte` is exclusive, but
|
|
// `find_enclosing` includes `byte_pos == end_byte` as enclosing.
|
|
// Cursor at the position just past the close-brace of a function
|
|
// still gets that function as context. Pinned so the boundary
|
|
// behavior is intentional, not accidental.
|
|
#[test]
|
|
fn m9_8_find_enclosing_at_end_byte_includes_node() {
|
|
let (state, _c, _u) = editor_with_ai();
|
|
|
|
let inclusive: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
local source = "fn alone() {}\n"
|
|
local buf = pmacs.buffer.from_bytes("end_byte.rs", source)
|
|
pmacs.window.switch_buffer(buf)
|
|
pmacs.parse._parse_now(buf, "rust")
|
|
local tree = pmacs.parse.tree(buf)
|
|
-- Walk to the function_item node and read its end_byte.
|
|
local function find(node)
|
|
if node:type() == "function_item" then return node end
|
|
for _, c in ipairs(node:children()) do
|
|
local found = find(c)
|
|
if found ~= nil then return found end
|
|
end
|
|
return nil
|
|
end
|
|
local fn_node = find(tree:root())
|
|
local eb = fn_node:end_byte()
|
|
-- Probe `_find_enclosing_function` AT eb (inclusive boundary).
|
|
local node, _, fail_kind = _G.AI._find_enclosing_function(buf, eb)
|
|
return fail_kind == nil and node ~= nil and node:type() == "function_item"
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("inclusive boundary probe");
|
|
assert!(
|
|
inclusive,
|
|
"cursor at exact end_byte must still be considered enclosing (right edge inclusive)"
|
|
);
|
|
}
|
|
|
|
// F5: When the buffer has no parse view yet, the seam returns
|
|
// `(nil, nil, "no_tree")` and the body surfaces a distinct status
|
|
// message. Without this distinction, a cursor in an unparsed buffer
|
|
// got "no enclosing function at cursor" — misleading because the
|
|
// cursor may have been right inside a function whose syntax tree
|
|
// just hadn't been built yet.
|
|
#[test]
|
|
fn m9_8_unparsed_buffer_surfaces_buffer_not_parsed_status() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
configure_default(&mut state, "m9_8");
|
|
|
|
// Create a buffer but do NOT call `_parse_now` — no parse tree
|
|
// exists for it.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("setup-unparsed"),
|
|
r#"
|
|
local buf = pmacs.buffer.from_bytes("never_parsed.rs", "fn x() {}")
|
|
pmacs.window.switch_buffer(buf)
|
|
"#,
|
|
)
|
|
.expect("setup unparsed");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke"),
|
|
r#"pmacs.command.invoke("ai.ask-about-function")"#,
|
|
)
|
|
.expect("invoke");
|
|
let status = state.core.borrow().status.clone();
|
|
assert!(
|
|
status.contains("buffer not parsed yet"),
|
|
"no-parse-tree path must surface a distinct status; got {status:?}"
|
|
);
|
|
}
|
|
|
|
// F1: Project payload size warning. When the collected files exceed
|
|
// the soft warning threshold, the package emits a notify so the user
|
|
// knows they're about to send a large payload. Collection still
|
|
// proceeds — the threshold is informational, not a hard cap.
|
|
#[test]
|
|
fn m9_8_oversized_project_payload_emits_size_warning() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
|
|
// Install a stub `pmacs.error` to capture the warning routed
|
|
// through the `notify` helper (M9.6 finding 10 carry-forward
|
|
// pattern: warnings hit both set_status and pmacs.error).
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("install-error-stub"),
|
|
r"
|
|
_G.PMACS_ERROR_LOG = {}
|
|
pmacs.error = function(msg)
|
|
_G.PMACS_ERROR_LOG[#_G.PMACS_ERROR_LOG + 1] = msg
|
|
end
|
|
",
|
|
)
|
|
.expect("install error stub");
|
|
|
|
// Lower the threshold so the test can produce an "oversized"
|
|
// payload with a few small buffers — keeps the test fast and
|
|
// doesn't depend on the package's default-threshold value.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("lower-threshold"),
|
|
r"_G.AI._PROJECT_PAYLOAD_WARN_BYTES = 64",
|
|
)
|
|
.expect("lower threshold");
|
|
|
|
// Two buffers whose combined content + paths comfortably exceed
|
|
// the lowered threshold.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("open-buffers"),
|
|
r#"
|
|
pmacs.buffer.from_bytes("file_one.rs", string.rep("a", 60))
|
|
pmacs.buffer.from_bytes("file_two.rs", string.rep("b", 60))
|
|
"#,
|
|
)
|
|
.expect("open buffers");
|
|
|
|
state
|
|
.lua_host
|
|
.eval(Some("collect"), "_G.AI._collect_project_files()")
|
|
.expect("collect");
|
|
|
|
let logged: bool = state
|
|
.lua_host
|
|
.lua()
|
|
.load(
|
|
r#"
|
|
for _, m in ipairs(_G.PMACS_ERROR_LOG) do
|
|
if string.find(m, "warning threshold", 1, true) ~= nil then
|
|
return true
|
|
end
|
|
end
|
|
return false
|
|
"#,
|
|
)
|
|
.eval()
|
|
.expect("scan log");
|
|
assert!(
|
|
logged,
|
|
"oversized project payload must surface a size warning through pmacs.error"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn m9_8_configured_prompt_missing_on_server_surfaces_error() {
|
|
let (mut state, _c, _u) = editor_with_ai();
|
|
spawn_initialized_server(&mut state);
|
|
// Configure a prompt name that the fake server doesn't advertise.
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("configure-bogus-prompt"),
|
|
r#"_G.AI.configure { server_label = "m9_8", prompts = { ask = "no_such_prompt" } }"#,
|
|
)
|
|
.expect("configure");
|
|
state
|
|
.lua_host
|
|
.eval(
|
|
Some("invoke"),
|
|
r#"
|
|
pmacs.command.invoke("ai.ask")
|
|
pmacs.minibuffer.set_contents("anything")
|
|
pmacs.minibuffer.accept()
|
|
"#,
|
|
)
|
|
.expect("invoke");
|
|
// The fake returns -32602 unknown prompt; ai dispatch surfaces it
|
|
// as "ai no_such_prompt error: ...".
|
|
let stop = Instant::now() + Duration::from_secs(2);
|
|
let mut saw_err = false;
|
|
while Instant::now() < stop {
|
|
state.tick_processes();
|
|
state.tick_mcp();
|
|
state.tick_async();
|
|
if state.core.borrow().status.contains("unknown prompt") {
|
|
saw_err = true;
|
|
break;
|
|
}
|
|
std::thread::sleep(Duration::from_millis(15));
|
|
}
|
|
assert!(
|
|
saw_err,
|
|
"missing-prompt-on-server path must surface the JSON-RPC error message; got status={:?}",
|
|
state.core.borrow().status
|
|
);
|
|
}
|
|
|
|
// 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;
|