// 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 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 bool>( sup: &SharedProcessSupervisor, runtime: &SharedAsyncRuntime, mgr: &SharedMcpManager, sid: McpServerId, deadline: Duration, pred: F, ) -> Vec { let stop = Instant::now() + deadline; let mut all: Vec = 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: /// ` 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 = 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::() .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> = 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::() .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 '' end if type(err) == 'table' then return tostring(err.message or '') 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;