From 1b010fb1f89f7470a0fa99c8ac7dbe5e500077f7 Mon Sep 17 00:00:00 2001 From: suzmii Date: Wed, 16 Sep 2026 18:46:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8DDirectProject=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5=E5=8D=A1=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 聊天历史按有效消息分页并返回原生游标,保留默认原始切片接口 工具和推理记录不占聊天页名额,逐行过滤避免传回大段工具输出 隔离重新加载和项目切换的迟到响应,补齐单飞、重试与消息去重 补充原生和真实App回归测试,增加人工只读日志重放入口 更新B05状态、规范与PR验收记录,不提交用户原始日志 --- .../src/agent/direct_project_history.rs | 236 +++++++++++++++++- .../src/agent/direct_thread_manager.rs | 1 + .../src-tauri/src/commands.rs | 20 +- apps/ai-game-creator-shell/src/App.tsx | 72 ++++-- .../project-workspace/directThreadEvents.ts | 51 +++- .../tests/appSurface/home.suite.ts | 4 +- .../appSurface/project-development.suite.ts | 1 + .../tests/directHistoryPagination.test.tsx | 232 +++++++++++++++++ .../tests/directThreadEvents.test.ts | 49 ++++ ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 25 ++ ...施计划】AGC画布交互稳定性修复-2026-09-16.md | 4 +- ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 19 ++ docs/project-memory/shared-memory/pitfalls.md | 4 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 3 + 14 files changed, 685 insertions(+), 36 deletions(-) create mode 100644 apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx create mode 100644 docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..ed9e5e576 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -482,9 +482,38 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result Result, 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, 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 Result 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, bool, BTreeMap), 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::>(); + write_history_lines(root, &lines.iter().map(String::as_str).collect::>()); + } + + #[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":"内部"}]}), + 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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index e3bc497ea..1bec45d0a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -59,6 +59,7 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + pub(crate) oldest_item_id: Option, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 12650f798..54ce0c7be 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5404,19 +5404,29 @@ pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, limit: Option, + messages_only: Option, ) -> Result { 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 diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 9523363ac..ac1ae9a8b 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -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(null); - const directHistoryLoadingRef = useRef(false); + const directHistoryLoadingRef = useRef(null); const [pendingCommand, setPendingCommand] = useState( 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( @@ -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( '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; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..32180fcff 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -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; + 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> = {}, diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 01e5f217d..a1ab75aad 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -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') { diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index f041845b6..21ec594a1 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -9133,6 +9133,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { projectPath, limit: 20, + messagesOnly: true, }), ); // 默认任务占位行也不能触发专业 Agent 历史的批量读取。 diff --git a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx new file mode 100644 index 000000000..9b7b410a0 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx @@ -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((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +function install( + read: ( + args: Record, + ) => DirectThreadHistorySlice | Promise, +) { + const invoke = vi.fn( + async (command: string, args?: Record) => { + 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( + , + ); +} +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); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ff7849dfc 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -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', diff --git a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..97f4d2433 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md @@ -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、编码、文档索引和差异检查通过。 +- 状态只覆盖日志中已经证明的分页卡页,不据此宣称其它可能的未落盘消息也已恢复。 diff --git a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md index d45ed0575..f91406977 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -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 双路径均已本地修复并通过定向验证,待重建原生端后真实客户端验收。本计划保持开放。 diff --git a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md new file mode 100644 index 000000000..a247be381 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md @@ -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. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8cb5e234e..59f167beb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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 资产门禁;缓存与项目切换须保留现有身份隔离。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index e024c1788..990ee7353 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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 时作兜底,不另建实时消息出口。