修复DirectProject对话历史分页卡页

聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口
工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出
隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重
补充原生和真实App回归测试,增加人工只读日志重放入口
更新B05状态、规范与PR验收记录,不提交用户原始日志
This commit is contained in:
2026-09-16 18:46:47 +08:00
parent 825ea76b7b
commit 1b010fb1f8
14 changed files with 685 additions and 36 deletions
@@ -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
+48 -24
View File
@@ -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',