修复DirectProject对话历史分页卡页
聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口 工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出 隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重 补充原生和真实App回归测试,增加人工只读日志重放入口 更新B05状态、规范与PR验收记录,不提交用户原始日志
This commit is contained in:
@@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result<Vec<Va
|
||||
}
|
||||
|
||||
fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64)>, String> {
|
||||
read_direct_project_history_entries_filtered_at(root, None, false)
|
||||
}
|
||||
|
||||
fn is_direct_project_chat_message(item: &Value) -> bool {
|
||||
matches!(
|
||||
item.get("role").and_then(Value::as_str),
|
||||
Some("user" | "assistant")
|
||||
) && item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
parts.iter().any(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|text| !text.is_empty())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// 消息模式逐行丢弃工具输出,只保留聊天正文,避免 40 MiB 工具日志被整表积累或发给 UI。
|
||||
fn read_direct_project_history_entries_filtered_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
messages_only: bool,
|
||||
) -> Result<Vec<(Value, u64)>, String> {
|
||||
let path = history_path(root);
|
||||
if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? {
|
||||
return Ok(Vec::new());
|
||||
return if before_item_id.is_some() {
|
||||
Err("DirectProject 历史游标对应的文件已不存在".to_string())
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
};
|
||||
}
|
||||
let file = File::open(&path)
|
||||
.map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?;
|
||||
@@ -519,6 +548,12 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
if is_direct_project_internal_context_item(&item) {
|
||||
continue;
|
||||
}
|
||||
if before_item_id.is_some_and(|id| item.get("id").and_then(Value::as_str) == Some(id)) {
|
||||
return Ok(items);
|
||||
}
|
||||
if messages_only && !is_direct_project_chat_message(&item) {
|
||||
continue;
|
||||
}
|
||||
items.push((
|
||||
item,
|
||||
parsed
|
||||
@@ -527,7 +562,45 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result<Vec<(Value, u64
|
||||
.unwrap_or(0),
|
||||
));
|
||||
}
|
||||
Ok(items)
|
||||
match before_item_id {
|
||||
Some(item_id) => Err(format!("DirectProject 历史中不存在 item:{item_id}")),
|
||||
None => Ok(items),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_chat_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), String> {
|
||||
let entries = read_direct_project_history_entries_filtered_at(root, before_item_id, true)?;
|
||||
let mut start = entries.len().saturating_sub(limit.clamp(1, 200));
|
||||
// 旧消息可能没有 ID:保留原文,并向前扩到可寻址的已有 ID,不能制造原始消息身份。
|
||||
while start > 0
|
||||
&& entries[start]
|
||||
.0
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_none_or(str::is_empty)
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let timestamps = entries[start..]
|
||||
.iter()
|
||||
.filter_map(|(item, at)| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
(*at > 0).then(|| (id.to_string(), *at))
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
entries
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.map(|(item, _)| item)
|
||||
.collect(),
|
||||
start > 0,
|
||||
timestamps,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
@@ -640,6 +713,165 @@ mod tests {
|
||||
const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#;
|
||||
const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#;
|
||||
|
||||
fn write_items(root: &std::path::Path, items: &[Value]) {
|
||||
let lines = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
json!({"type": "response_item", "payload": item, "recordedAt": 1000 + index})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
write_history_lines(root, &lines.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_skip_tool_only_tail_and_gaps_without_losing_messages_or_times() {
|
||||
let root = init_history_project("message-pages");
|
||||
let mut raw = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
for n in 0..44 {
|
||||
let item = json!({
|
||||
"id": format!("message-{n}"), "type": "message",
|
||||
"role": if n == 0 || n == 38 { "user" } else { "assistant" },
|
||||
"content": [{"type": "output_text", "text": format!("消息 {n}")}],
|
||||
});
|
||||
expected.push(item.clone());
|
||||
raw.push(item);
|
||||
for tool in 0..25 {
|
||||
raw.push(json!({
|
||||
"id": format!("tool-{n}-{tool}"), "type": "function_call_output",
|
||||
"output": "工具结果不应占聊天页名额",
|
||||
}));
|
||||
}
|
||||
}
|
||||
write_items(root.path(), &raw);
|
||||
let path = history_path(root.path());
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let (old_page, _, _) =
|
||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(old_page
|
||||
.iter()
|
||||
.all(|item| item["type"] == "function_call_output"));
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut sizes = Vec::new();
|
||||
loop {
|
||||
let (mut page, more, timestamps) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.unwrap();
|
||||
sizes.push(page.len());
|
||||
for item in &page {
|
||||
let index = raw.iter().position(|raw| raw["id"] == item["id"]).unwrap();
|
||||
assert_eq!(
|
||||
timestamps[item["id"].as_str().unwrap()],
|
||||
1000 + index as u64
|
||||
);
|
||||
}
|
||||
let next = page
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
page.append(&mut all);
|
||||
all = page;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert_ne!(next, cursor);
|
||||
cursor = next;
|
||||
assert!(sizes.len() < 10);
|
||||
}
|
||||
assert_eq!(sizes, vec![20, 20, 4]);
|
||||
assert_eq!(all, expected);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_pages_handle_empty_content_internal_context_and_missing_ids() {
|
||||
let root = init_history_project("message-page-boundary");
|
||||
write_items(
|
||||
root.path(),
|
||||
&[
|
||||
json!({"id":"u", "role":"user", "content":[{"text":"第一条"}]}),
|
||||
json!({"role":"assistant", "content":[{"text":"无ID的旧消息"}]}),
|
||||
json!({"id":"a", "role":"assistant", "content":[{"text":"最后一条"}]}),
|
||||
json!({"id":"empty", "role":"assistant", "content":[{"text":""}]}),
|
||||
json!({"id":"internal", "role":"user", "content":[{"text":"<environment_context>内部</environment_context>"}]}),
|
||||
json!({"id":"reason", "type":"reasoning", "content":[{"text":"推理"}]}),
|
||||
],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 1).unwrap();
|
||||
assert_eq!(page[0]["id"], "a");
|
||||
assert!(more);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("a"), 1).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[0]["id"], "u");
|
||||
assert!(page[1].get("id").is_none());
|
||||
assert!(!more);
|
||||
assert!(
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), Some("missing"), 20)
|
||||
.is_err()
|
||||
);
|
||||
write_items(
|
||||
root.path(),
|
||||
&[json!({"id":"tool", "type":"function_call", "arguments":"{}"})],
|
||||
);
|
||||
let (page, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), None, 20).unwrap();
|
||||
assert!(page.is_empty());
|
||||
assert!(!more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "人工只读诊断:通过 AGC_HISTORY_REPLAY_SOURCE 提供原始历史文件"]
|
||||
fn replay_external_chat_history_pages_without_mutating_source() {
|
||||
let source = std::env::var_os("AGC_HISTORY_REPLAY_SOURCE").expect("provide replay source");
|
||||
let before = std::fs::read(&source).expect("read source");
|
||||
let root = init_history_project("external-history-replay");
|
||||
let path = history_path(root.path());
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, &before).unwrap();
|
||||
let expected =
|
||||
super::read_direct_project_history_entries_filtered_at(root.path(), None, true)
|
||||
.expect("read messages");
|
||||
let mut cursor = None;
|
||||
let mut all = Vec::new();
|
||||
let mut pages = 0;
|
||||
loop {
|
||||
let (mut items, more, _) =
|
||||
super::read_direct_project_chat_items_slice_at(root.path(), cursor.as_deref(), 20)
|
||||
.expect("read page");
|
||||
let next = items
|
||||
.first()
|
||||
.and_then(|item| item["id"].as_str())
|
||||
.map(str::to_string);
|
||||
items.append(&mut all);
|
||||
all = items;
|
||||
pages += 1;
|
||||
if !more {
|
||||
break;
|
||||
}
|
||||
assert!(next.is_some() && next != cursor, "cursor must advance");
|
||||
assert!(pages <= expected.len() + 1, "pagination must terminate");
|
||||
cursor = next;
|
||||
}
|
||||
assert!(
|
||||
all.iter().eq(expected.iter().map(|(item, _)| item)),
|
||||
"message order and content must match"
|
||||
);
|
||||
assert!(
|
||||
std::fs::read(&source).unwrap() == before,
|
||||
"source must remain unchanged"
|
||||
);
|
||||
eprintln!(
|
||||
"history replay: messages={}, pages={pages}, users={}",
|
||||
all.len(),
|
||||
all.iter().filter(|item| item["role"] == "user").count()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() {
|
||||
let root = init_history_project("history-time");
|
||||
|
||||
@@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice {
|
||||
pub(crate) items: Vec<Value>,
|
||||
pub(crate) has_more: bool,
|
||||
pub(crate) item_timestamps: std::collections::BTreeMap<String, u64>,
|
||||
pub(crate) oldest_item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -5404,19 +5404,29 @@ pub(crate) async fn read_direct_project_history_slice(
|
||||
project_path: String,
|
||||
before_item_id: Option<String>,
|
||||
limit: Option<usize>,
|
||||
messages_only: Option<bool>,
|
||||
) -> Result<DirectThreadHistorySlice, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at(
|
||||
root,
|
||||
before_item_id.as_deref(),
|
||||
limit.unwrap_or(20),
|
||||
)?;
|
||||
let read_slice = if messages_only.unwrap_or(false) {
|
||||
read_direct_project_chat_items_slice_at
|
||||
} else {
|
||||
read_direct_project_history_items_slice_at
|
||||
};
|
||||
let (items, has_more, item_timestamps) =
|
||||
read_slice(root, before_item_id.as_deref(), limit.unwrap_or(20))?;
|
||||
let oldest_item_id = items
|
||||
.first()
|
||||
.and_then(|item| item.get("id"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_string);
|
||||
Ok(DirectThreadHistorySlice {
|
||||
items,
|
||||
has_more,
|
||||
item_timestamps,
|
||||
oldest_item_id,
|
||||
})
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -239,10 +239,11 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr
|
||||
import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels';
|
||||
import {
|
||||
type DirectThreadConsumeResult,
|
||||
directThreadHistoryItemsToMessages,
|
||||
directThreadHistoryPage,
|
||||
type DirectThreadHistorySlice,
|
||||
type DirectThreadSubscriptionBootstrap,
|
||||
isDirectTurnInProgress,
|
||||
prependDirectHistoryMessages,
|
||||
} from './features/project-workspace/directThreadEvents';
|
||||
import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation';
|
||||
import type { DirectCodexUserContentPart } from './features/project-workspace/generated';
|
||||
@@ -1958,7 +1959,7 @@ export function App({
|
||||
);
|
||||
const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false);
|
||||
const directHistoryOldestItemIdRef = useRef<string | null>(null);
|
||||
const directHistoryLoadingRef = useRef(false);
|
||||
const directHistoryLoadingRef = useRef<number | null>(null);
|
||||
const [pendingCommand, setPendingCommand] = useState<PendingCommand | null>(
|
||||
null,
|
||||
);
|
||||
@@ -2151,6 +2152,7 @@ export function App({
|
||||
|
||||
function resetProjectSupervisorState() {
|
||||
projectSupervisorHistoryLoadVersionRef.current += 1;
|
||||
directHistoryLoadingRef.current = null;
|
||||
projectSupervisorRuntimeResumeProjectPathRef.current = null;
|
||||
projectSupervisorSessionIdRef.current = null;
|
||||
projectSupervisorRuntimeRef.current = null;
|
||||
@@ -4009,6 +4011,8 @@ export function App({
|
||||
}
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1;
|
||||
projectSupervisorHistoryLoadVersionRef.current = loadVersion;
|
||||
if (directCodexProductRuntime)
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
try {
|
||||
// V2 projects do not have a Supervisor run or legacy conversation. Probe the
|
||||
// V2 authority first; a missing V2 session returns null and preserves the
|
||||
@@ -4105,6 +4109,7 @@ export function App({
|
||||
: await readProjectSupervisorActiveSession(invoke, nextProjectPath);
|
||||
let runtimeError = '';
|
||||
let loadedDirectHistoryHasMore = false;
|
||||
let loadedDirectHistoryCursor: string | null = null;
|
||||
const projectConversation = directCodexProductRuntime
|
||||
? (() => {
|
||||
return invoke<DirectThreadHistorySlice>(
|
||||
@@ -4112,16 +4117,16 @@ export function App({
|
||||
{
|
||||
projectPath: nextProjectPath,
|
||||
limit: CONVERSATION_INITIAL_VISIBLE_COUNT,
|
||||
messagesOnly: true,
|
||||
},
|
||||
).then((slice) => {
|
||||
loadedDirectHistoryHasMore = slice.hasMore;
|
||||
const page = directThreadHistoryPage(slice);
|
||||
loadedDirectHistoryHasMore = page.hasMore;
|
||||
loadedDirectHistoryCursor = page.cursor;
|
||||
return {
|
||||
path: nextProjectPath,
|
||||
agentId: null,
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
messages: page.messages,
|
||||
} satisfies LocalConversationResult;
|
||||
});
|
||||
})()
|
||||
@@ -4218,9 +4223,7 @@ export function App({
|
||||
setProjectSupervisorRuntimeError(runtimeError || resumeError);
|
||||
if (directCodexProductRuntime) {
|
||||
setDirectHistoryHasMore(loadedDirectHistoryHasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
conversationMessages.find((message) => message.messageId)
|
||||
?.messageId ?? null;
|
||||
directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor;
|
||||
}
|
||||
setMessages((current) => {
|
||||
// replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。
|
||||
@@ -4281,6 +4284,10 @@ export function App({
|
||||
: workspaceStatus,
|
||||
);
|
||||
// Keep the default greeting when history is missing or blocked.
|
||||
} finally {
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4396,6 +4403,9 @@ export function App({
|
||||
setAgentRunHistoryFiles([]);
|
||||
setAgentRuntimeById({});
|
||||
setMessages(conversationMessages);
|
||||
// 只有新项目确实打开后才丢弃旧分页位置;打开失败时旧会话仍可继续翻页。
|
||||
directHistoryOldestItemIdRef.current = null;
|
||||
setDirectHistoryHasMore(false);
|
||||
setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT);
|
||||
savedConversationProjectPathRef.current = openedProject.projectPath;
|
||||
savedConversationCountRef.current = conversationMessages.length;
|
||||
@@ -12480,27 +12490,38 @@ export function App({
|
||||
: null;
|
||||
|
||||
async function showEarlierConversationMessages() {
|
||||
if (hiddenConversationCount > 0) {
|
||||
setConversationVisibleCount((current) =>
|
||||
Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (directCodexProductRuntime && directHistoryHasMore) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
const projectPath = localProject?.projectPath;
|
||||
if (invoke && projectPath && !directHistoryLoadingRef.current) {
|
||||
directHistoryLoadingRef.current = true;
|
||||
if (invoke && projectPath && directHistoryLoadingRef.current === null) {
|
||||
const loadVersion = projectSupervisorHistoryLoadVersionRef.current;
|
||||
const beforeItemId = directHistoryOldestItemIdRef.current;
|
||||
directHistoryLoadingRef.current = loadVersion;
|
||||
const isCurrentLoad = () =>
|
||||
manifestRefreshMountedRef.current &&
|
||||
localProjectPathRef.current === projectPath &&
|
||||
projectSupervisorHistoryLoadVersionRef.current === loadVersion;
|
||||
try {
|
||||
const slice = await invoke<DirectThreadHistorySlice>(
|
||||
'read_direct_project_history_slice',
|
||||
{
|
||||
projectPath,
|
||||
beforeItemId: directHistoryOldestItemIdRef.current,
|
||||
beforeItemId,
|
||||
limit: CONVERSATION_VISIBLE_STEP,
|
||||
messagesOnly: true,
|
||||
},
|
||||
);
|
||||
if (localProjectPathRef.current !== projectPath) {
|
||||
if (!isCurrentLoad()) {
|
||||
return;
|
||||
}
|
||||
const older = directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
).map((message) => ({
|
||||
const page = directThreadHistoryPage(slice, beforeItemId);
|
||||
const older = page.messages.map((message) => ({
|
||||
role:
|
||||
message.role === 'user'
|
||||
? ('user' as const)
|
||||
@@ -12510,18 +12531,21 @@ export function App({
|
||||
messageId: message.messageId,
|
||||
updatedAt: message.updatedAt,
|
||||
}));
|
||||
setMessages((current) => [...older, ...current]);
|
||||
setMessages((current) =>
|
||||
prependDirectHistoryMessages(current, older),
|
||||
);
|
||||
setConversationVisibleCount((current) => current + older.length);
|
||||
setDirectHistoryHasMore(slice.hasMore);
|
||||
directHistoryOldestItemIdRef.current =
|
||||
older.find((message) => message.messageId)?.messageId ??
|
||||
directHistoryOldestItemIdRef.current;
|
||||
setDirectHistoryHasMore(page.hasMore);
|
||||
directHistoryOldestItemIdRef.current = page.cursor;
|
||||
} catch (error) {
|
||||
if (!isCurrentLoad()) return;
|
||||
setWorkspaceStatus(
|
||||
`读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
directHistoryLoadingRef.current = false;
|
||||
if (directHistoryLoadingRef.current === loadVersion) {
|
||||
directHistoryLoadingRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { LocalConversationMessageRecord } from '../../app/types';
|
||||
import type {
|
||||
ChatMessage,
|
||||
LocalConversationMessageRecord,
|
||||
} from '../../app/types';
|
||||
|
||||
export type DirectThreadRawEvent = {
|
||||
seq: number;
|
||||
@@ -22,8 +25,54 @@ export type DirectThreadHistorySlice = {
|
||||
items: unknown[];
|
||||
hasMore: boolean;
|
||||
itemTimestamps?: Record<string, number>;
|
||||
oldestItemId?: string | null;
|
||||
};
|
||||
|
||||
/** 游标取原始响应,而非过滤后的聊天消息;拒绝不能前进的页,避免静默反复回读。 */
|
||||
export function directThreadHistoryPage(
|
||||
slice: DirectThreadHistorySlice,
|
||||
previousCursor: string | null = null,
|
||||
) {
|
||||
const first = slice.items[0];
|
||||
const firstId =
|
||||
first && typeof first === 'object' && 'id' in first
|
||||
? (first as { id?: unknown }).id
|
||||
: null;
|
||||
const cursor =
|
||||
slice.oldestItemId ??
|
||||
(typeof firstId === 'string' && firstId ? firstId : null);
|
||||
if (slice.hasMore && (!cursor || cursor === previousCursor)) {
|
||||
throw new Error('对话历史分页游标未前进,请重新读取项目历史');
|
||||
}
|
||||
return {
|
||||
messages: directThreadHistoryItemsToMessages(
|
||||
slice.items,
|
||||
slice.itemTimestamps,
|
||||
),
|
||||
hasMore: slice.hasMore,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
/** 保留当前实时/已显示版本;原始身份相同的回读消息不能插入第二次。 */
|
||||
export function prependDirectHistoryMessages(
|
||||
current: readonly ChatMessage[],
|
||||
older: readonly ChatMessage[],
|
||||
): ChatMessage[] {
|
||||
const ids = new Set(
|
||||
current.flatMap((message) =>
|
||||
message.messageId ? [message.messageId] : [],
|
||||
),
|
||||
);
|
||||
const additions = older.filter((message) => {
|
||||
if (!message.messageId) return true;
|
||||
if (ids.has(message.messageId)) return false;
|
||||
ids.add(message.messageId);
|
||||
return true;
|
||||
});
|
||||
return [...additions, ...current];
|
||||
}
|
||||
|
||||
export function directThreadHistoryItemsToMessages(
|
||||
items: unknown[],
|
||||
itemTimestamps: Readonly<Record<string, number>> = {},
|
||||
|
||||
@@ -2450,7 +2450,7 @@ export function registerHomeProjectCreationTests() {
|
||||
};
|
||||
}
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args).toEqual({ projectPath, limit: 20 });
|
||||
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||
return { items: [...persistedMessages], hasMore: false };
|
||||
}
|
||||
if (command === 'append_local_conversation_message') {
|
||||
@@ -2536,7 +2536,7 @@ export function registerHomeProjectCreationTests() {
|
||||
return manifest;
|
||||
}
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args).toEqual({ projectPath, limit: 20 });
|
||||
expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true });
|
||||
return { items: [...persistedMessages], hasMore: false };
|
||||
}
|
||||
if (command === 'append_local_permission_log') {
|
||||
|
||||
@@ -9133,6 +9133,7 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||
projectPath,
|
||||
limit: 20,
|
||||
messagesOnly: true,
|
||||
}),
|
||||
);
|
||||
// 默认任务占位行也不能触发专业 Agent 历史的批量读取。
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { DirectThreadHistorySlice } from '../src/features/project-workspace/directThreadEvents';
|
||||
import {
|
||||
act,
|
||||
App,
|
||||
createGameCreationAppManifest,
|
||||
fireEvent,
|
||||
React,
|
||||
render,
|
||||
screen,
|
||||
setComposerText,
|
||||
} from './appSurface/harness';
|
||||
|
||||
const projectPath = '/tmp/direct-message-pages';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'direct-message-pages',
|
||||
'历史分页',
|
||||
);
|
||||
const messages = Array.from({ length: 26 }, (_, index) => ({
|
||||
id: `direct-codex:turn-${Math.floor(index / 2)}:${index % 2 ? 'assistant' : 'user'}`,
|
||||
type: 'message',
|
||||
role: index % 2 ? 'assistant' : 'user',
|
||||
content: [
|
||||
{
|
||||
type: index % 2 ? 'output_text' : 'input_text',
|
||||
text: `历史正文 ${index}`,
|
||||
},
|
||||
],
|
||||
}));
|
||||
function page(
|
||||
items: typeof messages,
|
||||
hasMore: boolean,
|
||||
): DirectThreadHistorySlice {
|
||||
return { items, hasMore, oldestItemId: items[0]?.id ?? null };
|
||||
}
|
||||
function deferred() {
|
||||
let resolve!: (value: DirectThreadHistorySlice) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<DirectThreadHistorySlice>((yes, no) => {
|
||||
resolve = yes;
|
||||
reject = no;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
function install(
|
||||
read: (
|
||||
args: Record<string, unknown>,
|
||||
) => DirectThreadHistorySlice | Promise<DirectThreadHistorySlice>,
|
||||
) {
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'read_direct_project_history_slice') {
|
||||
expect(args?.messagesOnly).toBe(true);
|
||||
return read(args!);
|
||||
}
|
||||
if (command === 'read_project_permission_policy')
|
||||
return {
|
||||
path: '.agent/policy.json',
|
||||
policy: { deniedCommands: [], confirmCommands: [] },
|
||||
};
|
||||
if (command === 'get_local_game_manifest') return manifest;
|
||||
if (command === 'read_game_creator_app_config')
|
||||
return {
|
||||
config: {
|
||||
selectedModelId: 'quality',
|
||||
selectedModelIsDefault: true,
|
||||
llm: { customEnabled: false },
|
||||
},
|
||||
};
|
||||
if (
|
||||
command === 'read_direct_tool_calls' ||
|
||||
command === 'read_direct_turn_stream' ||
|
||||
command === 'list_game_creator_direct_active_turns'
|
||||
)
|
||||
return [];
|
||||
return null;
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__;
|
||||
return invoke;
|
||||
}
|
||||
function mount(path = projectPath) {
|
||||
return render(
|
||||
<App
|
||||
initialProjectPath={path}
|
||||
initialProjectManifest={manifest}
|
||||
projectSupervisorOnly
|
||||
orchestrationMode="single-supervisor"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
const earlier = () => screen.getByRole('button', { name: /显示更早/ });
|
||||
afterEach(() => {
|
||||
delete window.__TAURI__;
|
||||
});
|
||||
|
||||
describe('Direct 聊天历史分页集成', () => {
|
||||
it('首屏按消息加载20条,更早消息使用原生游标,原始工具记录不占页', async () => {
|
||||
const invoke = install((args) =>
|
||||
args.beforeItemId
|
||||
? page(messages.slice(0, 6), false)
|
||||
: page(messages.slice(6), true),
|
||||
);
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
expect(screen.queryByText('历史正文 0')).toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
await screen.findByText('历史正文 0');
|
||||
expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', {
|
||||
projectPath,
|
||||
beforeItemId: messages[6]!.id,
|
||||
limit: 20,
|
||||
messagesOnly: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('重复点击单飞,失败保留游标可重试,重叠消息不重复', async () => {
|
||||
const pending = deferred();
|
||||
let attempts = 0;
|
||||
const invoke = install((args) => {
|
||||
if (!args.beforeItemId) return page(messages.slice(6), true);
|
||||
attempts += 1;
|
||||
return attempts === 1
|
||||
? pending.promise
|
||||
: page(messages.slice(0, 8), false);
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
fireEvent.click(earlier());
|
||||
expect(attempts).toBe(1);
|
||||
await act(async () => pending.reject(new Error('模拟读取失败')));
|
||||
expect(screen.getByText('历史正文 25')).not.toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
await screen.findByText('历史正文 0');
|
||||
expect(screen.getAllByText('历史正文 6')).toHaveLength(1);
|
||||
expect(screen.getAllByText('历史正文 7')).toHaveLength(1);
|
||||
const loads = invoke.mock.calls.filter(
|
||||
([command, args]) =>
|
||||
command === 'read_direct_project_history_slice' && args?.beforeItemId,
|
||||
);
|
||||
expect(loads.map(([, args]) => args?.beforeItemId)).toEqual([
|
||||
messages[6]!.id,
|
||||
messages[6]!.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('同项目重新加载使旧翻页失效,旧 finally 不解除新请求的单飞', async () => {
|
||||
const old = deferred();
|
||||
const fresh = deferred();
|
||||
let fullLoads = 0;
|
||||
let olderLoads = 0;
|
||||
const recent = messages.slice(6).map((item) => ({
|
||||
...item,
|
||||
content: [{ type: 'output_text', text: `重读 ${item.content[0]!.text}` }],
|
||||
}));
|
||||
install((args) => {
|
||||
if (!args.beforeItemId) {
|
||||
fullLoads += 1;
|
||||
return page(fullLoads === 1 ? messages.slice(6) : recent, true);
|
||||
}
|
||||
olderLoads += 1;
|
||||
return olderLoads === 1 ? old.promise : fresh.promise;
|
||||
});
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history');
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await screen.findByText('重读 历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
await act(async () =>
|
||||
old.resolve(
|
||||
page(
|
||||
[
|
||||
{
|
||||
...messages[0]!,
|
||||
id: 'stale',
|
||||
content: [{ type: 'input_text', text: '过期消息' }],
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText('过期消息')).toBeNull();
|
||||
fireEvent.click(earlier());
|
||||
expect(olderLoads).toBe(2);
|
||||
await act(async () => fresh.resolve(page(messages.slice(0, 6), false)));
|
||||
await screen.findByText('历史正文 0');
|
||||
});
|
||||
|
||||
it('离开项目并重进后,旧请求不能污染新实例', async () => {
|
||||
const old = deferred();
|
||||
const invoke = install((args) =>
|
||||
args.beforeItemId ? old.promise : page(messages.slice(6), true),
|
||||
);
|
||||
const first = mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
fireEvent.click(earlier());
|
||||
first.unmount();
|
||||
const other = mount('/tmp/another-project');
|
||||
await screen.findByText('历史正文 25');
|
||||
other.unmount();
|
||||
mount();
|
||||
await screen.findByText('历史正文 25');
|
||||
await act(async () =>
|
||||
old.resolve(
|
||||
page(
|
||||
[
|
||||
{
|
||||
...messages[0]!,
|
||||
id: 'stale',
|
||||
content: [{ type: 'input_text', text: '旧项目迟到消息' }],
|
||||
},
|
||||
],
|
||||
false,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(screen.queryByText('旧项目迟到消息')).toBeNull();
|
||||
expect(earlier()).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'read_direct_project_history_slice',
|
||||
),
|
||||
).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,59 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
directThreadHistoryItemsToMessages,
|
||||
directThreadHistoryPage,
|
||||
isDirectTurnInProgress,
|
||||
prependDirectHistoryMessages,
|
||||
} from '../src/features/project-workspace/directThreadEvents';
|
||||
|
||||
describe('Direct 回合状态与历史时间', () => {
|
||||
it('游标来自原始切片,不从没有聊天消息的工具页倒推', () => {
|
||||
const page = directThreadHistoryPage(
|
||||
{
|
||||
items: [{ id: 'tool-older', type: 'function_call_output' }],
|
||||
hasMore: true,
|
||||
oldestItemId: 'tool-older',
|
||||
},
|
||||
'message-newer',
|
||||
);
|
||||
expect(page.messages).toEqual([]);
|
||||
expect(page.cursor).toBe('tool-older');
|
||||
});
|
||||
it('空历史结束,无 ID 或不前进的非终页明确失败而不循环', () => {
|
||||
expect(directThreadHistoryPage({ items: [], hasMore: false })).toEqual({
|
||||
messages: [],
|
||||
cursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
expect(() => directThreadHistoryPage({ items: [], hasMore: true })).toThrow(
|
||||
'游标未前进',
|
||||
);
|
||||
expect(() =>
|
||||
directThreadHistoryPage(
|
||||
{
|
||||
items: [{ id: 'same' }],
|
||||
hasMore: true,
|
||||
oldestItemId: 'same',
|
||||
},
|
||||
'same',
|
||||
),
|
||||
).toThrow('游标未前进');
|
||||
});
|
||||
it('重叠页按原始 ID 去重,保留当前正文,旧无 ID 消息不删除', () => {
|
||||
const current = [
|
||||
{ role: 'assistant' as const, text: '完整正文', messageId: 'a' },
|
||||
];
|
||||
const old = { role: 'user' as const, text: '用户输入', messageId: 'u' };
|
||||
expect(
|
||||
prependDirectHistoryMessages(current, [
|
||||
old,
|
||||
old,
|
||||
{ role: 'assistant', text: '旧快照', messageId: 'a' },
|
||||
{ role: 'assistant', text: '无身份旧消息' },
|
||||
]),
|
||||
).toEqual([old, { role: 'assistant', text: '无身份旧消息' }, ...current]);
|
||||
});
|
||||
|
||||
it('终态和空状态不恢复为活动回合', () => {
|
||||
for (const status of [
|
||||
'completed',
|
||||
|
||||
Reference in New Issue
Block a user