Files
Unlucky-Launcher/crates/cli/tests/agent_mcp_stdio_test.rs
T
AskaEth a5d1dec495
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00

258 lines
8.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 真机验收:起 `ull agent serve --stdio`,用裸 JSON-RPC 走一遍握手 → 列工具 → 调用。
//!
//! 这是 M39 任务 5 的自动化形态:**不引任何 MCP 客户端库**,直接按协议喂行给子进程。
//! stdio 形态是 llama.cpp 内置 MCP client 的入口,它的 stdout 必须是纯 JSON-RPC——
//! 本测试顺带守住「stdout 里没有多余输出」这条最容易破的约束。
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use serde_json::{json, Value};
#[path = "support/mod.rs"]
mod support;
fn data_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("ull-agent-stdio-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("建临时数据根");
dir
}
struct Session {
child: std::process::Child,
stdin: std::process::ChildStdin,
stdout: std::io::Lines<BufReader<std::process::ChildStdout>>,
}
impl Session {
fn start(dir: &Path) -> Self {
let mut child = Command::new(env!("CARGO_BIN_EXE_ull"))
.args([
"agent",
"serve",
"--stdio",
"--allow-write",
"--data-dir",
&dir.display().to_string(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("应能起 ull agent serve --stdio");
let stdin = child.stdin.take().expect("stdin");
let stdout = BufReader::new(child.stdout.take().expect("stdout")).lines();
Self {
child,
stdin,
stdout,
}
}
fn send(&mut self, message: &Value) {
writeln!(self.stdin, "{message}").expect("写请求");
self.stdin.flush().expect("刷新 stdin");
}
fn next_response(&mut self) -> Value {
let line = self
.stdout
.next()
.expect("stdio 应有一行响应(不应挂断)")
.expect("读响应行失败");
serde_json::from_str(&line).unwrap_or_else(|_| panic!("stdout 不是 JSON-RPC:{line}"))
}
fn rpc(&mut self, id: u32, method: &str, params: Value) -> Value {
self.send(&json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }));
self.next_response()
}
}
impl Drop for Session {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[test]
fn stdio_handshake_lists_tools_and_calls_them() {
let dir = data_dir("handshake");
let mut session = Session::start(&dir);
// 1. initialize
let init = session.rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ull-test", "version": "0.1" }
}),
);
assert_eq!(
init["result"]["serverInfo"]["name"],
json!("unlucky-launcher"),
"{init}"
);
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
// 2. tools/list —— 写工具在(--allow-write),十个工具全在
let list = session.rpc(2, "tools/list", json!({}));
let names: Vec<String> = list["result"]["tools"]
.as_array()
.expect("tools 数组")
.iter()
.filter_map(|tool| tool["name"].as_str().map(str::to_string))
.collect();
assert_eq!(names.len(), 10, "{names:?}");
for expected in [
"get_config",
"set_config",
"list_instances",
"list_accounts",
"diagnose_crash",
] {
assert!(names.iter().any(|name| name == expected), "缺 {expected}");
}
// 3. 只读工具真返回(真实 core 端口:空数据根 → 空实例/空账户)
let instances = session.rpc(
3,
"tools/call",
json!({ "name": "list_instances", "arguments": {} }),
);
assert_ne!(instances["result"]["isError"], json!(true), "{instances}");
let text = instances["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("\"total\":0"), "空数据根应 0 个实例:{text}");
// 4. 白名单普通键写入真落盘
let written = session.rpc(
4,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "download.concurrency", "value": 16 } }),
);
assert_ne!(written["result"]["isError"], json!(true), "{written}");
let launcher = dir.join(".ULL").join("config").join("launcher.json");
let content = std::fs::read_to_string(&launcher).expect("应写出 launcher.json");
assert!(content.contains("\"concurrency\""), "{content}");
// 5. 敏感键被拒(外部客户端没有弹窗),文案引导到设置页 / YOLO
let denied = session.rpc(
5,
"tools/call",
json!({ "name": "set_config", "arguments": { "path": "java.selected_path", "value": "D:/jdk17/bin/java.exe" } }),
);
assert_eq!(denied["result"]["isError"], json!(true), "{denied}");
let text = denied["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("YOLO"), "{text}");
// 6. 审计落盘:每一条都在,且会话标签能区分「外部 MCP」
let audit_dir = dir.join(".ULL").join("data").join("agent").join("audit");
let mut records: Vec<Value> = Vec::new();
for entry in std::fs::read_dir(&audit_dir)
.expect("审计目录应存在")
.flatten()
{
let text = std::fs::read_to_string(entry.path()).unwrap_or_default();
for line in text.lines() {
if let Ok(value) = serde_json::from_str::<Value>(line) {
records.push(value);
}
}
}
assert!(
records
.iter()
.any(|record| record["tool"] == json!("set_config") && record["outcome"] == json!("ok")),
"普通键写入应有 ok 审计:{records:?}"
);
assert!(
records
.iter()
.any(|record| record["outcome"] == json!("denied")),
"敏感键拒绝应有 denied 审计:{records:?}"
);
assert!(records
.iter()
.all(|record| record["agent_session_id"] == json!("mcp-stdio")));
let _ = std::fs::remove_dir_all(&dir);
}
/// 抓得住:真实会话(索引 + 事件流按**生产形状**落盘)经 MCP 走 `read_game_log` /
/// `diagnose_crash` 时真读得到行,而不是「0 行 / 找不到事件流」。
/// 抓不住:事件流内容本身对不对(`ports::session` / `ports::diagnose` 的用例守),
/// 也抓不住 `ull agent` 之外的读取点(`ull logs` / GUI 由 M211 与 presenter 用例守)。
#[test]
fn mcp_reads_a_real_session_log_and_diagnoses_it() {
let dir = data_dir("session-read");
let sid = support::write_session(&dir);
let mut session = Session::start(&dir);
session.rpc(
1,
"initialize",
json!({
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": { "name": "ull-test", "version": "0.1" }
}),
);
session.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }));
let listed = session.rpc(
2,
"tools/call",
json!({ "name": "list_game_sessions", "arguments": {} }),
);
let text = listed["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains(&sid), "应列出该会话:{text}");
// 读日志:三行事件都在;只要 stderr 时正好那一行 OOM。
let page = session.rpc(
3,
"tools/call",
json!({ "name": "read_game_log", "arguments": { "session_id": sid, "kinds": ["stderr"] } }),
);
assert_ne!(page["result"]["isError"], json!(true), "{page}");
let text = page["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(
text.contains("\"total_events\":3"),
"应读到全部 3 行事件:{text}"
);
assert!(
text.contains("OutOfMemoryError"),
"应读到 stderr 原文:{text}"
);
// 诊断:同一份 OOM 事件流要命中规则,而不是 needs_more_info。
let report = session.rpc(
4,
"tools/call",
json!({ "name": "diagnose_crash", "arguments": { "session_id": sid } }),
);
assert_ne!(report["result"]["isError"], json!(true), "{report}");
let text = report["result"]["content"][0]["text"]
.as_str()
.unwrap_or_default();
assert!(text.contains("\"needs_more_info\":false"), "{text}");
assert!(
!text.contains("\"candidates\":[]"),
"应命中至少一条规则:{text}"
);
let _ = std::fs::remove_dir_all(&dir);
}