From b37d91882c1c31ca44ff825a37c7cdc6bc73d642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 17 Sep 2026 14:38:39 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8DDirectProject?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=8E=86=E5=8F=B2=E5=88=86=E9=A1=B5=E5=8D=A1?= =?UTF-8?q?=E9=A1=B5"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1b010fb1f89f7470a0fa99c8ac7dbe5e500077f7. --- .../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, 36 insertions(+), 685 deletions(-) delete mode 100644 apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx delete mode 100644 docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md delete 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 ed9e5e576..130c01549 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,38 +482,9 @@ 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 if before_item_id.is_some() { - Err("DirectProject 历史游标对应的文件已不存在".to_string()) - } else { - Ok(Vec::new()) - }; + return Ok(Vec::new()); } let file = File::open(&path) .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; @@ -548,12 +519,6 @@ fn read_direct_project_history_entries_filtered_at( 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 @@ -562,45 +527,7 @@ fn read_direct_project_history_entries_filtered_at( .unwrap_or(0), )); } - 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, 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, - )) + Ok(items) } pub(crate) fn read_direct_project_history_items_slice_at( @@ -713,165 +640,6 @@ 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 1bec45d0a..e3bc497ea 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,7 +59,6 @@ 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 f5724810b..273d193ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5415,29 +5415,19 @@ 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 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); + let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( + root, + before_item_id.as_deref(), + limit.unwrap_or(20), + )?; 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 ac1ae9a8b..9523363ac 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -239,11 +239,10 @@ import { DeveloperProjectPanels } from './features/project-workspace/DeveloperPr import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { type DirectThreadConsumeResult, - directThreadHistoryPage, + directThreadHistoryItemsToMessages, 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'; @@ -1959,7 +1958,7 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(null); + const directHistoryLoadingRef = useRef(false); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2152,7 +2151,6 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; - directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -4011,8 +4009,6 @@ 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 @@ -4109,7 +4105,6 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryCursor: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4117,16 +4112,16 @@ export function App({ { projectPath: nextProjectPath, limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - messagesOnly: true, }, ).then((slice) => { - const page = directThreadHistoryPage(slice); - loadedDirectHistoryHasMore = page.hasMore; - loadedDirectHistoryCursor = page.cursor; + loadedDirectHistoryHasMore = slice.hasMore; return { path: nextProjectPath, agentId: null, - messages: page.messages, + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), } satisfies LocalConversationResult; }); })() @@ -4223,7 +4218,9 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; + directHistoryOldestItemIdRef.current = + conversationMessages.find((message) => message.messageId) + ?.messageId ?? null; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4284,10 +4281,6 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. - } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } } } @@ -4403,9 +4396,6 @@ 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; @@ -12490,38 +12480,27 @@ 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 === null) { - const loadVersion = projectSupervisorHistoryLoadVersionRef.current; - const beforeItemId = directHistoryOldestItemIdRef.current; - directHistoryLoadingRef.current = loadVersion; - const isCurrentLoad = () => - manifestRefreshMountedRef.current && - localProjectPathRef.current === projectPath && - projectSupervisorHistoryLoadVersionRef.current === loadVersion; + if (invoke && projectPath && !directHistoryLoadingRef.current) { + directHistoryLoadingRef.current = true; try { const slice = await invoke( 'read_direct_project_history_slice', { projectPath, - beforeItemId, + beforeItemId: directHistoryOldestItemIdRef.current, limit: CONVERSATION_VISIBLE_STEP, - messagesOnly: true, }, ); - if (!isCurrentLoad()) { + if (localProjectPathRef.current !== projectPath) { return; } - const page = directThreadHistoryPage(slice, beforeItemId); - const older = page.messages.map((message) => ({ + const older = directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ).map((message) => ({ role: message.role === 'user' ? ('user' as const) @@ -12531,21 +12510,18 @@ export function App({ messageId: message.messageId, updatedAt: message.updatedAt, })); - setMessages((current) => - prependDirectHistoryMessages(current, older), - ); + setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(page.hasMore); - directHistoryOldestItemIdRef.current = page.cursor; + setDirectHistoryHasMore(slice.hasMore); + directHistoryOldestItemIdRef.current = + older.find((message) => message.messageId)?.messageId ?? + directHistoryOldestItemIdRef.current; } catch (error) { - if (!isCurrentLoad()) return; setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, ); } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } + directHistoryLoadingRef.current = false; } } 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 32180fcff..6c1cad229 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,7 +1,4 @@ -import type { - ChatMessage, - LocalConversationMessageRecord, -} from '../../app/types'; +import type { LocalConversationMessageRecord } from '../../app/types'; export type DirectThreadRawEvent = { seq: number; @@ -25,54 +22,8 @@ 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 a1ab75aad..01e5f217d 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, messagesOnly: true }); + expect(args).toEqual({ projectPath, limit: 20 }); 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, messagesOnly: true }); + expect(args).toEqual({ projectPath, limit: 20 }); 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 21c3f481c..49a0d67d6 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 @@ -9139,7 +9139,6 @@ 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 deleted file mode 100644 index 9b7b410a0..000000000 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/** @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 ff7849dfc..128cdf135 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -2,59 +2,10 @@ 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 deleted file mode 100644 index 97f4d2433..000000000 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,25 +0,0 @@ -# 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 9902abd0b..24f16aefa 100644 --- a/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】AGC画布交互稳定性修复-2026-09-16.md @@ -32,7 +32,7 @@ | B02 | 初次进入双指平移无效,整理后恢复 | 已优化;用户在本轮反馈未再复现,按用户要求更新状态。隔离组件连续平移通过。 | | B03 | 快速平移触发更新深度错误 | 已修复已确认的窗口 Context 反馈循环,回归验证收敛;真实操作继续观察。 | | B04 | 资源选中后运行不可用提示消失 | 已修复,提示与选择解耦,自动化验证通过。 | -| B05 | 对话记录偶发丢失 | 已修复日志复现的历史分页卡点:消息模式过滤后分页、原生游标与读取代次隔离。原生只读重放分 3 页取回全部 44 条现存消息,原文件未变;真实客户端待验收,不扩大为其它未落盘记录已恢复。 | +| B05 | 对话记录偶发丢失 | 已定位历史分页卡点,尚未修改。只读复验用户提供的历史:558 条合法原始记录中有 44 条聊天消息;首屏原始 20 条只投影出一条助手消息,下一页原始 20 条没有聊天消息,按消息计算的游标不推进。已存用户提问和最终回答因此无法继续翻出;不能凭该文件排除其它未落盘记录。 | | B06 | JSON 文档未正确识别展示 | 已按用户确认完成本地修复:合法 UI State 由原生完整校验,卡片显示 UI 设计并进入现有编辑器;普通 JSON 显示 JSON 并可代码预览。自动化验证通过,待重建原生客户端验收;详见 JSON 语义识别实施计划。 | | C01 | 右键平移,保留左键框选 | 已实现,卡片左键拖动、框选、指针取消/失焦/捕获丢失及控件边界测试通过。 | @@ -41,4 +41,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 deleted file mode 100644 index a247be381..000000000 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,19 +0,0 @@ -# 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 24a21eea8..ac8fbca0c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,9 +1,5 @@ # 踩坑与排障记录 -## 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 792e58a6c..d69549ea0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,9 +32,6 @@ ## 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 时作兜底,不另建实时消息出口。