pmacs/tests/m9_4_acceptance.rs

504 lines
17 KiB
Rust

// m9_4_acceptance.rs --- T M9.4 prompt resolution acceptance.
//! Acceptance tests for T M9.4 (`spec/pmacs-tasks.tex:3951`):
//!
//! 1. Prompt resolution returns the expected template-with-args.
//! 2. Required args missing produce a clear error.
//! 3. Prompts with no arguments are also supported.
//!
//! Plus the wire-shape verification called out during the M9.4
//! design review:
//!
//! 4. Empty-args call sends `arguments: {}` on the wire (not
//! omitted, not `null`). The fake's prompt-record mechanism
//! writes each request's params to disk; the test parses the
//! JSON and asserts the shape.
use std::cell::RefCell;
use std::rc::Rc;
use std::time::{Duration, Instant};
use pmacs::async_runtime::{AsyncRuntime, JobId, JobOutcome, JobResult, SharedAsyncRuntime};
use pmacs::lua_bindings::SharedProcessSupervisor;
use pmacs::mcp::{
McpClientState, McpEvent, McpEventKind, McpManager, McpRestartPolicy, McpServerId,
McpServerSpec, SharedMcpManager,
};
use pmacs::process::ProcessSupervisor;
fn fake_mcp_path() -> String {
env!("CARGO_BIN_EXE_pmacs_fake_mcp").to_owned()
}
fn make_test_triple() -> (
SharedProcessSupervisor,
SharedAsyncRuntime,
SharedMcpManager,
) {
let sup = Rc::new(RefCell::new(ProcessSupervisor::new()));
let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1));
let mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone())));
(sup, runtime, mgr)
}
fn fake_spec(label: &str) -> McpServerSpec {
let mut spec = McpServerSpec::new(label, fake_mcp_path());
spec.restart = McpRestartPolicy::Never;
spec
}
fn pump_until<F: FnMut() -> bool>(
sup: &SharedProcessSupervisor,
runtime: &SharedAsyncRuntime,
mgr: &SharedMcpManager,
deadline: Duration,
mut pred: F,
) {
let stop = Instant::now() + deadline;
while Instant::now() < stop {
sup.borrow_mut().tick();
mgr.borrow_mut().tick();
runtime.tick();
if pred() {
return;
}
std::thread::sleep(Duration::from_millis(15));
}
}
fn drain_mcp_until<F: Fn(&[McpEvent]) -> bool>(
sup: &SharedProcessSupervisor,
runtime: &SharedAsyncRuntime,
mgr: &SharedMcpManager,
sid: McpServerId,
deadline: Duration,
pred: F,
) -> Vec<McpEvent> {
let stop = Instant::now() + deadline;
let mut all: Vec<McpEvent> = Vec::new();
while Instant::now() < stop {
sup.borrow_mut().tick();
mgr.borrow_mut().tick();
runtime.tick();
let mut evs = mgr.borrow_mut().take_events(sid);
all.append(&mut evs);
if pred(&all) {
return all;
}
std::thread::sleep(Duration::from_millis(15));
}
all
}
fn spawn_initialized(
sup: &SharedProcessSupervisor,
runtime: &SharedAsyncRuntime,
mgr: &SharedMcpManager,
spec: McpServerSpec,
) -> McpServerId {
let sid = mgr.borrow_mut().spawn(spec).expect("spawn");
drain_mcp_until(sup, runtime, mgr, sid, Duration::from_secs(5), |evs| {
evs.iter()
.any(|e| matches!(e.kind, McpEventKind::Initialized { .. }))
});
assert!(matches!(
mgr.borrow().state(sid),
Some(McpClientState::Initialized { .. })
));
sid
}
fn await_job(
sup: &SharedProcessSupervisor,
runtime: &SharedAsyncRuntime,
mgr: &SharedMcpManager,
job_id: JobId,
deadline: Duration,
) -> JobOutcome {
pump_until(sup, runtime, mgr, deadline, || runtime.is_complete(job_id));
runtime
.take_result(job_id)
.unwrap_or_else(|| panic!("job {job_id} did not settle within deadline"))
}
// ===========================================================================
// Bullet 1: round-trip with args
// ===========================================================================
/// Bullet 1: `get_prompt` with required args returns messages
/// referencing those args (the fake threads them through so the
/// test can verify they were transmitted intact).
#[test]
fn m9_4_get_prompt_round_trip_with_args() {
let (sup, runtime, mgr) = make_test_triple();
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-args"));
let job = mgr
.borrow_mut()
.get_prompt(
sid,
"code_review",
serde_json::json!({
"language": "rust",
"source": "fn main() {}",
}),
)
.expect("get_prompt");
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
let result = match outcome {
JobOutcome::Complete(JobResult::Json(v)) => v,
other => panic!("expected Complete(Json(...)), got {other:?}"),
};
let description = result
.get("description")
.and_then(|v| v.as_str())
.expect("description field");
assert!(
description.contains("rust"),
"description must contain the language arg; got {description:?}"
);
let text = result
.get("messages")
.and_then(|m| m.as_array())
.and_then(|arr| arr.first())
.and_then(|m| m.get("content"))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
.expect("messages[0].content.text");
assert!(
text.contains("rust") && text.contains("fn main() {}"),
"message text must contain both args; got {text:?}"
);
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Bullet 2: missing required arg
// ===========================================================================
/// Bullet 2: `get_prompt` without a required arg produces a Lua
/// error via the standard JSON-RPC `Failed` path. The error
/// message includes the server's `-32602 missing required argument:
/// <name>` text.
#[test]
fn m9_4_missing_required_arg_produces_jsonrpc_error() {
let (sup, runtime, mgr) = make_test_triple();
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-missing"));
// code_review requires {language, source}; omit source.
let job = mgr
.borrow_mut()
.get_prompt(
sid,
"code_review",
serde_json::json!({ "language": "rust" }),
)
.expect("get_prompt");
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
let msg = match outcome {
JobOutcome::Failed(m) => m,
other => panic!("expected Failed; got {other:?}"),
};
assert!(
msg.contains("missing required argument: source"),
"Failed message must contain the server's missing-arg text; got {msg:?}"
);
assert!(
msg.contains("-32602"),
"Failed message should include the JSON-RPC error code; got {msg:?}"
);
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Bullet 3: no-args prompt
// ===========================================================================
/// Bullet 3: prompts with no required arguments resolve cleanly
/// when called with an empty args object.
#[test]
fn m9_4_no_args_prompt_resolves() {
let (sup, runtime, mgr) = make_test_triple();
let sid = spawn_initialized(&sup, &runtime, &mgr, fake_spec("prompt-noargs"));
let job = mgr
.borrow_mut()
.get_prompt(sid, "simple", serde_json::json!({}))
.expect("get_prompt");
let outcome = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
let result = match outcome {
JobOutcome::Complete(JobResult::Json(v)) => v,
other => panic!("expected Complete(Json(...)), got {other:?}"),
};
let text = result
.get("messages")
.and_then(|m| m.as_array())
.and_then(|arr| arr.first())
.and_then(|m| m.get("content"))
.and_then(|c| c.get("text"))
.and_then(|t| t.as_str())
.expect("messages[0].content.text");
assert_eq!(text, "no-args prompt body");
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Wire-shape verification (design-review addition)
// ===========================================================================
/// The MCP spec requires `arguments` even when there are no
/// arguments — sending it as `{}` (empty object), not `null` and
/// not omitting the field. This test verifies the wire shape by
/// having the fake record each prompts/get's params to disk;
/// the test reads the recorded JSON and asserts `arguments` is
/// present and an empty object.
#[test]
fn m9_4_no_args_wire_shape_is_empty_object() {
let tmp = tempfile::tempdir().expect("tmpdir");
let record_dir = tmp.path().to_owned();
let (sup, runtime, mgr) = make_test_triple();
let mut spec = fake_spec("prompt-wire");
spec.env = vec![(
"PMACS_FAKE_MCP_PROMPT_RECORD_DIR".into(),
record_dir.to_string_lossy().into_owned(),
)];
let sid = spawn_initialized(&sup, &runtime, &mgr, spec);
// No-args call.
let job = mgr
.borrow_mut()
.get_prompt(sid, "simple", serde_json::json!({}))
.expect("get_prompt");
let _ = await_job(&sup, &runtime, &mgr, job, Duration::from_secs(5));
// Find the recorded params file.
let mut entries: Vec<_> = std::fs::read_dir(&record_dir)
.expect("read record dir")
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.starts_with("prompt-"))
})
.collect();
entries.sort_by_key(std::fs::DirEntry::file_name);
assert_eq!(
entries.len(),
1,
"fake should have recorded exactly one prompts/get; got {} files",
entries.len()
);
let bytes = std::fs::read(entries[0].path()).expect("read recorded params");
let params: serde_json::Value = serde_json::from_slice(&bytes).expect("parse recorded params");
let arguments = params
.get("arguments")
.expect("params.arguments must be present (not omitted)");
assert!(
arguments.is_object(),
"params.arguments must be an object, not null/array/string; got {arguments:?}"
);
assert_eq!(
arguments.as_object().unwrap().len(),
0,
"params.arguments must be empty {{}}; got {arguments:?}"
);
let _ = mgr.borrow_mut().stop(sid);
}
// ===========================================================================
// Lua surface
// ===========================================================================
/// `pmacs.mcp.get_prompt(server, name, args):await()` resolves to
/// the response's `result` table. Three call patterns (no third
/// arg, nil third arg, explicit empty table) all produce the same
/// wire request and identical result.
#[test]
#[allow(
clippy::too_many_lines,
reason = "linear pump-coroutine-then-verify pattern; splitting fragments the test's narrative"
)]
fn m9_4_lua_get_prompt_returns_awaitable_handle() {
use pmacs::editor::EditorState;
let mut state = EditorState::new_with_roots(&crate::iso::roots());
let fake = fake_mcp_path();
state
.lua_host
.lua()
.load(format!(
"
_G._mcp_test_server = pmacs.mcp.spawn({{
label = 'lua-prompt',
command = '{fake}',
restart = 'never',
}})
",
))
.exec()
.expect("spawn via Lua");
// Pump until Initialized.
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, "server must reach Initialized");
// Three call forms, captured into separate globals. All three
// should produce identical result tables.
state
.lua_host
.lua()
.load(
"
_G._mcp_prompt_done = 0
_G._mcp_prompt_text_a = nil
_G._mcp_prompt_text_b = nil
_G._mcp_prompt_text_c = nil
pmacs.async(function()
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple'):await()
_G._mcp_prompt_text_a = r.messages[1].content.text
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
end)
pmacs.async(function()
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', nil):await()
_G._mcp_prompt_text_b = r.messages[1].content.text
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
end)
pmacs.async(function()
local r = pmacs.mcp.get_prompt(_G._mcp_test_server, 'simple', {}):await()
_G._mcp_prompt_text_c = r.messages[1].content.text
_G._mcp_prompt_done = _G._mcp_prompt_done + 1
end)
",
)
.exec()
.expect("dispatch coroutines");
let stop = Instant::now() + Duration::from_secs(5);
let mut done = 0i64;
while Instant::now() < stop && done < 3 {
state.tick_processes();
state.tick_mcp();
state.tick_async();
done = state
.lua_host
.lua()
.load("return _G._mcp_prompt_done")
.eval::<i64>()
.unwrap_or(0);
if done < 3 {
std::thread::sleep(Duration::from_millis(15));
}
}
assert_eq!(done, 3, "all three coroutines must complete");
let texts: Vec<Option<String>> = state
.lua_host
.lua()
.load("return { _G._mcp_prompt_text_a, _G._mcp_prompt_text_b, _G._mcp_prompt_text_c }")
.eval()
.expect("read texts");
assert_eq!(
texts,
vec![
Some("no-args prompt body".to_owned()),
Some("no-args prompt body".to_owned()),
Some("no-args prompt body".to_owned())
],
"all three call forms must produce the same result"
);
// Failure path: missing required arg must raise a Lua error
// visible through pcall.
state
.lua_host
.lua()
.load(
"
_G._mcp_prompt_done2 = false
_G._mcp_prompt_failed2 = nil
pmacs.async(function()
local ok, err = pcall(function()
return pmacs.mcp.get_prompt(_G._mcp_test_server, 'code_review',
{ language = 'rust' }):await()
end)
_G._mcp_prompt_done2 = true
_G._mcp_prompt_failed2 = (not ok) and err or nil
end)
",
)
.exec()
.expect("dispatch failure-path coroutine");
let stop = Instant::now() + Duration::from_secs(5);
let mut done2 = false;
while Instant::now() < stop && !done2 {
state.tick_processes();
state.tick_mcp();
state.tick_async();
done2 = state
.lua_host
.lua()
.load("return _G._mcp_prompt_done2")
.eval::<bool>()
.unwrap_or(false);
if !done2 {
std::thread::sleep(Duration::from_millis(15));
}
}
assert!(done2, "failure-path coroutine must complete");
let message: String = state
.lua_host
.lua()
.load(
"
local err = _G._mcp_prompt_failed2
if err == nil then return '<no error>' end
if type(err) == 'table' then return tostring(err.message or '<no message>') end
return tostring(err)
",
)
.eval()
.expect("read error message");
assert!(
message.contains("missing required argument: source"),
"failure-path coroutine must observe the server's missing-arg text; got {message:?}"
);
let _ = state
.lua_host
.lua()
.load("pmacs.mcp.stop(_G._mcp_test_server)")
.exec();
}
// 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;