Merge branch 'feat/m212-agentlog-agent-and-mcp-log-diag-too'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s
This commit is contained in:
commit
bb8be87c8f
4 files changed
+197
-13
No files matched your search
@@ -128,13 +128,17 @@ impl DiagnosePort for CoreDiagnose {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
/// 与生产写入同形(M212/D2):`log_path` 相对会话根,事件流落在 `<根>/game/<id>.jsonl`。
|
||||
fn seed(root: &std::path::Path, stderr_line: &str) {
|
||||
let game = root.join("game");
|
||||
std::fs::create_dir_all(&game).expect("建目录");
|
||||
let sid = "20261029T124501-9f3c2a";
|
||||
let log_path = session_index::log_path_for(sid);
|
||||
let index = session_index::index_path(root);
|
||||
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
"session_id": sid,
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
@@ -146,18 +150,18 @@ mod tests {
|
||||
"silent_failure": false,
|
||||
"window_detected": false,
|
||||
"event_count": 1,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"log_path": log_path.clone(),
|
||||
"crash_found": true,
|
||||
"diagnosis_top": null
|
||||
});
|
||||
std::fs::write(game.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
std::fs::write(index, format!("{entry}\n")).expect("写索引");
|
||||
let event = json!({
|
||||
"seq": 0, "ts": "2026-10-29T12:45:01.123Z", "t_ms": 0,
|
||||
"kind": "stderr",
|
||||
"payload": { "line": stderr_line, "level": "error" }
|
||||
});
|
||||
std::fs::write(
|
||||
game.join("20261029T124501-9f3c2a.jsonl"),
|
||||
session_index::event_file(root, &log_path),
|
||||
format!("{event}\n"),
|
||||
)
|
||||
.expect("写事件");
|
||||
|
||||
@@ -120,15 +120,21 @@ impl SessionSource {
|
||||
}
|
||||
|
||||
/// 事件流文件路径(新根没有时回落到旧根)。
|
||||
///
|
||||
/// 解析只走 [`session_index::event_file`] 一处(M212/D2):索引里的 `log_path` 已经是
|
||||
/// **相对会话根**的 `game/<id>.jsonl`,这里再拼一层 `game/` 就会拼成
|
||||
/// `game/game/<id>.jsonl` —— `ull agent` / MCP 的「读游戏日志」「诊断崩溃」两个工具
|
||||
/// 因此对每个真实会话都报「读会话事件流失败」,夹具写裸文件名时这个 bug 测不出来。
|
||||
#[must_use]
|
||||
pub fn event_path(&self, entry: &SessionIndexEntry) -> PathBuf {
|
||||
for root in self.roots() {
|
||||
let path = root.join("game").join(&entry.log_path);
|
||||
let path = session_index::event_file(root, &entry.log_path);
|
||||
if path.is_file() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
self.root.join("game").join(&entry.log_path)
|
||||
// 两处都没有:返回**新根**的路径,调用方据此说出「该去哪找」。
|
||||
session_index::event_file(&self.root, &entry.log_path)
|
||||
}
|
||||
|
||||
/// 事件里的 stdout / stderr 文本(诊断与日志阅读共用)。
|
||||
@@ -174,3 +180,100 @@ pub fn tail_join(lines: &[String], tail: usize) -> String {
|
||||
pub fn index_exists(root: &Path) -> bool {
|
||||
session_index::index_path(root).is_file()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
const SID: &str = "20261029T124501-9f3c2a";
|
||||
|
||||
fn entry(log_path: &str) -> SessionIndexEntry {
|
||||
serde_json::from_value(json!({
|
||||
"v": 1,
|
||||
"session_id": SID,
|
||||
"instance_id": "inst-1",
|
||||
"instance_name": "1.20.1 Forge",
|
||||
"game_version": "1.20.1",
|
||||
"started_at": "2026-10-29T12:45:01.123Z",
|
||||
"ended_at": "2026-10-29T12:46:31.123Z",
|
||||
"duration_ms": 90000,
|
||||
"exit_code": 1,
|
||||
"exit_type": "application_error",
|
||||
"silent_failure": false,
|
||||
"window_detected": true,
|
||||
"event_count": 1,
|
||||
"log_path": log_path,
|
||||
"crash_found": false,
|
||||
"diagnosis_top": null
|
||||
}))
|
||||
.expect("索引条目")
|
||||
}
|
||||
|
||||
fn temp_root(tag: &str) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ull-agent-events-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(root.join("game")).expect("建临时根");
|
||||
root
|
||||
}
|
||||
|
||||
/// 抓得住:新根解析出的路径**正好**是写入侧落盘的那一个(`<根>/game/<id>.jsonl`)。
|
||||
/// 抓不住:旧根回落(下一条用例单独守)与事件内容解析(`ports::session` 的用例守)。
|
||||
#[test]
|
||||
fn event_path_matches_what_the_writer_wrote() {
|
||||
let root = temp_root("primary");
|
||||
let log_path = session_index::log_path_for(SID);
|
||||
let written = session_index::event_file(&root, &log_path);
|
||||
std::fs::write(&written, "{\"seq\":0}\n").expect("写事件流");
|
||||
|
||||
let source = SessionSource::new(root.clone());
|
||||
let resolved = source.event_path(&entry(&log_path));
|
||||
|
||||
assert_eq!(resolved, root.join("game").join(format!("{SID}.jsonl")));
|
||||
assert!(
|
||||
resolved.is_file(),
|
||||
"解析出的路径应真的是写入侧那份:{resolved:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 文件暂时不在时,返回的「该去哪找」也必须是生产形状,不能多一层 `game/`。
|
||||
#[test]
|
||||
fn missing_event_file_still_points_at_the_production_shape() {
|
||||
let root = temp_root("missing");
|
||||
let log_path = session_index::log_path_for(SID);
|
||||
|
||||
let source = SessionSource::new(root.clone());
|
||||
let resolved = source.event_path(&entry(&log_path));
|
||||
|
||||
assert_eq!(resolved, session_index::event_file(&root, &log_path));
|
||||
assert_eq!(resolved, root.join("game").join(format!("{SID}.jsonl")));
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// 旧根(`data/agent/sessions`,M188 前的生产落点)里的存量会话仍要读得回来。
|
||||
#[test]
|
||||
fn legacy_root_fallback_uses_the_same_resolution() {
|
||||
let primary = temp_root("legacy-primary");
|
||||
let legacy = temp_root("legacy-old");
|
||||
let log_path = session_index::log_path_for(SID);
|
||||
let legacy_file = session_index::event_file(&legacy, &log_path);
|
||||
std::fs::create_dir_all(legacy_file.parent().expect("父目录")).expect("建 game/");
|
||||
std::fs::write(&legacy_file, "{\"seq\":0}\n").expect("写旧根事件流");
|
||||
|
||||
let source = SessionSource::new(primary.clone()).with_legacy(legacy.clone());
|
||||
let resolved = source.event_path(&entry(&log_path));
|
||||
|
||||
assert_eq!(resolved, legacy_file);
|
||||
assert!(
|
||||
resolved.is_file(),
|
||||
"旧根里的存量会话应读得回来:{resolved:?}"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&primary);
|
||||
let _ = std::fs::remove_dir_all(&legacy);
|
||||
}
|
||||
}
|
||||
@@ -142,10 +142,15 @@ pub fn log_event_json(event: &LogEvent) -> Value {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use unlucky_core::launch::session_index;
|
||||
|
||||
/// 生产写入侧真会写出的形状(M212/D2):`log_path` 相对**会话根**,事件流真落点
|
||||
/// `<根>/game/<id>.jsonl`。夹具以前写裸文件名、把事件流也放在 `game/` 下,
|
||||
/// 与生产只差一层 `game/` —— 正是这一层让「读取侧多 join 一层」永远测不出来。
|
||||
fn seed_session(root: &Path) {
|
||||
let game_dir = root.join("game");
|
||||
std::fs::create_dir_all(&game_dir).expect("建目录");
|
||||
let log_path = session_index::log_path_for("20261029T124501-9f3c2a");
|
||||
let index = session_index::index_path(root);
|
||||
std::fs::create_dir_all(index.parent().expect("索引父目录")).expect("建目录");
|
||||
let entry = json!({
|
||||
"v": 1,
|
||||
"session_id": "20261029T124501-9f3c2a",
|
||||
@@ -160,11 +165,11 @@ mod tests {
|
||||
"silent_failure": false,
|
||||
"window_detected": true,
|
||||
"event_count": 2,
|
||||
"log_path": "20261029T124501-9f3c2a.jsonl",
|
||||
"log_path": log_path.clone(),
|
||||
"crash_found": true,
|
||||
"diagnosis_top": "integrity.forge_missing_library"
|
||||
});
|
||||
std::fs::write(game_dir.join("index.jsonl"), format!("{entry}\n")).expect("写索引");
|
||||
std::fs::write(index, format!("{entry}\n")).expect("写索引");
|
||||
let events = [
|
||||
json!({"seq":0,"ts":"2026-10-29T12:45:01.123Z","t_ms":0,"kind":"stdout","payload":{"line":"hi","level":"info"}}),
|
||||
json!({"seq":1,"ts":"2026-10-29T12:45:02.123Z","t_ms":1000,"kind":"stderr","payload":{"line":"boom","level":"error"}}),
|
||||
@@ -173,7 +178,7 @@ mod tests {
|
||||
.iter()
|
||||
.map(|event| format!("{event}\n"))
|
||||
.collect::<String>();
|
||||
std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件");
|
||||
std::fs::write(session_index::event_file(root, &log_path), text).expect("写事件");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,6 +10,9 @@ 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);
|
||||
@@ -183,3 +186,72 @@ fn stdio_handshake_lists_tools_and_calls_them() {
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in new issue
Block a user