分页锚点改用文件里的原始 item id
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 6m22s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Successful in 6m34s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 6m49s
Project CI / Backend tests (pull_request) Failing after 10s
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 4m49s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Repository checks (pull_request) Failing after 10s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m19s
Project CI / Frontend tests (pull_request) Failing after 6m15s
Project CI / AI game creator shell web tests (pull_request) Failing after 5m47s
Project CI / Native shell tests (pull_request) Successful in 12m22s

- 新增 direct_project_history_anchor_id:优先取 project.jsonl 原始 id,缺 id 时退回归一身份兜底
- read_direct_project_history_items_slice_at 的锚点匹配与 first_item_id 都改用原始 id
- 工具条目的调用与输出共用 call_id,按归一身份当锚点会先跳过 output 再把上一屏的 function_call 带回来
- 原分页用例更名 history_window_paginates_upwards_by_item_id_anchor
- 新增 pagination_anchor_uses_raw_item_id_for_tool_call_pairs,旧实现下会返回 call-1 而非 fc-1(已做变异验证)
This commit is contained in:
2026-09-17 00:24:01 +08:00
parent ad19c9475e
commit da2ad83c3b
@@ -595,9 +595,22 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
Ok(items)
}
/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的 itemId)`。
/// 分页锚点用的原始 item id`project.jsonl` 里这条条目自己的 `id`。
///
/// 分页锚点就是条目身份(`itemId`),收满 `limit` 条可显示条目后再多看一眼"还有没有更早的
/// 工具条目在文件里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一的聊天
/// 身份因此不唯一,当锚点会让翻页原地打转;缺 `id` 的条目才退回归一身份兜底。
fn direct_project_history_anchor_id(item: &Value) -> Option<String> {
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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(ids, vec!["codex-item-1"]);
assert!(!has_more);
assert_eq!(first_item_id.as_deref(), Some("codex-item-1"));
}
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
///
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次