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 039942a3f..5a59918d8 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 @@ -595,9 +595,22 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result Option { + item.get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| direct_thread_item_identity(item)) +} + +/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。 +/// +/// 分页锚点是原始 item id,收满 `limit` 条可显示条目后再多看一眼"还有没有更早的 /// 条目"就停,不回读整份历史。 pub(crate) fn read_direct_project_history_items_slice_at( root: &Path, @@ -635,7 +648,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( }; if !anchor_seen { anchor_seen = before_item_id.is_some_and(|anchor| { - direct_thread_item_identity(&item).as_deref() == Some(anchor) + direct_project_history_anchor_id(&item).as_deref() == Some(anchor) }); continue; } @@ -669,7 +682,7 @@ pub(crate) fn read_direct_project_history_items_slice_at( .collect(); let first_item_id = newest_first .first() - .and_then(|(item, _)| direct_thread_item_identity(item)); + .and_then(|(item, _)| direct_project_history_anchor_id(item)); Ok(( newest_first.into_iter().map(|(item, _)| item).collect(), has_more, @@ -814,9 +827,9 @@ mod tests { assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); } - /// 判据:分页锚点是"本窗口最老一条的归一身份",逐屏向前不重不漏。 + /// 判据:分页锚点是"本窗口最老一条的原始 item id",逐屏向前不重不漏。 #[test] - fn history_window_paginates_upwards_by_identity_anchor() { + fn history_window_paginates_upwards_by_item_id_anchor() { let root = init_history_project("history-pagination"); write_history_lines( root.path(), @@ -900,6 +913,46 @@ mod tests { assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); } + /// 判据:分页锚点取文件里的原始 item id。工具条目的调用与输出共用调用 id,归一身份不唯一。 + /// + /// 变异验证:锚点按归一身份算(旧实现)时,翻页会先跳过同身份的 output,再把上一屏已经 + /// 显示过的 function_call 重新带进这一屏。 + #[test] + fn pagination_anchor_uses_raw_item_id_for_tool_call_pairs() { + let root = init_history_project("history-tool-call-anchor"); + let call_row = r#"{"type":"response_item","payload":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}"#; + let output_row = r#"{"type":"response_item","payload":{"type":"function_call_output","id":"fc-2","call_id":"call-1","output":"ok"}}"#; + let earlier_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines(root.path(), &[&earlier_row, call_row, output_row]); + + // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["fc-1", "fc-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("fc-1")); + + // 用锚点翻上一屏:不能再把 fc-1 带一遍,也不能原地返回同一屏。 + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + first_item_id.as_deref(), + 2, + ) + .unwrap(); + let ids = older + .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")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次