diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index f7bf03745..f1e6679bb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -613,13 +613,47 @@ fn direct_project_history_anchor_id(item: &Value) -> Option { .or_else(|| direct_thread_item_identity(item)) } +/// 一屏历史窗口在新端(较新一侧)的锚点。 +/// +/// 锚点一律是 `project.jsonl` 里的原始 item id(见 [`direct_project_history_anchor_id`])。 +pub(crate) enum DirectProjectHistoryAnchor<'a> { + /// 不锚定:直接取文件尾最近的一屏。 + Newest, + /// 取锚点条目**之前**的一屏:锚点本身不进窗口,是"下一屏"的边界(向后翻页用)。 + Before(&'a str), + /// 取到锚点条目**为止**的一屏:锚点在窗口内(订阅回执给出的首屏边界用)。 + Through(&'a str), +} + +impl<'a> DirectProjectHistoryAnchor<'a> { + /// 锚点的原始 item id;不锚定时为 `None`。 + fn item_id(&self) -> Option<&'a str> { + match self { + Self::Newest => None, + Self::Before(item_id) | Self::Through(item_id) => Some(item_id), + } + } + + /// 锚点条目本身是否属于窗口:`Through` 含锚点,`Before` 把锚点留给下一屏。 + fn includes_anchor(&self) -> bool { + matches!(self, Self::Through(_)) + } + + /// 这一条是不是锚点条目(按原始 item id 比对,不看归一身份)。 + fn matches_item(&self, item: &Value) -> bool { + self.item_id() + .is_some_and(|wanted| direct_project_history_anchor_id(item).as_deref() == Some(wanted)) + } +} + /// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。 /// -/// 分页锚点是原始 item id,收满 `limit` 条可显示条目后再多看一眼"还有没有更早的 -/// 条目"就停,不回读整份历史。 +/// 窗口的新端由 `anchor` 给出:`Before` 用于向后翻页(锚点本身不进窗口),`Through` 用于订阅 +/// 回执给出的首屏边界(锚点进窗口),`Newest` 直接取文件尾。收满 `limit` 条可显示条目后再多 +/// 看一眼"还有没有更早的条目"就停,不回读整份历史。 pub(crate) fn read_direct_project_history_items_slice_at( root: &Path, - before_item_id: Option<&str>, + anchor: DirectProjectHistoryAnchor<'_>, limit: usize, ) -> Result<(Vec, bool, BTreeMap, Option), String> { let path = history_path(root); @@ -629,8 +663,8 @@ pub(crate) fn read_direct_project_history_items_slice_at( let bounded_limit = limit.clamp(1, 200); let mut lines = DirectProjectHistoryReverseLines::open(&path)?; let mut newest_first: Vec<(Value, u64)> = Vec::new(); - // 锚点所在行本身不进窗口:它是"下一屏"的边界。 - let mut anchor_seen = before_item_id.is_none(); + // 锚点命中之前先跳过更新的条目(首屏的 `Through` 会把订阅回执之后才完成的条目挡在外面)。 + let mut anchor_seen = matches!(anchor, DirectProjectHistoryAnchor::Newest); let mut has_more = false; while let Some(line) = lines.next_line()? { if line.bytes.iter().all(u8::is_ascii_whitespace) { @@ -652,10 +686,14 @@ pub(crate) fn read_direct_project_history_items_slice_at( continue; }; if !anchor_seen { - anchor_seen = before_item_id.is_some_and(|anchor| { - direct_project_history_anchor_id(&item).as_deref() == Some(anchor) - }); - continue; + if !anchor.matches_item(&item) { + continue; + } + anchor_seen = true; + // `Before` 的锚点所在行本身不进窗口:它是"下一屏"的边界。 + if !anchor.includes_anchor() { + continue; + } } if is_direct_project_internal_context_item(&item) { continue; @@ -674,7 +712,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( if !anchor_seen { return Err(format!( "DirectProject 历史中不存在 item:{}", - before_item_id.unwrap_or_default() + anchor.item_id().unwrap_or_default() )); } newest_first.reverse(); @@ -700,7 +738,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( /// /// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。 pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { - Ok(read_direct_project_history_items_slice_at(root, None, 1)?.3) + Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3) } pub(crate) fn read_direct_project_chat_history_at( @@ -750,6 +788,7 @@ mod tests { append_direct_project_history_item_at, append_direct_project_user_message_at, direct_project_history_scan_probe, history_path, is_direct_project_internal_context_item, read_direct_project_chat_history_at, read_direct_project_history_items_at, + DirectProjectHistoryAnchor as Anchor, }; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -780,12 +819,14 @@ mod tests { }); append_direct_project_user_message_at(root.path(), &item).unwrap(); let (items, _, timestamps, _) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(items, vec![item.clone()]); assert!(timestamps["sent-message"] > 0); append_direct_project_user_message_at(root.path(), &item).unwrap(); let (_, _, reloaded, _) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(timestamps, reloaded); } @@ -794,7 +835,8 @@ mod tests { let root = init_history_project("history-unknown-time"); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); let (_, _, timestamps, _) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert!(timestamps.is_empty()); assert_eq!( read_direct_project_chat_history_at(root.path()) @@ -822,7 +864,8 @@ mod tests { ], ); let (items, has_more, _, first_item_id) = - super::read_direct_project_history_items_slice_at(root.path(), None, 1).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 1) + .unwrap(); let ids = items .iter() .filter_map(|item| item.get("id").and_then(Value::as_str)) @@ -845,7 +888,8 @@ mod tests { ], ); let (newest, has_more, _, first_item_id) = - super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); let ids = newest .iter() .filter_map(|item| item.get("id").and_then(Value::as_str)) @@ -857,7 +901,7 @@ mod tests { let (older, has_more, _, first_item_id) = super::read_direct_project_history_items_slice_at( root.path(), - first_item_id.as_deref(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), 2, ) .unwrap(); @@ -870,6 +914,107 @@ mod tests { assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); } + /// 判据:首屏窗口的新端边界由 `Through` 锚点给出并**含**锚点条目。 + /// + /// 订阅回执里的 `lastCompletedItemId` 就是这条:比它更新的条目属于运行态事件,不能再从 + /// 历史带一遍,否则同一条目在历史与实时各来一次(此前只靠前端合并兜住)。 + #[test] + fn history_window_through_anchor_includes_it_and_drops_newer_items() { + let root = init_history_project("history-through-anchor"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-2"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!( + ids, + vec!["codex-item-1", "codex-item-2"], + "锚点本身在窗口内,比它更新的条目必须留给运行态" + ); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:锚点就是文件里最老一条时,`Through` 窗口只有它一条且 `hasMore=false`;同一份历史用 + /// `Before` 取锚点**之前**的一屏会取空——两者不是同一个窗口,方向搞反就会凭空多给一屏或吞掉一条。 + #[test] + fn history_window_through_anchor_at_the_oldest_item_still_keeps_it() { + let root = init_history_project("history-through-anchor-oldest"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-1"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + + let (empty, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before("codex-item-1"), + 20, + ) + .unwrap(); + assert!( + empty.is_empty(), + "`Before` 的锚点是下一屏的边界,本身不进窗口" + ); + assert!(!has_more); + assert_eq!(first_item_id, None); + } + + /// 判据:锚点在历史里不存在时失败关闭,错误里带锚点 id(首屏与翻页共用这条)。 + /// + /// 变异验证:把"锚点没命中"当成"读完整个文件"(例如沿用旧的 `anchor_seen` 初始化)时, + /// 首屏会绕过订阅回执的边界去取文件尾,这条用例与上面的 `Through` 用例会一起变红。 + #[test] + fn history_window_unknown_anchor_fails_closed() { + let root = init_history_project("history-unknown-anchor"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + + for anchor in [ + Anchor::Through("codex-missing"), + Anchor::Before("codex-missing"), + ] { + let error = super::read_direct_project_history_items_slice_at(root.path(), anchor, 20) + .expect_err("不存在的锚点必须失败关闭"); + assert!( + error.contains("不存在 item:codex-missing"), + "错误里要带锚点 id:{error}" + ); + } + } + /// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。 /// /// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接 @@ -888,7 +1033,8 @@ mod tests { .expect("write truncated history tail"); let (items, has_more, _, first_item_id) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); let ids = items .iter() .filter_map(|item| item.get("id").and_then(Value::as_str)) @@ -913,8 +1059,9 @@ mod tests { ], ); - let error = super::read_direct_project_history_items_slice_at(root.path(), None, 20) - .expect_err("换行结尾的坏行必须失败关闭"); + let error = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .expect_err("换行结尾的坏行必须失败关闭"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } @@ -932,7 +1079,8 @@ mod tests { // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 let (newest, has_more, _, first_item_id) = - super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); let ids = newest .iter() .filter_map(|item| item.get("id").and_then(Value::as_str)) @@ -945,7 +1093,7 @@ mod tests { let (older, has_more, _, first_item_id) = super::read_direct_project_history_items_slice_at( root.path(), - first_item_id.as_deref(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), 2, ) .unwrap(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 5647d2cb4..a016be19d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,5 +1,8 @@ use super::*; -use crate::agent::{read_direct_project_chat_history_at, read_direct_project_last_item_id_at}; +use crate::agent::{ + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, +}; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; @@ -5376,21 +5379,34 @@ pub(crate) fn consume_direct_project_thread( consume_direct_thread(subscription_id.trim()) } +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 #[tauri::command] pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, + through_item_id: Option, limit: Option, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; + let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ) + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, + }; let (raw_items, has_more, recorded_at_ms, first_item_id) = - read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; let items = direct_thread_items_from_history(root, &raw_items, |item| { direct_thread_item_identity(item) .and_then(|identity| recorded_at_ms.get(&identity).copied())