历史切片补新端锚点:首屏窗口取到订阅回执那条为止

direct_project_history 的分页锚点收敛成 DirectProjectHistoryAnchor:Newest(取文件尾)/ Before(不含锚点,翻页用)/ Through(含锚点,首屏边界用)
read_direct_project_history_slice 命令新增 throughItemId,与 beforeItemId 互斥,两个都传直接报错
新增三条用例:Through 含锚点且挡掉更新的条目、锚点是最老一条时仍把它算进窗口、锚点不存在时失败关闭
既有用例改走显式锚点(Newest / Before),行为不变
变异验证:把 anchor_seen 初始化成 true(等于忽略锚点)后四条锚点用例变红
This commit is contained in:
2026-09-17 18:22:02 +08:00
parent fae02694fb
commit 110e9260a7
2 changed files with 192 additions and 28 deletions
@@ -613,13 +613,47 @@ fn direct_project_history_anchor_id(item: &Value) -> Option<String> {
.or_else(|| direct_thread_item_identity(item)) .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)`。
/// ///
/// 分页锚点是原始 item id,收满 `limit` 条可显示条目后再多看一眼"还有没有更早的 /// 窗口的新端由 `anchor` 给出:`Before` 用于向后翻页(锚点本身不进窗口),`Through` 用于订阅
/// 条目"就停,不回读整份历史。 /// 回执给出的首屏边界(锚点进窗口),`Newest` 直接取文件尾。收满 `limit` 条可显示条目后再多
/// 看一眼"还有没有更早的条目"就停,不回读整份历史。
pub(crate) fn read_direct_project_history_items_slice_at( pub(crate) fn read_direct_project_history_items_slice_at(
root: &Path, root: &Path,
before_item_id: Option<&str>, anchor: DirectProjectHistoryAnchor<'_>,
limit: usize, limit: usize,
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>, Option<String>), String> { ) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>, Option<String>), String> {
let path = history_path(root); 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 bounded_limit = limit.clamp(1, 200);
let mut lines = DirectProjectHistoryReverseLines::open(&path)?; let mut lines = DirectProjectHistoryReverseLines::open(&path)?;
let mut newest_first: Vec<(Value, u64)> = Vec::new(); let mut newest_first: Vec<(Value, u64)> = Vec::new();
// 锚点所在行本身不进窗口:它是"下一屏"的边界 // 锚点命中之前先跳过更新的条目(首屏的 `Through` 会把订阅回执之后才完成的条目挡在外面)
let mut anchor_seen = before_item_id.is_none(); let mut anchor_seen = matches!(anchor, DirectProjectHistoryAnchor::Newest);
let mut has_more = false; let mut has_more = false;
while let Some(line) = lines.next_line()? { while let Some(line) = lines.next_line()? {
if line.bytes.iter().all(u8::is_ascii_whitespace) { if line.bytes.iter().all(u8::is_ascii_whitespace) {
@@ -652,10 +686,14 @@ pub(crate) fn read_direct_project_history_items_slice_at(
continue; continue;
}; };
if !anchor_seen { if !anchor_seen {
anchor_seen = before_item_id.is_some_and(|anchor| { if !anchor.matches_item(&item) {
direct_project_history_anchor_id(&item).as_deref() == Some(anchor) continue;
}); }
continue; anchor_seen = true;
// `Before` 的锚点所在行本身不进窗口:它是"下一屏"的边界。
if !anchor.includes_anchor() {
continue;
}
} }
if is_direct_project_internal_context_item(&item) { if is_direct_project_internal_context_item(&item) {
continue; continue;
@@ -674,7 +712,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
if !anchor_seen { if !anchor_seen {
return Err(format!( return Err(format!(
"DirectProject 历史中不存在 item{}", "DirectProject 历史中不存在 item{}",
before_item_id.unwrap_or_default() anchor.item_id().unwrap_or_default()
)); ));
} }
newest_first.reverse(); 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<Option<String>, String> { pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, 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( 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, 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, 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, read_direct_project_chat_history_at, read_direct_project_history_items_at,
DirectProjectHistoryAnchor as Anchor,
}; };
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
@@ -780,12 +819,14 @@ mod tests {
}); });
append_direct_project_user_message_at(root.path(), &item).unwrap(); append_direct_project_user_message_at(root.path(), &item).unwrap();
let (items, _, timestamps, _) = 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_eq!(items, vec![item.clone()]);
assert!(timestamps["sent-message"] > 0); assert!(timestamps["sent-message"] > 0);
append_direct_project_user_message_at(root.path(), &item).unwrap(); append_direct_project_user_message_at(root.path(), &item).unwrap();
let (_, _, reloaded, _) = 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); assert_eq!(timestamps, reloaded);
} }
@@ -794,7 +835,8 @@ mod tests {
let root = init_history_project("history-unknown-time"); let root = init_history_project("history-unknown-time");
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
let (_, _, timestamps, _) = 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!(timestamps.is_empty());
assert_eq!( assert_eq!(
read_direct_project_chat_history_at(root.path()) read_direct_project_chat_history_at(root.path())
@@ -822,7 +864,8 @@ mod tests {
], ],
); );
let (items, has_more, _, first_item_id) = 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 let ids = items
.iter() .iter()
.filter_map(|item| item.get("id").and_then(Value::as_str)) .filter_map(|item| item.get("id").and_then(Value::as_str))
@@ -845,7 +888,8 @@ mod tests {
], ],
); );
let (newest, has_more, _, first_item_id) = 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 let ids = newest
.iter() .iter()
.filter_map(|item| item.get("id").and_then(Value::as_str)) .filter_map(|item| item.get("id").and_then(Value::as_str))
@@ -857,7 +901,7 @@ mod tests {
let (older, has_more, _, first_item_id) = let (older, has_more, _, first_item_id) =
super::read_direct_project_history_items_slice_at( super::read_direct_project_history_items_slice_at(
root.path(), root.path(),
first_item_id.as_deref(), Anchor::Before(first_item_id.as_deref().expect("分页锚点")),
2, 2,
) )
.unwrap(); .unwrap();
@@ -870,6 +914,107 @@ mod tests {
assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); 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::<Vec<_>>();
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::<Vec<_>>();
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("不存在 itemcodex-missing"),
"错误里要带锚点 id{error}"
);
}
}
/// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。 /// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。
/// ///
/// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接 /// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接
@@ -888,7 +1033,8 @@ mod tests {
.expect("write truncated history tail"); .expect("write truncated history tail");
let (items, has_more, _, first_item_id) = 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 let ids = items
.iter() .iter()
.filter_map(|item| item.get("id").and_then(Value::as_str)) .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) let error =
.expect_err("换行结尾的坏行必须失败关闭"); super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
.expect_err("换行结尾的坏行必须失败关闭");
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
} }
@@ -932,7 +1079,8 @@ mod tests {
// 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。
let (newest, has_more, _, first_item_id) = 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 let ids = newest
.iter() .iter()
.filter_map(|item| item.get("id").and_then(Value::as_str)) .filter_map(|item| item.get("id").and_then(Value::as_str))
@@ -945,7 +1093,7 @@ mod tests {
let (older, has_more, _, first_item_id) = let (older, has_more, _, first_item_id) =
super::read_direct_project_history_items_slice_at( super::read_direct_project_history_items_slice_at(
root.path(), root.path(),
first_item_id.as_deref(), Anchor::Before(first_item_id.as_deref().expect("分页锚点")),
2, 2,
) )
.unwrap(); .unwrap();
@@ -1,5 +1,8 @@
use super::*; 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 crate::ui_editor::resource::font::FontAsset;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashSet}; use std::collections::{BTreeMap, HashSet};
@@ -5376,21 +5379,34 @@ pub(crate) fn consume_direct_project_thread(
consume_direct_thread(subscription_id.trim()) consume_direct_thread(subscription_id.trim())
} }
/// 读一屏项目对话历史。
///
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的
/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。
#[tauri::command] #[tauri::command]
pub(crate) async fn read_direct_project_history_slice( pub(crate) async fn read_direct_project_history_slice(
project_path: String, project_path: String,
before_item_id: Option<String>, before_item_id: Option<String>,
through_item_id: Option<String>,
limit: Option<usize>, limit: Option<usize>,
) -> Result<DirectThreadHistorySlice, String> { ) -> Result<DirectThreadHistorySlice, String> {
tauri::async_runtime::spawn_blocking(move || { tauri::async_runtime::spawn_blocking(move || {
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?; 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) = let (raw_items, has_more, recorded_at_ms, first_item_id) =
read_direct_project_history_items_slice_at( read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
root,
before_item_id.as_deref(),
limit.unwrap_or(20),
)?;
let items = direct_thread_items_from_history(root, &raw_items, |item| { let items = direct_thread_items_from_history(root, &raw_items, |item| {
direct_thread_item_identity(item) direct_thread_item_identity(item)
.and_then(|identity| recorded_at_ms.get(&identity).copied()) .and_then(|identity| recorded_at_ms.get(&identity).copied())