修复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',
@@ -0,0 +1,25 @@
# AGC 对话历史分页恢复实施计划
- Date: 2026-09-16
- Status: awaiting-runtime-acceptance
- Milestone: [AGC 对话历史分页恢复](./【里程碑】AGC对话历史分页恢复-2026-09-16.md)
## 实施
1. 原生历史读取复用逐行解析,增加消息模式,过滤后分页并返回已有原始消息 ID 游标;保持原始接口默认行为和路径权限。
2. 工作台首屏与更早消息读取显式请求消息模式,消费游标;加载代次隔离、单飞与 ID 去重。
3. 合成记录测试复现原始工具页卡住的形状,覆盖旧无 ID、坏行、时间、失败/重复/切项目;临时目录只读重放用户日志。
4. 前端/原生定向测试、类型、Lint、编码、文档和差异检查通过后,更新问题表及 PR 草稿并本地提交。
## 边界与停止条件
不调整 Direct 消息呈现归属、不修未证明的写入丢失、不上传日志、不触碰用户项目。必要 API 变化仅为本地 IPC 可选参数和游标字段;无 OpenAPI、SpacetimeDB 或持久化迁移。远程推送/PR/WIP 操作仍待额外确认。
## 验收证据
- 历史消息模型、回合呈现及分页集成共 24 个前端测试通过;包含真实 App 的首屏/更早页、单飞、失败重试、重叠消息、同项目重新加载及离开再进入的迟到响应。
- 16 个历史原生测试通过;人工日志重放用例在 CI 默认忽略,已在本地单独执行通过。
- 使用用户提供的原始日志运行修复后的原生读取:44 条现存聊天消息(含 2 条用户消息)分 3 页取回,逐项内容与顺序一致,原文件字节未变;未在仓库保存原始日志。
- 工作台/Direct 恢复与画布导航的 8 个定向回归通过。真实客户端重新进入与向上翻页尚待用户验收;本次涉及 Rust IPC,需重新构建并启动原生端。
- 与前面画布/JSON 修复联合复验:119 个前端定向测试、30 个原生测试通过;AGC TypeScript、修改文件 ESLint、编码、文档索引和差异检查通过。
- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。
@@ -31,7 +31,7 @@
| B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 |
| B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 |
| B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 |
| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 |
| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 |
| B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 |
| C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 |
@@ -40,4 +40,4 @@
- 修复前新增回归测试能检出外壳重复发布、运行提示消失和右键无效;修复后窗口/画布定向测试通过,现有导航、框选、指针点击/取消、UI 编辑器返回平移和素材定位用例通过。
- AGC TypeScript、修改文件 ESLint、编码、文档索引及差异空白检查通过。
- 测试仍有既有 React 列表 key、旧用例 act/IPC 桩告警,未作为本批功能修复扩大范围。
- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页尚未修复;JSON 双路径已本地修复并通过自动化验证,待真实客户端验收。本计划保持开放。
- 用户反馈 B01/B02 本轮未再复现,记为已优化;右键手感与其它真实客户端细节继续观察。对话历史分页JSON 双路径已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。
@@ -0,0 +1,19 @@
# AGC 对话历史分页恢复
- Version: 1
- Status: reviewed
- Date: 2026-09-16
- Parent Spec: [AGC 实施计划:DirectProject 回合展示唯一归属](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md)
## 范围与评审
用户已要求修复 B05。已用用户提供的原始日志只读复验:原始切片被工具/推理填满时,聊天投影为空,消息游标不推进。修复聊天读取与分页,不改历史写入、不删除记录、不修改模型上下文,不扩大到其它尚无证据的对话丢失原因。
采用已有切片接口的显式消息模式与原生游标。原始模式缺省行为保持不变,旧无 ID 消息保留;前端以项目和加载代次隔离结果。本轮不包含远程写入。
## 验收
1. 工具/推理密集、末尾无消息、纯工具历史均不产生空页死循环。
2. 消息正文、原始 ID、时间和顺序保持不变,翻页能到达早期用户提问及最终回答,不重复。
3. 连点、请求失败重试、项目切换和同项目重新加载不会污染消息或游标。
4. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。
@@ -1,5 +1,9 @@
# 踩坑与排障记录
## DirectProject 历史不能按工具条目切页再按消息推进游标
原始 `response_item` 历史同时含用户/助手消息、推理与工具输出。若原生每次取 20 个原始条目、前端过滤聊天消息后再找最旧 ID,纯工具页会让消息集合为空且游标不动,看起来历史丢失。聊天读取固定显式请求 `messagesOnly: true`,原生逐行过滤后按消息分页并返回 `oldestItemId`;默认原始模式留给原始条目消费者。无 ID 旧消息保留并扩展到可寻址边界,不能造 ID。前端保留项目与读取代次、单飞及 ID 去重,旧请求的成功、失败与 finally 都不能覆盖新读取;真实日志只在临时目录只读重放,不能提交正文夹具。
## JSON 卡片显示与 UI 编辑能力必须同源
JSON 的文本读取分支不等于卡面应该展示原始 State 摘要。卡片、缩略图及编辑器入口共同消费受控文本预览的 `uiDesignAssetId`;只有原生复用 UI 持久化合同校验 schema、完整 State 和项目/资产身份后才设置它。普通 JSON 保留 JSON 代码预览,不按 `kind: UI/ui` 或 schema 字符串片段猜测编辑能力。已有合法 UI State 的加载/保存不依赖 kind 精确大小写,但新建初始化仍保留正式 UI 资产门禁;缓存与项目切换须保留现有身份隔离。
@@ -32,6 +32,9 @@
## 2026-09-16 DirectProject 回合展示唯一归属
- 聊天历史使用 `read_direct_project_history_slice``messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。
- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。
- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。
- 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。
- 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。
- 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。