diff --git a/crates/cli/src/commands/agent/ports/diagnose.rs b/crates/cli/src/commands/agent/ports/diagnose.rs index 0f9b4e5..cb16fcf 100644 --- a/crates/cli/src/commands/agent/ports/diagnose.rs +++ b/crates/cli/src/commands/agent/ports/diagnose.rs @@ -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/.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("写事件"); diff --git a/crates/cli/src/commands/agent/ports/events.rs b/crates/cli/src/commands/agent/ports/events.rs index 86f249d..dfd28e8 100644 --- a/crates/cli/src/commands/agent/ports/events.rs +++ b/crates/cli/src/commands/agent/ports/events.rs @@ -120,15 +120,21 @@ impl SessionSource { } /// 事件流文件路径(新根没有时回落到旧根)。 + /// + /// 解析只走 [`session_index::event_file`] 一处(M212/D2):索引里的 `log_path` 已经是 + /// **相对会话根**的 `game/.jsonl`,这里再拼一层 `game/` 就会拼成 + /// `game/game/.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/.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); + } +} diff --git a/crates/cli/src/commands/agent/ports/session.rs b/crates/cli/src/commands/agent/ports/session.rs index ae03cd9..3259475 100644 --- a/crates/cli/src/commands/agent/ports/session.rs +++ b/crates/cli/src/commands/agent/ports/session.rs @@ -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/.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::(); - std::fs::write(game_dir.join("20261029T124501-9f3c2a.jsonl"), text).expect("写事件"); + std::fs::write(session_index::event_file(root, &log_path), text).expect("写事件"); } #[test] diff --git a/crates/cli/tests/agent_mcp_stdio_test.rs b/crates/cli/tests/agent_mcp_stdio_test.rs index 2600454..7ae0e90 100644 --- a/crates/cli/tests/agent_mcp_stdio_test.rs +++ b/crates/cli/tests/agent_mcp_stdio_test.rs @@ -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); +}