Files
yvgude--lean-ctx/rust/tests/live_smoke_client_instructions.rs
T
wehub-resource-sync 26382a7ac6
CI / Clippy (push) Failing after 15m13s
CI / Test (ubuntu-latest) (push) Failing after 16m1s
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Build (no embeddings / no ORT) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Cookbook (Node) (push) Has been cancelled
CI / Pi Extension (Node) (push) Has been cancelled
CI / Rust SDK (lean-ctx-client) (push) Has been cancelled
CI / Embed SDK (lean-ctx-sdk) (push) Has been cancelled
CI / Python SDK (leanctx) (push) Has been cancelled
CI / Hermes Plugin (Python) (push) Has been cancelled
CI / SDK Conformance Matrix (push) Has been cancelled
CI / Coverage (push) Has been cancelled
CI / cargo-deny (push) Has been cancelled
CI / Adversarial Safety (push) Has been cancelled
CI / Benchmarks (push) Has been cancelled
CI / Output-Quality Gate (eval A/B) (push) Has been cancelled
CI / Documentation (push) Has been cancelled
CI / CI Green (push) Has been cancelled
JetBrains Plugin / Actionlint (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (rust) (push) Has been cancelled
JetBrains Plugin / Validation (push) Has been cancelled
JetBrains Plugin / Build (push) Has been cancelled
JetBrains Plugin / Test (push) Has been cancelled
Security Check / Security Scan (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:30 +08:00

380 lines
14 KiB
Rust

//! Live client-wiring smoke (#578, Säule 1c).
//!
//! Boots the REAL binary end-to-end per client, in an isolated HOME:
//! 1. `lean-ctx init --agent <client>` installs MCP registration + rules file,
//! 2. `lean-ctx mcp` is spoken to over actual stdio JSON-RPC,
//! 3. the `initialize` response proves the cross-channel dedup contract:
//! covered clients (Cursor mdc / Codex instructions.md) get the one-line
//! SKELETON_ANCHOR, uncovered clients (Claude Code) get the full skeleton,
//! 4. `tools/list` proves the advertised surface is exactly the lazy core.
//!
//! This is the CI stand-in for a manual "open Cursor/Claude/Codex and check"
//! session — it exercises the same code paths the editors hit (per-client
//! instructions in `server_handler::initialize`, lazy tool visibility).
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
/// The one-line anchor `instructions.rs` emits for covered clients — shared
/// prefix of both `SKELETON_ANCHOR` and `HOOK_COVERED_ANCHOR` (GL #1153).
/// Kept as a distinctive prefix (not the full constant) so cosmetic rewording
/// doesn't break the smoke while the contract (anchor vs full skeleton) holds.
const ANCHOR_PREFIX: &str = "lean-ctx active —";
/// A line only the FULL canonical skeleton carries.
const SKELETON_MARKER: &str = "MANDATORY MAPPING";
struct TestEnv {
_tmp: tempfile::TempDir,
home: std::path::PathBuf,
data: std::path::PathBuf,
project: std::path::PathBuf,
}
fn test_env() -> TestEnv {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().join("home");
let data = tmp.path().join("data");
let project = tmp.path().join("project");
std::fs::create_dir_all(&home).unwrap();
std::fs::create_dir_all(&data).unwrap();
std::fs::create_dir_all(project.join(".git")).unwrap();
TestEnv {
_tmp: tmp,
home,
data,
project,
}
}
fn base_cmd(bin: &str, env: &TestEnv) -> Command {
let mut cmd = Command::new(bin);
cmd.current_dir(&env.project)
.env("HOME", &env.home)
.env("LEAN_CTX_DATA_DIR", &env.data)
.env("CODEX_HOME", env.home.join(".codex"))
// Skip background maintenance (rules re-inject, version check) so the
// server can't mutate coverage state mid-handshake.
.env("LEAN_CTX_HEADLESS", "1");
cmd
}
fn run_init(bin: &str, env: &TestEnv, agent: &str) {
let out = base_cmd(bin, env)
.args(["init", "--agent", agent, "--global", "--mode", "mcp"])
.env("LEAN_CTX_ACTIVE", "1")
.env("LEAN_CTX_DISABLED", "1")
.output()
.expect("init spawn");
assert!(
out.status.success(),
"init --agent {agent} failed:\nstdout={}\nstderr={}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
/// Speak minimal MCP over the child's stdio: initialize (with `client_name`),
/// then tools/list. Returns (instructions, advertised tool names).
fn mcp_handshake(bin: &str, env: &TestEnv, client_name: &str) -> (String, Vec<String>) {
let mut child: Child = base_cmd(bin, env)
.arg("mcp")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("mcp server spawn");
let mut stdin = child.stdin.take().expect("child stdin");
let stdout = child.stdout.take().expect("child stdout");
// Reader thread: forward every stdout line; a deadline on the receiving
// side keeps a wedged server from hanging the suite.
let (tx, rx) = mpsc::channel::<String>();
let reader = std::thread::spawn(move || {
for line in BufReader::new(stdout).lines() {
match line {
Ok(l) => {
if tx.send(l).is_err() {
break;
}
}
Err(_) => break,
}
}
});
let recv_response = |id: u64| -> serde_json::Value {
let needle = format!("\"id\":{id}");
let deadline = std::time::Instant::now() + Duration::from_mins(1);
loop {
let remaining = deadline
.checked_duration_since(std::time::Instant::now())
.unwrap_or_else(|| {
panic!("timeout waiting for JSON-RPC response id={id}");
});
let line = rx
.recv_timeout(remaining)
.unwrap_or_else(|e| panic!("no response id={id} within deadline: {e}"));
if line.contains(&needle) {
return serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("invalid JSON-RPC line: {e}\n{line}"));
}
}
};
let init = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": client_name, "version": "1.0.0" }
}
});
writeln!(stdin, "{init}").expect("write initialize");
let init_res = recv_response(1);
let instructions = init_res["result"]["instructions"]
.as_str()
.unwrap_or_default()
.to_string();
assert!(
!instructions.is_empty(),
"initialize must return instructions; got: {init_res}"
);
writeln!(
stdin,
"{}",
serde_json::json!({ "jsonrpc": "2.0", "method": "notifications/initialized" })
)
.expect("write initialized");
writeln!(
stdin,
"{}",
serde_json::json!({ "jsonrpc": "2.0", "id": 2, "method": "tools/list" })
)
.expect("write tools/list");
let tools_res = recv_response(2);
let tools: Vec<String> = tools_res["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools/list must return tools; got: {tools_res}"))
.iter()
.filter_map(|t| t["name"].as_str().map(str::to_string))
.collect();
drop(stdin); // EOF → clean server shutdown
let _ = child.wait();
let _ = reader.join();
(instructions, tools)
}
fn assert_lazy_core_surface(tools: &[String], client: &str) {
// The lazy core is client-aware for editing (#1008): clients with a
// reliable native editor don't get ctx_patch, everyone else does. Mirror
// the production rule instead of hardcoding per-client expectations here.
let quirks = lean_ctx::server::tool_visibility::ClientQuirks::resolve(
client,
lean_ctx::server::tool_visibility::CandidateSet::LazyCore,
);
for expected in lean_ctx::tool_defs::CORE_TOOL_NAMES {
if *expected == "ctx_patch" && quirks.hide_ctx_patch {
assert!(
!tools.iter().any(|t| t == expected),
"{client}: ctx_patch must be hidden for native-editor clients (#1008): {tools:?}"
);
continue;
}
assert!(
tools.iter().any(|t| t == expected),
"{client}: lazy core tool {expected} missing from tools/list: {tools:?}"
);
}
assert!(
!tools.iter().any(|t| t == "ctx_graph"),
"{client}: ctx_graph left the lazy core in #578 (ctx_callgraph replaced it): {tools:?}"
);
}
#[test]
#[cfg_attr(
windows,
ignore = "HOME-override isolation is Unix-only (dirs::home_dir uses the Win32 API)"
)]
fn cursor_covered_client_gets_anchor_and_lazy_core() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let env = test_env();
run_init(bin, &env, "cursor");
// Wiring on disk: MCP registration + canonical rules file.
let mcp_json = std::fs::read_to_string(env.home.join(".cursor/mcp.json")).expect("mcp.json");
assert!(mcp_json.contains("lean-ctx"), "mcp.json must register us");
let mdc = std::fs::read_to_string(env.home.join(".cursor/rules/lean-ctx.mdc")).expect("mdc");
assert!(
mdc.contains("<!-- lean-ctx-rules -->"),
"mdc must carry the canonical rules block"
);
let (instructions, tools) = mcp_handshake(bin, &env, "cursor");
// Covered client → one-line anchor, NOT the full skeleton (#578).
assert!(
instructions.contains(ANCHOR_PREFIX),
"cursor is covered by its mdc → instructions must carry the anchor.\n{instructions}"
);
assert!(
!instructions.contains(SKELETON_MARKER),
"cursor instructions must NOT repeat the full skeleton.\n{instructions}"
);
assert_lazy_core_surface(&tools, "cursor");
}
#[test]
#[cfg_attr(
windows,
ignore = "HOME-override isolation is Unix-only (dirs::home_dir uses the Win32 API)"
)]
fn claude_uncovered_client_gets_full_skeleton() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let env = test_env();
run_init(bin, &env, "claude");
// CLAUDE.md carries the compact block — deliberately NOT the canonical
// rules (rules_channel: Claude never counts as covered).
let claude_md = std::fs::read_to_string(env.home.join(".claude/CLAUDE.md")).expect("CLAUDE.md");
assert!(claude_md.contains("<!-- lean-ctx -->"));
let (instructions, tools) = mcp_handshake(bin, &env, "claude-code");
assert!(
instructions.contains(SKELETON_MARKER),
"claude is NOT rules-file covered → instructions must carry the full skeleton.\n{instructions}"
);
assert!(
!instructions.contains(ANCHOR_PREFIX),
"claude must not get the covered-client anchor.\n{instructions}"
);
assert_lazy_core_surface(&tools, "claude");
}
#[test]
#[cfg_attr(
windows,
ignore = "HOME-override isolation is Unix-only (dirs::home_dir uses the Win32 API)"
)]
fn codex_covered_client_gets_anchor() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let env = test_env();
run_init(bin, &env, "codex");
// Wiring on disk: config.toml MCP entry + canonical instructions.md.
let config = std::fs::read_to_string(env.home.join(".codex/config.toml")).expect("config");
assert!(config.contains("lean-ctx"), "config.toml must register us");
let instr_md =
std::fs::read_to_string(env.home.join(".codex/instructions.md")).expect("instructions.md");
assert!(
instr_md.contains("<!-- lean-ctx-rules -->"),
"instructions.md must carry the canonical rules block"
);
let (instructions, tools) = mcp_handshake(bin, &env, "codex");
assert!(
instructions.contains(ANCHOR_PREFIX),
"codex is covered by instructions.md → anchor expected.\n{instructions}"
);
assert!(
!instructions.contains(SKELETON_MARKER),
"codex instructions must NOT repeat the full skeleton.\n{instructions}"
);
assert_lazy_core_surface(&tools, "codex");
}
/// Zero-config golden path: the FIRST MCP session on a fresh machine must
/// leave rules AND the on-demand SKILL.md behind (session-start heal), so
/// `doctor` is green without the user ever running `lean-ctx setup`.
///
/// Unlike the other smokes this one does NOT set LEAN_CTX_HEADLESS — the
/// startup-maintenance block (rules inject + skills install) is the unit
/// under test. The maintenance runs async after initialize, so the test
/// polls for the artifacts with a deadline while the session stays open.
#[test]
#[cfg_attr(
windows,
ignore = "HOME-override isolation is Unix-only (dirs::home_dir uses the Win32 API)"
)]
fn first_session_heals_rules_and_skill() {
let bin = env!("CARGO_BIN_EXE_lean-ctx");
let env = test_env();
// A Cursor install is just its config dir — no prior lean-ctx wiring.
std::fs::create_dir_all(env.home.join(".cursor")).unwrap();
let mut child: Child = Command::new(bin)
.arg("mcp")
.current_dir(&env.project)
.env("HOME", &env.home)
.env("LEAN_CTX_DATA_DIR", &env.data)
.env("CODEX_HOME", env.home.join(".codex"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("mcp server spawn");
let mut stdin = child.stdin.take().expect("child stdin");
let stdout = child.stdout.take().expect("child stdout");
let reader = std::thread::spawn(move || {
// Drain stdout so the server never blocks on a full pipe.
for _ in BufReader::new(stdout).lines() {}
});
let init = serde_json::json!({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "cursor", "version": "1.0.0" }
}
});
writeln!(stdin, "{init}").expect("write initialize");
let rules = env.home.join(".cursor/rules/lean-ctx.mdc");
let skill = env.home.join(".cursor/skills/lean-ctx/SKILL.md");
let deadline = std::time::Instant::now() + Duration::from_mins(1);
while (!rules.exists() || !skill.exists()) && std::time::Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(200));
}
drop(stdin);
let _ = child.wait();
let _ = reader.join();
assert!(
rules.exists(),
"session-start heal must write the Cursor rules file without any setup command"
);
assert!(
skill.exists(),
"session-start heal must install SKILL.md alongside the rules (zero-config: doctor green after first session)"
);
}
/// Guard the exact anchor prefix this smoke keys on: if `SKELETON_ANCHOR` in
/// `instructions.rs` is reworded, this test points straight at the constant
/// instead of three opaque handshake failures.
#[test]
fn anchor_prefix_matches_lib_constant() {
let src =
std::fs::read_to_string(Path::new(env!("CARGO_MANIFEST_DIR")).join("src/instructions.rs"))
.expect("read instructions.rs");
assert!(
src.contains(ANCHOR_PREFIX),
"SKELETON_ANCHOR in instructions.rs no longer starts with the prefix this smoke asserts on — update both together"
);
}