// m9_1_acceptance.rs --- T M9.1 MCP worker variant acceptance. //! Acceptance tests for T M9.1 (`spec/pmacs-tasks.tex:3891`): //! //! 1. MCP server supervised through the same supervisor as LSP. //! 2. Initial `initialize` handshake completes; the server's //! declared capabilities are discoverable through the worker. //! 3. Server crash and restart are handled identically to LSP. //! //! Plus the framing-claim cross-check called out during the M9.1 //! design review: the supervisor is protocol-agnostic, which is the //! load-bearing property behind the "no new dispatch path" claim. //! `lsp_and_mcp_can_coexist_on_one_supervisor` is the explicit test //! for that — multiple MCPs is the easy case, LSP+MCP exercises the //! protocol-agnostic property. use std::cell::RefCell; use std::rc::Rc; use std::time::{Duration, Instant}; use pmacs::async_runtime::{AsyncRuntime, JobOutcome, JobResult, SharedAsyncRuntime}; use pmacs::lsp::{LspEventKind, LspManager, LspRestartPolicy, LspServerSpec}; 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 fake_lsp_path() -> String { env!("CARGO_BIN_EXE_pmacs_fake_lsp").to_owned() } fn make_test_triple() -> ( SharedProcessSupervisor, SharedAsyncRuntime, SharedMcpManager, ) { let sup = Rc::new(RefCell::new(ProcessSupervisor::new())); // Pool of 1 is enough — MCP requests don't dispatch through the // pool (Pass-2 finding 1 wires response delivery via // register_external + complete_external_*), so the pool is // sized for tests that may want to mix MCP with another worker // dispatch in the same harness. 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) } /// Drain MCP events until `pred` is satisfied or the deadline lapses. /// Mirrors `drain_lsp_until` from the M4 acceptance tests; also /// drives the async runtime's tick so externally-settled jobs are /// observable to callers that `take_result` on a `JobId`. 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 fake_spec(label: &str) -> McpServerSpec { let mut spec = McpServerSpec::new(label, fake_mcp_path()); spec.restart = McpRestartPolicy::Never; spec } // =========================================================================== // Acceptance bullet 2: initialize handshake + capability discovery // =========================================================================== /// The handshake completes and the server's declared capabilities /// surface through the manager. #[test] fn m9_1_initialize_handshake_completes_and_capabilities_discoverable() { let (sup, runtime, mgr) = make_test_triple(); let sid = mgr.borrow_mut().spawn(fake_spec("init")).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); let caps = evs .iter() .find_map(|e| match &e.kind { McpEventKind::Initialized { capabilities } => Some(capabilities.clone()), _ => None, }) .expect("must observe Initialized event"); assert!(caps.is_object(), "capabilities should be a JSON object"); // The fake server advertises resources, tools, and prompts. assert!( caps.get("resources").is_some(), "fake mcp must advertise resources, got {caps}" ); assert!( caps.get("tools").is_some(), "fake mcp must advertise tools, got {caps}" ); assert!( caps.get("prompts").is_some(), "fake mcp must advertise prompts, got {caps}" ); // State surface mirrors event surface. let state = mgr.borrow().state(sid).cloned(); assert!(matches!(state, Some(McpClientState::Initialized { .. }))); // Capabilities also reachable via the worker-level getter (M9.1 // acceptance: "discoverable through the worker"). let mgr_caps = mgr .borrow() .capabilities(sid) .cloned() .expect("capabilities must be discoverable"); assert_eq!( mgr_caps, caps, "manager capabilities must match the Initialized event's" ); // Protocol version was echoed back. Pass-2 finding 3: pmacs // sends 2025-11-25 (the latest revision) and the fake server // echoes whatever the client sent. assert_eq!(mgr.borrow().protocol_version(sid), Some("2025-11-25")); let _ = mgr.borrow_mut().stop(sid); } #[test] fn m9_1_server_info_surfaces_after_initialize() { let (sup, runtime, mgr) = make_test_triple(); let sid = mgr.borrow_mut().spawn(fake_spec("info")).expect("spawn"); drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); let info = mgr .borrow() .server_info(sid) .cloned() .expect("serverInfo must be reported"); assert_eq!( info.get("name").and_then(|v| v.as_str()), Some("pmacs-fake-mcp") ); let _ = mgr.borrow_mut().stop(sid); } // =========================================================================== // Acceptance bullet 3: crash + restart parity with LSP // =========================================================================== /// `OnCrash` policy respawns after the fake server's `exit 7`. The /// observable signal is two `Started` events bracketing a `Crashed` /// + `Restarting` — exactly the LSP test's shape. #[test] fn m9_1_server_crash_auto_restarts() { let (sup, runtime, mgr) = make_test_triple(); mgr.borrow_mut() .set_restart_backoff(Duration::from_millis(50)); let mut spec = fake_spec("crasher"); spec.restart = McpRestartPolicy::OnCrash; spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| { evs.iter() .filter(|e| matches!(e.kind, McpEventKind::Started { .. })) .count() >= 2 }); let started_count = evs .iter() .filter(|e| matches!(e.kind, McpEventKind::Started { .. })) .count(); assert!( started_count >= 2, "OnCrash policy should respawn after the fake MCP exits non-zero; saw Started count {started_count}" ); assert!( evs.iter() .any(|e| matches!(e.kind, McpEventKind::Crashed { .. })), "must observe Crashed event after the fake MCP's exit 7" ); assert!( evs.iter() .any(|e| matches!(e.kind, McpEventKind::Restarting { .. })), "must observe Restarting event when policy is OnCrash" ); assert!( mgr.borrow().attempt(sid).unwrap_or(0) >= 2, "attempt count should be >= 2 after at least one restart" ); let _ = mgr.borrow_mut().stop(sid); } /// After a crash + restart, the new generation re-runs `initialize`. /// Two `Initialized` events prove the handshake replays, which is /// the LSP-parity claim in concrete form. #[test] fn m9_1_restart_reruns_initialize_handshake() { let (sup, runtime, mgr) = make_test_triple(); mgr.borrow_mut() .set_restart_backoff(Duration::from_millis(50)); let mut spec = fake_spec("re-init"); spec.restart = McpRestartPolicy::OnCrash; spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "crash".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(10), |evs| { evs.iter() .filter(|e| matches!(e.kind, McpEventKind::Initialized { .. })) .count() >= 2 }); let init_count = evs .iter() .filter(|e| matches!(e.kind, McpEventKind::Initialized { .. })) .count(); assert!( init_count >= 2, "must see Initialized for the original generation and the restart; saw {init_count}" ); let _ = mgr.borrow_mut().stop(sid); } // =========================================================================== // Acceptance bullet 1: same supervisor as LSP // (multi-server coexistence; LSP+MCP cross-protocol) // =========================================================================== /// Two MCP servers spawned simultaneously through the same manager /// reach `Initialized` independently. This is the easy case — both /// peers speak the same wire format. #[test] fn m9_1_multiple_mcps_coexist_on_one_manager() { let (sup, runtime, mgr) = make_test_triple(); let a = mgr.borrow_mut().spawn(fake_spec("a")).expect("spawn a"); let b = mgr.borrow_mut().spawn(fake_spec("b")).expect("spawn b"); drain_mcp_until(&sup, &runtime, &mgr, a, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); drain_mcp_until(&sup, &runtime, &mgr, b, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); assert!(matches!( mgr.borrow().state(a), Some(McpClientState::Initialized { .. }) )); assert!(matches!( mgr.borrow().state(b), Some(McpClientState::Initialized { .. }) )); let _ = mgr.borrow_mut().stop(a); let _ = mgr.borrow_mut().stop(b); } /// **The protocol-uniformity test.** An LSP server and an MCP server /// share one [`ProcessSupervisor`]. Both reach `Initialized`. Neither /// observes events from the other's process. If this test passes /// without special wiring, the supervisor is genuinely /// protocol-agnostic and the spec's "no new dispatch path" claim /// holds. A failure would be a real M9.1 finding worth surfacing. #[test] fn m9_1_lsp_and_mcp_can_coexist_on_one_supervisor() { let sup = Rc::new(RefCell::new(ProcessSupervisor::new())); let runtime: SharedAsyncRuntime = Rc::new(AsyncRuntime::with_pool_size(1)); let lsp_mgr = Rc::new(RefCell::new(LspManager::new(sup.clone()))); let mcp_mgr = Rc::new(RefCell::new(McpManager::new(sup.clone(), runtime.clone()))); // Spin up one LSP and one MCP through the same supervisor. let mut lsp_spec = LspServerSpec::new("co-lsp", "rust", fake_lsp_path()); lsp_spec.restart = LspRestartPolicy::Never; let lsp_sid = lsp_mgr.borrow_mut().spawn(lsp_spec).expect("spawn lsp"); let mcp_sid = mcp_mgr .borrow_mut() .spawn(fake_spec("co-mcp")) .expect("spawn mcp"); // Drive both for up to 5s, each ticking through the shared // supervisor. The deadline is shared; we exit early once both // have initialized. let stop = Instant::now() + Duration::from_secs(5); let mut lsp_evs: Vec = Vec::new(); let mut mcp_evs: Vec = Vec::new(); while Instant::now() < stop { sup.borrow_mut().tick(); lsp_mgr.borrow_mut().tick(); mcp_mgr.borrow_mut().tick(); let next_lsp: Vec<_> = lsp_mgr .borrow_mut() .take_events(lsp_sid) .into_iter() .map(|e| e.kind) .collect(); let next_mcp: Vec<_> = mcp_mgr .borrow_mut() .take_events(mcp_sid) .into_iter() .map(|e| e.kind) .collect(); lsp_evs.extend(next_lsp); mcp_evs.extend(next_mcp); let lsp_init = lsp_evs .iter() .any(|k| matches!(k, LspEventKind::Initialized { .. })); let mcp_init = mcp_evs .iter() .any(|k| matches!(k, McpEventKind::Initialized { .. })); if lsp_init && mcp_init { break; } std::thread::sleep(Duration::from_millis(15)); } assert!( lsp_evs .iter() .any(|k| matches!(k, LspEventKind::Initialized { .. })), "LSP server must complete its handshake when sharing a supervisor with MCP; events: {lsp_evs:?}" ); assert!( mcp_evs .iter() .any(|k| matches!(k, McpEventKind::Initialized { .. })), "MCP server must complete its handshake when sharing a supervisor with LSP; events: {mcp_evs:?}" ); // Cross-leakage check: ProtocolError on either side would mean // bytes destined for one peer landed in the other's parser. assert!( !lsp_evs .iter() .any(|k| matches!(k, LspEventKind::ProtocolError { .. })), "LSP saw ProtocolError under coexistence; cross-leak suspected" ); assert!( !mcp_evs .iter() .any(|k| matches!(k, McpEventKind::ProtocolError { .. })), "MCP saw ProtocolError under coexistence; cross-leak suspected" ); let _ = lsp_mgr.borrow_mut().stop(lsp_sid); let _ = mcp_mgr.borrow_mut().stop(mcp_sid); } // =========================================================================== // Auxiliary: send_request gating, forget, protocol violation // =========================================================================== /// `send_request` before the server is `Initialized` errors loudly /// rather than silently dropping the bytes. #[test] fn m9_1_send_request_before_initialized_errors() { let (_sup, _runtime, mgr) = make_test_triple(); let sid = mgr.borrow_mut().spawn(fake_spec("early")).expect("spawn"); // Don't drain — server is in Starting/Initializing state. let res = mgr .borrow_mut() .send_request(sid, "ping", serde_json::Value::Null); assert!( res.is_err(), "send_request before Initialized must error; got {res:?}" ); let _ = mgr.borrow_mut().stop(sid); } /// `ping` round-trips through the async runtime. Pass-2 finding 1: /// `send_request` returns a `JobId`; the response settles the /// runtime's pending entry rather than landing as a poll-style event /// the caller has to scan for. `Response` events still fire for /// observers, but the canonical settle path is the async runtime. #[test] fn m9_1_ping_request_settles_runtime_job() { let (sup, runtime, mgr) = make_test_triple(); let sid = mgr.borrow_mut().spawn(fake_spec("ping")).expect("spawn"); drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); let job_id = mgr .borrow_mut() .send_request(sid, "ping", serde_json::Value::Null) .expect("send_request"); // Drive the manager + runtime until the job settles. We can't // use drain_mcp_until's predicate over events because the // canonical settle is on the runtime side, not in the // McpManager event queue. let stop = Instant::now() + Duration::from_secs(5); while Instant::now() < stop && !runtime.is_complete(job_id) { sup.borrow_mut().tick(); mgr.borrow_mut().tick(); runtime.tick(); std::thread::sleep(Duration::from_millis(15)); } assert!( runtime.is_complete(job_id), "ping job must settle within deadline" ); let outcome = runtime.take_result(job_id).expect("take_result"); let value = match outcome { JobOutcome::Complete(JobResult::Json(v)) => v, other => panic!("expected Complete(Json(...)), got {other:?}"), }; assert!( value.is_object(), "fake mcp returns `result: {{}}` for ping; got {value}" ); let _ = mgr.borrow_mut().stop(sid); } /// A garbage line on the wire surfaces as a `ProtocolError` event. /// Mirrors the LSP protocol-violation acceptance test. #[test] fn m9_1_protocol_violation_surfaces_as_structured_error() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("garbager"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "garbage".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter().any(|e| { matches!( e.kind, McpEventKind::ProtocolError { .. } | McpEventKind::Crashed { .. } | McpEventKind::Stopped ) }) }); assert!( evs.iter() .any(|e| matches!(&e.kind, McpEventKind::ProtocolError { .. })), "non-JSON line should surface as a ProtocolError; got events: {:?}", evs.iter().map(|e| &e.kind).collect::>() ); let _ = mgr.borrow_mut().stop(sid); } /// `forget` removes a terminal-state server from the registry; a /// non-terminal server can't be forgotten. #[test] fn m9_1_forget_only_terminal_servers() { let (sup, runtime, mgr) = make_test_triple(); let sid = mgr.borrow_mut().spawn(fake_spec("forget")).expect("spawn"); drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })) }); // Forgetting an Initialized server must error. assert!( mgr.borrow_mut().forget(sid).is_err(), "forget should reject a non-terminal server" ); // After stop, the server transitions through ShuttingDown to // Stopped; once Stopped, forget succeeds. let _ = mgr.borrow_mut().stop(sid); drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| { matches!( mgr.borrow().state(sid), Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. }) ) }); assert!( mgr.borrow_mut().forget(sid).is_ok(), "forget should succeed once the server is in a terminal state" ); assert!( mgr.borrow().state(sid).is_none(), "forgotten server's state must be None" ); } // =========================================================================== // Pass-2 findings 2 / 3 / 5: lifecycle correctness // =========================================================================== /// Pass-2 finding 2: a JSON-RPC error response to `initialize` must /// **not** transition the client to `Initialized`. The fake server's /// `init_error` mode replies with an error code; pmacs should /// surface a `ProtocolError` event and terminate the process rather /// than masquerading as a healthy server. #[test] fn m9_1_initialize_error_does_not_transition_to_initialized() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("init_error"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "init_error".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. })) }); assert!( evs.iter() .any(|e| matches!(&e.kind, McpEventKind::ProtocolError { message } if message.contains("refused initialize"))), "must observe ProtocolError citing the initialize refusal; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); assert!( !evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })), "Initialized must not fire when initialize returned error" ); // The state never transitions to Initialized; capabilities() // returns None. assert!( !matches!( mgr.borrow().state(sid), Some(McpClientState::Initialized { .. }) ), "client state must not be Initialized after error response" ); assert!(mgr.borrow().capabilities(sid).is_none()); let _ = mgr.borrow_mut().stop(sid); } /// Pass-2 finding 3: an unsupported `protocolVersion` in the /// `initialize` response must terminate the connection rather than /// silently accepting it. The fake's `bad_version` mode reports /// `1999-01-01`, which is not in pmacs's supported set. #[test] fn m9_1_unsupported_protocol_version_is_rejected() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("bad_version"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "bad_version".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. })) }); assert!( evs.iter().any(|e| matches!( &e.kind, McpEventKind::ProtocolError { message } if message.contains("unsupported protocolVersion") )), "must observe ProtocolError citing the unsupported protocolVersion; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); assert!( !matches!( mgr.borrow().state(sid), Some(McpClientState::Initialized { .. }) ), "client must not reach Initialized for an unsupported protocolVersion" ); let _ = mgr.borrow_mut().stop(sid); } /// Pass-3 finding 1: MCP stdio shutdown is stdin-EOF + live SIGTERM / /// SIGKILL fallback. The client must /// **not** send protocol-level `shutdown` requests or `exit` /// notifications. The fake's `crash_on_protocol_shutdown` mode /// exits with code 99 if it ever sees those methods on the wire, /// so a Stopped event (clean exit code 0) proves pmacs took the /// EOF path. #[test] fn m9_1_stop_uses_stdio_eof_not_protocol_shutdown() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("polite-stop"); spec.env = vec![( "PMACS_FAKE_MCP_MODE".into(), "crash_on_protocol_shutdown".into(), )]; 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 { .. })) }); let _ = mgr.borrow_mut().stop(sid); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| { matches!( mgr.borrow().state(sid), Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. }) ) }); let final_state = mgr.borrow().state(sid).cloned(); assert!( matches!(final_state, Some(McpClientState::Stopped { .. })), "stop() must transition to Stopped via stdin EOF (clean exit), not Crashed; got {:?}; events: {:?}", final_state, evs.iter().map(|e| &e.kind).collect::>() ); assert!( !evs.iter() .any(|e| matches!(e.kind, McpEventKind::Crashed { .. })), "no Crashed event — server must exit cleanly on stdin EOF; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); } /// Pass-4 finding 1: EOF is the compliant first shutdown signal, but /// a server that ignores EOF must still be stopped while the manager /// is live. This fixture sleeps forever after stdin closes; the /// shortened grace window lets the manager send SIGTERM and observe a /// terminal Stopped state. #[test] fn m9_1_stop_escalates_when_server_ignores_stdin_eof() { let (sup, runtime, mgr) = make_test_triple(); mgr.borrow_mut() .set_shutdown_grace(Duration::from_millis(25)); let mut spec = fake_spec("ignore-eof"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "ignore_eof_sleep".into())]; 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 { .. })) }); let _ = mgr.borrow_mut().stop(sid); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |_| { matches!( mgr.borrow().state(sid), Some(McpClientState::Stopped { .. } | McpClientState::Crashed { .. }) ) }); let final_state = mgr.borrow().state(sid).cloned(); assert!( matches!(final_state, Some(McpClientState::Stopped { .. })), "stop() must keep escalating after stdin EOF until the process exits; got {:?}; events: {:?}", final_state, evs.iter().map(|e| &e.kind).collect::>() ); } /// Pass-3 finding 2: an `initialize` response missing the required /// `capabilities` field is a protocol violation. pmacs must surface /// `ProtocolError` and refuse to transition to `Initialized`, /// rather than defaulting capabilities to `Null` and proceeding. #[test] fn m9_1_missing_capabilities_in_initialize_is_rejected() { let (sup, runtime, mgr) = make_test_triple(); let mut spec = fake_spec("missing_caps"); spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "missing_caps".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(5), |evs| { evs.iter() .any(|e| matches!(e.kind, McpEventKind::ProtocolError { .. })) }); assert!( evs.iter().any(|e| matches!( &e.kind, McpEventKind::ProtocolError { message } if message.contains("missing required capabilities") )), "must observe ProtocolError citing missing capabilities; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); assert!( !evs.iter() .any(|e| matches!(e.kind, McpEventKind::Initialized { .. })), "Initialized must not fire when capabilities is missing" ); assert!( !matches!( mgr.borrow().state(sid), Some(McpClientState::Initialized { .. }) ), "client state must not be Initialized after a malformed result" ); assert!(mgr.borrow().capabilities(sid).is_none()); let _ = mgr.borrow_mut().stop(sid); } /// Pass-2 finding 5: `OnCrash` policy must not restart on a clean /// exit (code 0). The fake's `clean_exit_after_init` mode replies /// to initialize then exits 0 without us asking. The manager /// should observe `Stopped`, not `Crashed`/`Restarting`. #[test] fn m9_1_oncrash_policy_does_not_restart_on_clean_exit() { let (sup, runtime, mgr) = make_test_triple(); mgr.borrow_mut() .set_restart_backoff(Duration::from_millis(50)); let mut spec = fake_spec("clean_exit"); spec.restart = McpRestartPolicy::OnCrash; spec.env = vec![("PMACS_FAKE_MCP_MODE".into(), "clean_exit_after_init".into())]; let sid = mgr.borrow_mut().spawn(spec).expect("spawn"); // Drain for a while to give a buggy implementation room to // restart; the assertion is about what didn't happen. let evs = drain_mcp_until(&sup, &runtime, &mgr, sid, Duration::from_secs(2), |evs| { evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped)) }); assert!( evs.iter().any(|e| matches!(e.kind, McpEventKind::Stopped)), "clean exit + OnCrash must surface as Stopped, not Crashed/Restarting; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); assert!( !evs.iter() .any(|e| matches!(e.kind, McpEventKind::Restarting { .. })), "OnCrash must not respawn on clean exit; got {:?}", evs.iter().map(|e| &e.kind).collect::>() ); let started_count = evs .iter() .filter(|e| matches!(e.kind, McpEventKind::Started { .. })) .count(); assert_eq!( started_count, 1, "exactly one Started event for a single clean-exit generation; got {started_count}" ); } // =========================================================================== // Lua surface // =========================================================================== /// `pmacs.mcp.spawn` / `_tick` / `events_take` / `capabilities` agree /// with the Rust-level view. Smoke test of the boundary marshalling. #[test] fn m9_1_lua_surface_drives_mcp_lifecycle() { use pmacs::editor::EditorState; let mut state = EditorState::new(); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); let sid_raw: u64 = lua .load(format!( " local id = pmacs.mcp.spawn({{ label = 'lua-fake', command = '{fake}', restart = 'never', }}) return id:raw() ", )) .eval() .expect("spawn via Lua"); assert!(sid_raw > 0); // 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(); 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, "lua-spawned MCP must reach Initialized"); // capabilities() should return a non-nil table. let has_caps: bool = state .lua_host .lua() .load( " local rows = pmacs.mcp.list() assert(#rows == 1, 'expected one server') local caps = pmacs.mcp.capabilities(rows[1].id) return caps ~= nil and type(caps) == 'table' and caps.resources ~= nil ", ) .eval() .expect("capabilities"); assert!( has_caps, "pmacs.mcp.capabilities should return the server's caps table" ); // Stop and let it drain. let _ = state .lua_host .lua() .load( " for _, row in ipairs(pmacs.mcp.list()) do pmacs.mcp.stop(row.id) end ", ) .exec(); } /// Pass-2 finding 1 acceptance: `pmacs.mcp.send_request` returns a /// Handle, and awaiting that handle inside `pmacs.async(...)` /// resumes with the response's `result` table — same dispatch shape /// as `pmacs.workers.compute_sum`, `pmacs.fs.read_dir`, etc. This is /// the shape M9.2/M9.3 will build on; verifying it here means the /// follow-up tasks don't need to introduce a separate async layer. #[test] fn m9_1_lua_send_request_returns_awaitable_handle() { use pmacs::editor::EditorState; let mut state = EditorState::new(); let fake = fake_mcp_path(); let lua = state.lua_host.lua(); // Spawn the server and stash its id in a global the next // `pmacs.async` block reads. lua.load(format!( " _G._mcp_test_server = pmacs.mcp.spawn({{ label = 'lua-await', 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"); // Spawn the awaiting coroutine. The result lands in a global // when the handle settles, which we observe from Rust below. state .lua_host .lua() .load( " _G._mcp_test_done = false _G._mcp_test_result = nil pmacs.async(function() local result = pmacs.mcp.send_request(_G._mcp_test_server, 'ping', {}):await() _G._mcp_test_result = result _G._mcp_test_done = true end) ", ) .exec() .expect("dispatch awaiting coroutine"); // Pump until the coroutine completes. let stop = Instant::now() + Duration::from_secs(5); let mut done = false; while Instant::now() < stop && !done { state.tick_processes(); state.tick_mcp(); state.tick_async(); done = state .lua_host .lua() .load("return _G._mcp_test_done") .eval::() .unwrap_or(false); if !done { std::thread::sleep(Duration::from_millis(15)); } } assert!( done, "awaiting coroutine must complete (Handle.await() didn't resume)" ); // The fake's `ping` reply is `result: {}`. The Lua side received // an empty table, which we verify by checking the type. let result_type: String = state .lua_host .lua() .load("return type(_G._mcp_test_result)") .eval() .expect("read result type"); assert_eq!( result_type, "table", "ping response should marshal to a Lua table; got {result_type}" ); // Stop the server. let _ = state .lua_host .lua() .load("pmacs.mcp.stop(_G._mcp_test_server)") .exec(); }