From 30c377d0f09c39addecdb7acbf2b912e39f11499 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 18 Sep 2026 02:13:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=94=AF=E4=B8=80=E7=9A=84th?= =?UTF-8?q?read=20manager=E4=BD=9C=E4=B8=BAdirect=20project=E7=9A=84?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E6=9D=A5=E6=BA=90=20(#384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明: 在把工作交给段哥前还没有实现direct project聊天页面的迁移, 导致在 #375 里用很复杂的实现又做了一套事件流, 实测还有会话丢失的bug, 我在这里把数据获取的部分迁移到 #367 上 --------- Co-authored-by: 孔令弘 Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/384 Reviewed-by: 孔令弘 Co-authored-by: 王德宇 Co-committed-by: 王德宇 --- CONTEXT.md | 16 + .../src-tauri/src/agent.rs | 5 +- .../direct_project_identity.rs | 23 + .../src/agent/codex_app_server/mod.rs | 814 ++++++++-- .../src/agent/direct_codex_user_item/mod.rs | 4 +- .../src/agent/direct_codex_user_item/model.rs | 7 - .../src/agent/direct_project_history.rs | 838 ++++++---- .../src/agent/direct_thread_manager.rs | 399 +++-- .../src-tauri/src/agent/direct_thread_wire.rs | 872 +++++++++++ .../src-tauri/src/agent/direct_tool_calls.rs | 293 +--- .../src-tauri/src/agent/direct_turn_stream.rs | 13 +- .../src-tauri/src/commands.rs | 76 +- .../src-tauri/src/main.rs | 2 - apps/ai-game-creator-shell/src/App.tsx | 1117 +++----------- .../src/app/constants.ts | 2 + apps/ai-game-creator-shell/src/app/types.ts | 93 +- .../ProjectSupervisorView.tsx | 439 ++---- .../ResourceReferenceInput.tsx | 21 +- .../project-workspace/ToolCallGroup.tsx | 6 +- .../directHistoryAnchorGate.ts | 59 + .../project-workspace/directHistoryPaging.ts | 84 ++ .../project-workspace/directThreadChat.ts | 272 ++++ .../project-workspace/directThreadEvents.ts | 136 +- .../directThreadItemProjection.ts | 269 ++++ .../directTurnPresentation.ts | 504 ++++--- .../generated/DirectThreadConsumeResult.ts | 4 + .../generated/DirectThreadDeltaKind.ts | 6 + .../generated/DirectThreadEvent.ts | 30 + .../generated/DirectThreadFileChange.ts | 12 + .../generated/DirectThreadHistorySlice.ts | 14 + .../generated/DirectThreadItem.ts | 76 + .../generated/DirectThreadRequestKind.ts | 9 + .../DirectThreadSubscriptionBootstrap.ts | 14 + .../project-workspace/generated/index.ts | 8 + .../toolCallGroupPresentation.ts | 22 +- .../tests/appSurface/chat-composer.suite.ts | 114 +- .../tests/appSurface/harness.ts | 229 +++ .../tests/appSurface/home.suite.ts | 97 +- .../appSurface/project-development.suite.ts | 1344 ++++++++--------- .../tests/directHistoryAnchorGate.test.ts | 108 ++ .../tests/directHistoryPagination.test.tsx | 269 ---- .../tests/directHistoryPaging.test.ts | 253 ++++ .../tests/directThreadChat.test.ts | 214 +++ .../tests/directThreadEvents.test.ts | 88 -- .../tests/directTurnPresentation.test.ts | 431 +++--- docs/README.md | 1 + ...irectProject对话历史单一事实源-2026-09-16.md | 55 + ...实施计划】AGC对话历史分页恢复-2026-09-16.md | 26 - ...施计划】AGC画布交互稳定性修复-2026-09-16.md | 4 +- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 43 + ...里程碑】AGC对话历史分页恢复-2026-09-16.md | 20 - ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 75 + .../【里程碑】对话回合唯一投影-2026-09-16.md | 37 +- .../shared-memory/decision-log.md | 21 + docs/project-memory/shared-memory/pitfalls.md | 15 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 - ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 52 +- ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 15 +- 58 files changed, 5997 insertions(+), 4077 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryAnchorGate.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts create mode 100644 apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts delete mode 100644 apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts create mode 100644 apps/ai-game-creator-shell/tests/directThreadChat.test.ts delete mode 100644 apps/ai-game-creator-shell/tests/directThreadEvents.test.ts create mode 100644 docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md delete mode 100644 docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md create mode 100644 docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md delete mode 100644 docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..da2aca812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI +## 项目开发对话(DirectProject) + +**项目对话历史**: +AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 +_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 + +**运行态事件**: +Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 +_Avoid_: 进度通知、快照轮询、第二套历史 + +**聊天投影**: +把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 +_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 +- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。 +- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index e64b321dd..ced9b0a98 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -40,7 +41,8 @@ use codex_app_server::*; pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, - direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, + direct_thread_id_for_project, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -55,6 +57,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs index 901467454..98f347d30 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/direct_project_identity.rs @@ -20,6 +20,29 @@ pub(crate) fn direct_codex_canonical_project_identity( )) } +/// 项目根目录在 Thread Manager 里的线程身份。 +/// +/// 订阅入口、回合事件写入和"回合被兜底释放"三处必须算出同一个字符串,否则前端会订阅到 +/// 一个永不产生事件的空线程。这个字符串**只取决于路径**:能归一就用 canonical 路径,只有 +/// 归一本身失败(路径不存在 / 不是目录 / 无法安全解析)才退回调用方给的字符串。 +/// +/// 这里刻意不读 `.agent/manifest.json`:那次读取是"项目权威身份"(连接池摘要,见 +/// `direct_codex_canonical_project_identity`)的要求,而线程 id 只是一个路径 key。把 +/// manifest 的瞬时抖动混进线程 id,会让同一项目在"订阅那一刻"与"跑回合那一刻"算出两个 +/// 字符串(例如调用方给的是符号链接路径),订阅就绑到一条永远不会有事件的空线程上。 +pub(crate) fn direct_thread_id_for_project(root: &std::path::Path) -> String { + let Ok((canonical_root, _)) = resolve_direct_codex_project_authority(root) else { + return root.to_string_lossy().into_owned(); + }; + canonical_root + .to_str() + .and_then(|value| value.strip_prefix(r"\\?\")) + .map(std::path::Path::new) + .unwrap_or(canonical_root.as_path()) + .to_string_lossy() + .into_owned() +} + pub(super) fn direct_codex_os_path_identity_bytes(path: &std::path::Path) -> Vec { #[cfg(unix)] { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 457185fc0..7bf8b2484 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -13,8 +13,11 @@ use uuid::Uuid; mod direct_project_history_wire; use direct_project_history_wire::build_direct_project_history_injection_params; mod direct_project_identity; -pub(crate) use direct_project_identity::direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands; use direct_project_identity::*; +pub(crate) use direct_project_identity::{ + direct_codex_canonical_project_identity as direct_codex_canonical_project_identity_for_commands, + direct_thread_id_for_project, +}; const GAME_CREATOR_CODEX_APP_SERVER_PROVIDER_ID: &str = "genarrative_agc"; const GAME_CREATOR_CODEX_APP_SERVER_API_KEY_ENV: &str = "GENARRATIVE_AGC_CODEX_API_KEY"; @@ -577,6 +580,17 @@ enum CodexTurnEvent { item_id: String, delta: String, }, + /// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。 + /// + /// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与 + /// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是 + /// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed` + /// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为 + /// 活动状态,不下发正文。 + ReasoningDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { @@ -584,7 +598,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - event_type: &'static str, + kind: DirectThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -754,23 +768,33 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// Project an app-server item into the small public payload carried by the -/// DirectProject event queue. Full item contents are persisted in JSONL and -/// must not be forwarded through the runtime event stream. -fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "itemType": item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - }) +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 +/// +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 +fn direct_thread_event_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } -fn direct_thread_item_id(item: &serde_json::Value) -> Option { - item.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) +/// 运行态条目投影:Codex 回显的用户消息整条跳过。 +/// +/// AGC 是 DirectProject 用户消息的唯一来源——回合入口在 `turn/start` 之前就把 +/// `direct-codex:{clientTurnId}:user` 落盘,并下发同一条运行态条目。app-server 之后 +/// 回显的 `userMessage`(`item/started`)和原始 `role=user` item +/// (`rawResponseItem/completed`)只用于观察和关联:落盘侧已由 +/// `append_direct_project_history_item_at` 过滤,运行态必须用同一口径过滤,否则前端会多 +/// 渲染出两条没有历史对应的孤儿用户气泡,各自开出一个耗时 0 秒的假回合,直到重进页面才 +/// 恢复(那时读的是同一份已过滤的 `project.jsonl`)。 +fn direct_thread_visible_item( + root: &std::path::Path, + item: &serde_json::Value, +) -> Option { + if is_direct_project_codex_user_item(item) { + return None; + } + direct_thread_event_item(root, item) } fn direct_codex_command_is_game_verification(command: &str) -> bool { @@ -987,19 +1011,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some("approval.requested"), - "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"), + | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { + Some(DirectThreadRequestKind::AskRequested) + } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some("request.resolved"), + "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), _ => None, } } @@ -1066,12 +1092,71 @@ fn should_emit_direct_codex_activity( true } +/// 思考正文增量事件:`item/reasoning/summaryTextDelta`(reasoning item 的 `summary`)与 +/// `item/reasoning/textDelta`(它的 `content`)都下发正文,不降级成"preparing 活动文本"。 +/// +/// 这里只是把"完成时才看到"提前为"边生成边看到":两段文本本来就随 `item/completed` +/// 落进 `project.jsonl` 并展示给用户,可见范围没有放宽;未识别的 plan 文本与命令输出 +/// 仍然只降级为活动状态。运行态读取器和 `direct_codex_notification_event` 共用这一处, +/// 避免两份实现再次分叉(分叉时就出现过"生产路径从不产生 ReasoningDelta")。 +fn direct_codex_reasoning_delta_event( + method: &str, + params: &serde_json::Value, +) -> Option { + if !matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return None; + } + params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }) +} + +/// DirectProject 的流式正文在进入 Thread Manager 前就完成脱敏;前端不得接触原始增量。 +fn direct_codex_thread_delta_event( + root: &std::path::Path, + item_id: String, + kind: DirectThreadDeltaKind, + delta: &str, +) -> DirectThreadEvent { + DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta)) +} + +/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 +/// +/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 +/// 再做一遍——两份实现分叉过一次,结果是生产路径从不产生 `ReasoningDelta`。 +/// +/// Preparing notifications may carry private plan/reasoning text; expose only the safe +/// activity category. Other categories may retain their bounded, redacted intermediate text. fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, + turn_id: &str, ) -> Option { + if let Some(event) = direct_codex_reasoning_delta_event(method, params) { + return Some(event); + } + if let Some(kind) = direct_codex_resolution_event_type(method) { + return Some(CodexTurnEvent::Request { + kind, + params: params.clone(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -1086,14 +1171,19 @@ fn direct_codex_notification_event( "item/agentMessage/delta" => params .get("delta") .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) + .filter(|value| !value.is_empty()) .map(|delta| { let item_id = params .get("itemId") .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()); + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); CodexTurnEvent::AgentMessageDelta { item_id, delta: delta.to_string(), @@ -1109,6 +1199,13 @@ fn direct_codex_notification_event( .cloned() .unwrap_or(serde_json::Value::Null), )), + method if direct_codex_request_event_type(method).is_some() => { + Some(CodexTurnEvent::Request { + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), + params: params.clone(), + }) + } _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -2810,6 +2907,9 @@ impl CodexAppServerConnection { .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); + // 用户消息在这一轮开始前就落盘;把它作为本回合的第一条运行态条目下发, + // 前端就能用同一个 itemId 把"本地乐观气泡"和"历史里的同一条"合成一条。 + let mut direct_persisted_user_item: Option = None; if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2833,6 +2933,7 @@ impl CodexAppServerConnection { }; append_direct_project_user_message_at(history_root, &user_item) .map_err(platform_llm::LlmError::InvalidRequest)?; + direct_persisted_user_item = Some(user_item); } } let (thread_lease, thread_created) = self.thread_for(snapshot, &request, llm).await?; @@ -2944,20 +3045,17 @@ impl CodexAppServerConnection { } }; turn_start_guard.armed = false; - let direct_thread_id = history_root.to_string_lossy().into_owned(); + let direct_thread_id = direct_thread_id_for_project(history_root); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.started".to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: serde_json::json!({ - "threadId": thread_id, - "turnId": turn_id, - }), - }, - ); + append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); + if let Some(user_item) = direct_persisted_user_item.as_ref() { + if let Some(entry_item) = direct_thread_event_item(history_root, user_item) { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item), + ); + } + } } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); @@ -2975,6 +3073,9 @@ impl CodexAppServerConnection { game_creator_codex_app_server_hard_timeout_ms(self.inner.workspace_mode, timeout_ms); let hard_deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(hard_timeout_ms); + // 记录 app-server 是否已经给出终态:`turn/completed` 只在收到被动 terminal + // 事件时下发,超时 / 传输中断 / 协议错误都走不到那里。 + let mut terminal_recorded = false; let collect = async { let mut final_text = None; let mut streamed_text = String::new(); @@ -3012,12 +3113,14 @@ impl CodexAppServerConnection { direct_project_history.observe_delta(&item_id, &delta); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.delta".to_string(), - turn_id: turn_id.clone(), - item_id: Some(item_id.clone()), - payload: serde_json::json!({ "delta": delta.clone() }), - }, + // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + direct_codex_thread_delta_event( + history_root, + item_id.clone(), + DirectThreadDeltaKind::Message, + &delta, + ), ); } streamed_text.push_str(&delta); @@ -3048,6 +3151,19 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + append_direct_thread_event( + &direct_thread_id, + direct_codex_thread_delta_event( + history_root, + item_id, + DirectThreadDeltaKind::Reasoning, + &delta, + ), + ); + } + } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::IntermediateText(text)); @@ -3060,6 +3176,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } + let entry_item = direct_thread_visible_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3073,19 +3190,15 @@ impl CodexAppServerConnection { })? .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); - let item_id = direct_thread_item_id(&item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.completed".to_string(), - turn_id: turn_id.clone(), - item_id, - payload: serde_json::json!({}), - }, - ); + if let Some(entry_item) = entry_item { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item), + ); + } } } - Some(CodexTurnEvent::Request { event_type, params }) => { + Some(CodexTurnEvent::Request { kind, params }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let request_id = params .get("requestId") @@ -3095,14 +3208,7 @@ impl CodexAppServerConnection { .map(str::to_string); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: request_id - .map(|id| serde_json::json!({ "requestId": id })) - .unwrap_or_else(|| serde_json::json!({})), - }, + DirectThreadEvent::request(kind, request_id), ); } } @@ -3213,16 +3319,14 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - let item_id = direct_thread_item_id(item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.started".to_string(), - turn_id: turn_id.clone(), - item_id, - payload: direct_thread_item_started_payload(item), - }, - ); + if let Some(entry_item) = + direct_thread_visible_item(history_root, item) + { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_started(entry_item), + ); + } } } } @@ -3262,14 +3366,10 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject && matches!(status, "completed" | "interrupted" | "failed") { + terminal_recorded = true; append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.completed".to_string(), - turn_id: turn_id.clone(), - item_id: None, - payload: serde_json::json!({ "status": status }), - }, + DirectThreadEvent::turn_completed(status.to_string()), ); } match status { @@ -3310,7 +3410,26 @@ impl CodexAppServerConnection { } } }; - let text = match collect.await { + let collect_result = collect.await; + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject + && !terminal_recorded + { + // 兜底终态:没有这条事件,前端的"最新回合是否在跑"会一直停在运行中。 + // 事件日志本身不完美(见 `ThreadState::lifecycle_anchor` 的 TODO), + // 但"这一轮有没有结束"必须有终态事件。 + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::turn_completed( + if collect_result.is_ok() { + "completed" + } else { + "failed" + } + .to_string(), + ), + ); + } + let text = match collect_result { Ok(text) => text, Err(error) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3557,6 +3676,12 @@ pub(crate) fn cancel_direct_codex_turn_at( DirectCodexTurnCancelTarget::Stale(reason) => { let released = release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + // 这一轮不会再有人替它发终态事件(执行进程已退出 / 从没进执行器), + // 兜底补一条,否则前端的"最新回合是否在跑"会永远停在运行中。 + append_direct_thread_event( + &direct_thread_id_for_project(root), + DirectThreadEvent::turn_completed("aborted".to_string()), + ); Ok(DirectTurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( @@ -3948,12 +4073,18 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - if let Some(activity) = safe_activity { - let last_activity = last_direct_activity_by_turn - .entry(turn_id.clone()) - .or_default(); - if !should_emit_direct_codex_activity(last_activity, activity) { - continue; + // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 + // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 + // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 + let is_reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms).is_some(); + if !is_reasoning_delta { + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } } } if let Some(text) = intermediate_text.as_deref() { @@ -3964,66 +4095,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event_type) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { event_type, params } - } else if let Some(activity) = safe_activity { - // Preparing notifications may carry private plan/reasoning text; - // expose only the safe activity category. Other categories may - // retain their bounded, redacted intermediate text below. - if activity == "preparing" { - CodexTurnEvent::Activity(activity) - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - CodexTurnEvent::Activity(activity) - } - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - let item_id = params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - eprintln!( - "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" - ); - format!("direct-missing-item:{turn_id}") - }); - CodexTurnEvent::AgentMessageDelta { - item_id, - delta: delta.to_string(), - } - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - "rawResponseItem/completed" => CodexTurnEvent::RawItem( - params - .get("item") - .cloned() - .unwrap_or(serde_json::Value::Null), - ), - method if direct_codex_request_event_type(method).is_some() => { - CodexTurnEvent::Request { - event_type: direct_codex_request_event_type(method) - .expect("request event type checked above"), - params, - } - } - _ => CodexTurnEvent::Terminal(params), - } + let Some(event) = direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + &turn_id, + ) else { + continue; }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -4542,11 +4621,29 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); + // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 + let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + assert_eq!(projected.item_id(), "item-1"); + let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( - direct_thread_item_started_payload(&item), - serde_json::json!({ "itemType": "mcpToolCall" }) + payload.get("itemType").and_then(serde_json::Value::as_str), + Some("mcpToolCall") ); + assert_eq!( + payload.get("itemId").and_then(serde_json::Value::as_str), + Some("item-1") + ); + // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 + assert!(payload.get("toolCall").is_none(), "{payload}"); + assert!(payload.get("title").is_none(), "{payload}"); + assert!(payload.get("summary").is_none(), "{payload}"); + assert!(payload.get("kind").is_none(), "{payload}"); + // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 + let arguments = payload + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!(!arguments.contains("\"secret\""), "{payload}"); } #[test] @@ -4738,18 +4835,50 @@ mod tests { ); } + /// 判据:思考正文走 `ReasoningDelta` 正文通道(与 ADR 一致),不再被 `preparing` + /// 活动降级吃掉;plan 文本与命令输出仍然只降级成活动类别,不带正文。 #[test] - fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { - let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); - assert!(matches!( - direct_codex_notification_event( + fn direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity() { + let reasoning = serde_json::json!({ "itemId": "reasoning-1", "delta": "思考正文" }); + for method in [ + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + ] { + assert!( + matches!( + direct_codex_reasoning_delta_event(method, &reasoning), + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) + if item_id == "reasoning-1" && delta == "思考正文" + ), + "{method} 必须下发明文思考增量" + ); + // 运行态读取器用的就是这一个分类函数,不能再走「preparing 活动」降级。 + assert!( + matches!( + direct_codex_notification_event( + method, + &reasoning, + Some("思考正文".to_string()), + Some("preparing"), + "turn-1", + ), + Some(CodexTurnEvent::ReasoningDelta { .. }) + ), + "{method} 在通知分类里不能降级成活动" + ); + } + assert!( + direct_codex_reasoning_delta_event( "item/reasoning/textDelta", - &reasoning, - Some("hidden reasoning must not leak".to_string()), - Some("preparing"), - ), - Some(CodexTurnEvent::Activity("preparing")) - )); + &serde_json::json!({ "delta": "" }), + ) + .is_none(), + "空增量不产生正文事件" + ); + assert!( + direct_codex_reasoning_delta_event("turn/plan/updated", &reasoning).is_none(), + "非 reasoning 通知不进正文通道" + ); let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); assert!(matches!( @@ -4758,6 +4887,7 @@ mod tests { &plan, Some("private plan text must not leak".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::Activity("preparing")) )); @@ -4769,11 +4899,113 @@ mod tests { &command_output, None, Some("command-exec"), + "turn-1", ), Some(CodexTurnEvent::Activity("command-exec")) )); } + #[test] + fn direct_thread_message_and_reasoning_deltas_are_sanitized_before_enqueue() { + let root = std::path::Path::new("/workspace/direct-project"); + let raw = "key=sk-abcdefghijklmnop project=/workspace/direct-project/assets/a.png private=/root/secret.txt"; + for kind in [ + DirectThreadDeltaKind::Message, + DirectThreadDeltaKind::Reasoning, + ] { + let event = direct_codex_thread_delta_event(root, "item-1".to_string(), kind, raw); + let wire = serde_json::to_string(&event).expect("serialize direct thread delta"); + assert!( + !wire.contains("sk-abcdefghijklmnop"), + "不得泄漏密钥:{wire}" + ); + assert!( + !wire.contains("/root/secret.txt"), + "不得泄漏绝对路径:{wire}" + ); + assert!( + wire.contains("assets/a.png"), + "项目内绝对路径应归一成相对路径:{wire}" + ); + } + } + + /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 + /// + /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 + #[test] + fn direct_notification_classification_covers_the_reader_branches() { + let empty = serde_json::json!({}); + let resolved = serde_json::json!({ "requestId": "request-1" }); + assert!(matches!( + direct_codex_notification_event( + "serverRequest/resolved", + &resolved, + None, + None, + "turn-1" + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::RequestResolved, + .. + }) + )); + let approval = serde_json::json!({ "requestId": "request-2" }); + assert!(matches!( + direct_codex_notification_event( + "item/fileChange/requestApproval", + &approval, + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::ApprovalRequested, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event("item/started", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Item { + completed: false, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event( + "rawResponseItem/completed", + &serde_json::json!({ "item": { "type": "reasoning" } }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::RawItem(_)) + )); + // 空正文的 assistant 增量既不产事件,也不产 Terminal 兜底。 + assert!(direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "" }), + None, + None, + "turn-1", + ) + .is_none()); + assert!(matches!( + direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "正文" }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::AgentMessageDelta { delta, .. }) if delta == "正文" + )); + assert!(matches!( + direct_codex_notification_event("turn/completed", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Terminal(_)) + )); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { custom_enabled: false, @@ -5135,6 +5367,50 @@ mod tests { assert_ne!(first_identity, second_identity); } + /// 判据:线程 id 只由路径决定,不受 manifest 可读性影响。 + /// + /// 变异验证:线程 id 走 `direct_codex_canonical_project_identity`(旧实现)时,manifest + /// 读不到会让非 canonical 的调用方路径退回"原始字符串",订阅与回合事件因此落在两条线程上。 + #[cfg(unix)] + #[test] + fn direct_project_thread_id_ignores_manifest_readability() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("thread-id-project"); + init_local_game_project_at(&project, "thread-id-project", "线程身份项目") + .expect("initialize project"); + let stable_link = temp.path().join("current-project"); + symlink(&project, &stable_link).expect("project symlink"); + + let canonical = direct_thread_id_for_project(&project); + assert_eq!( + canonical, + project + .canonicalize() + .expect("canonical project") + .to_string_lossy(), + "线程 id 就是 canonical 路径" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "符号链接必须归一到同一个线程 id" + ); + + // manifest 暂时读不到(项目刚创建 / 正被替换)不能让线程 id 变样。 + std::fs::remove_file(project.join(".agent/manifest.json")).expect("remove manifest"); + assert!( + direct_codex_canonical_project_identity(&project).is_err(), + "前提:manifest 读不到时权威身份确实会失败" + ); + assert_eq!( + direct_thread_id_for_project(&stable_link), + canonical, + "manifest 读失败不能把线程 id 退回调用方原始字符串" + ); + } + #[test] fn direct_project_pool_identity_changes_when_manifest_identity_is_replaced_in_place() { let temp = tempfile::tempdir().expect("temp dir"); @@ -6298,6 +6574,228 @@ while IFS= read -r line; do :; done assert_eq!(connection.inner.threads.lock().await.len(), 1); } + /// 判据:生产读取器不再把思考正文降级成 `preparing` 活动。 + /// + /// fixture 刻意不发 `turn/started` 等先导活动,所以"活动节流"这条退路不存在: + /// 一旦读取器把 `item/reasoning/*Delta` 归回活动通道,observations 里就会出现 + /// `Activity("preparing")`。同时明文思考只走 DirectProject 正文通道, + /// 不得漏进旧的运行态 observation。 + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_streams_reasoning_deltas_without_activity_fallback() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-reasoning-delta"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 51 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 52 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*) ;; *) exit 53 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*) ;; *) exit 54 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"SECRET_REASONING_TEXT"}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":[]}"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); + connection + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + None, + Some(&mut observer), + None, + ) + .await + .expect("run fake app-server turn"); + drop(observer); + assert!( + !observations.iter().any(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity("preparing") + )), + "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" + ); + assert!( + !format!("{observations:?}").contains("SECRET_REASONING_TEXT"), + "明文思考不得漏进运行态 observation" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn direct_project_turn_does_not_forward_codex_user_echo_as_chat_items() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let project = temp.path().join("direct-user-echo-project"); + crate::init_local_game_project_at(&project, "direct-user-echo", "回显过滤") + .expect("init project"); + let executable = temp.path().join("fake-codex-app-server-direct-user-echo"); + std::fs::write( + &executable, + r#"#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) printf '{"id":%s,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}\n' "$id" ;; + *'"method":"skills/extraRoots/set"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"skills/list"'*) printf '{"id":%s,"result":{"data":[{"skills":[{"name":"agc-browser-playtest"},{"name":"agc-client-projection"},{"name":"agc-game-production-workflow"},{"name":"agc-project-structure"},{"name":"agc-web-game-development"},{"name":"taonier-art-assets"}],"errors":[]}]}}\n' "$id" ;; + *'"method":"thread/start"'*) printf '{"id":%s,"result":{"thread":{"id":"thread-echo"}}}\n' "$id" ;; + *'"method":"thread/inject_items"'*) printf '{"id":%s,"result":{}}\n' "$id" ;; + *'"method":"turn/start"'*) + printf '{"id":%s,"result":{"turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}\n' "$id" + printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"inProgress"}}}' + printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-user-echo-1","type":"userMessage","clientId":"turn-0001","content":[{"type":"text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"codex-raw-user-echo-1","type":"message","role":"user","content":[{"type":"input_text","text":"请创建菜单"}]}}}' + printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-echo","turnId":"turn-echo","itemId":"item-echo-1","delta":"好的"}}' + printf '%s\n' '{"method":"item/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"agentMessage","text":"好的"}}}' + printf '%s\n' '{"method":"rawResponseItem/completed","params":{"threadId":"thread-echo","turnId":"turn-echo","item":{"id":"item-echo-1","type":"message","role":"assistant","content":[{"type":"output_text","text":"好的"}]}}}' + printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-echo","turn":{"id":"turn-echo","items":[],"status":"completed"}}}' + ;; + esac +done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let credential = CodexAppServerCredential::AppDataKey { + fingerprint: "fixture-credential".to_string(), + }; + let connection = + CodexAppServerConnection::spawn_with_executable_and_credential_at_workspace( + &llm, + &credential, + executable.as_os_str(), + Some(&project), + CodexAppServerWorkspaceMode::DirectProject, + ) + .await + .expect("spawn direct-project app-server"); + + let user_item = serde_json::json!({ + "type": "message", + "role": "user", + "id": "direct-codex:turn-0001:user", + "content": [{"type": "input_text", "text": "请创建菜单"}] + }); + let thread_id = direct_thread_id_for_project(&project); + let bootstrap = crate::agent::subscribe_direct_thread(&thread_id); + assert!( + bootstrap.events.is_empty(), + "订阅发生在回合之前,bootstrap 必须为空" + ); + // 生产入口(`chat_with_game_creator_direct_codex`)在发起回合前登记本客户端的 + // 付费生成身份;工具桥在这一轮里按它绑定付费调用,这里补上同一步。 + let _active_invocation = + crate::agent::DirectTaonierActiveInvocationGuard::enter(&project, "turn-0001") + .expect("enter direct invocation"); + let mut observer = |_observation| {}; + connection + .run_turn_with_direct_observer_and_history( + &test_snapshot(), + &llm, + LlmRunRequest::single_turn("系统", "请创建菜单"), + Some(&project), + Some("turn-0001"), + Some(&user_item), + None, + Some(&mut observer), + None, + ) + .await + .expect("run direct-project turn"); + drop(observer); + + let consumed = crate::agent::consume_direct_thread(&bootstrap.subscription_id) + .expect("consume events"); + let mut user_items = Vec::new(); + let mut assistant_items = Vec::new(); + for event in &consumed.events { + let item = match event { + DirectThreadEvent::ItemStarted { item } + | DirectThreadEvent::ItemCompleted { item } => item, + _ => continue, + }; + match item { + DirectThreadItem::Message { + item_id, + role, + text, + .. + } if role.as_str() == "user" => { + user_items.push((item_id.clone(), text.clone())); + } + DirectThreadItem::Message { text, .. } if !text.trim().is_empty() => { + assistant_items.push(text.clone()); + } + _ => {} + } + } + assert_eq!( + user_items, + vec![( + "direct-codex:turn-0001:user".to_string(), + "请创建菜单".to_string() + )], + "Codex 回显的用户消息不得再作为聊天条目下发,否则前端会渲染出多余的孤儿用户气泡" + ); + assert_eq!( + assistant_items, + vec!["好的".to_string()], + "assistant 正文仍必须按同一 itemId 下发一次" + ); + let history = read_direct_project_history_items_at(&project).expect("read history"); + let history_user_items = history + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("user")) + .cloned() + .collect::>(); + assert!( + history + .iter() + .any(|item| item.get("id").and_then(serde_json::Value::as_str) + == Some("item-echo-1")), + "assistant 与工具条目仍必须落盘:{history:?}" + ); + assert_eq!( + history_user_items, + vec![user_item], + "回显的用户消息也不得落盘,历史里只能有 AGC 自己那条" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index d5ccc94c4..e33bfae72 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -5,8 +5,8 @@ mod validation; mod wire; pub(crate) use model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageEnvelope, - DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, + DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem, + DirectCodexUserRole, DirectCodexUserRuntimeRegionPart, }; pub(crate) use validation::validate_direct_codex_user_item; pub(crate) use wire::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index b46336b66..266f330cc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -61,13 +61,6 @@ pub(crate) struct DirectCodexUserRuntimeRegionPart { pub(crate) resource_ids: Vec, } -#[derive(Clone, Debug, Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] -pub(crate) struct DirectCodexUserMessageEnvelope { - pub(crate) item: DirectCodexUserItem, -} - #[cfg(test)] mod tests { use super::*; 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..f1e6679bb 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 @@ -1,3 +1,4 @@ +use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ @@ -102,63 +103,127 @@ fn record(item: &Value) -> Result { const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; +/// 从文件尾向前回读到的一行。 +/// +/// `terminated` 表示这行后面确实有换行符:整份历史里只有末尾那一段可能没有换行,那一段是 +/// append 侧承诺会修复的截断尾。解析失败时跳过这一行继续往前回扫,而不是报错——它是回扫 +/// 见到的第一行,一旦就地结束就会把后面完整的更早历史全部丢掉。 +struct DirectProjectHistoryReverseLine { + bytes: Vec, + terminated: bool, +} + +/// 从文件尾向前逐行产出 `project.jsonl`。 +/// +/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套 +/// 按块回读,"读一屏"不必再从文件头逐行读到尾。 +struct DirectProjectHistoryReverseLines { + path: PathBuf, + file: File, + /// 已读进内存、但还没被换行切出来的更早字节。 + pending: Vec, + chunk: Vec, + /// 下一次回读的起始偏移;0 表示文件头已经读完。 + position: u64, + /// 是否已经切过一次换行:切过之后,接下来产出的行前面一定还有换行分隔。 + saw_delimiter: bool, +} + +impl DirectProjectHistoryReverseLines { + fn open(path: &Path) -> Result { + let file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + Ok(Self { + path: path.to_path_buf(), + file, + pending: Vec::new(), + chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES], + position, + saw_delimiter: false, + }) + } + + fn next_line(&mut self) -> Result, String> { + loop { + if let Some(newline) = self.pending.iter().rposition(|byte| *byte == b'\n') { + let bytes = self.pending[newline + 1..].to_vec(); + // `pending` 的尾部始终是文件尾,所以第一次切出来的这一段后面有没有换行, + // 就是"整个文件是否以换行结尾";之后切出来的行都被刚切掉的那个换行分隔。 + let terminated = if self.saw_delimiter { + true + } else { + self.pending.last() == Some(&b'\n') + }; + self.pending.truncate(newline); + self.saw_delimiter = true; + return Ok(Some(DirectProjectHistoryReverseLine { bytes, terminated })); + } + if self.position == 0 { + if self.pending.is_empty() { + return Ok(None); + } + // 走到这里说明剩下的字节里没有换行:只有文件根本没被换行分隔过, + // 或者这就是第一行。前者是唯一没有换行结尾的行。 + let terminated = self.saw_delimiter; + return Ok(Some(DirectProjectHistoryReverseLine { + bytes: std::mem::take(&mut self.pending), + terminated, + })); + } + let read_len = usize::try_from(self.position) + .unwrap_or(usize::MAX) + .min(self.chunk.len()); + self.position -= read_len as u64; + self.file + .seek(SeekFrom::Start(self.position)) + .map_err(|error| { + format!( + "定位 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + self.file + .read_exact(&mut self.chunk[..read_len]) + .map_err(|error| { + format!( + "读取 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + let mut combined = self.chunk[..read_len].to_vec(); + combined.extend_from_slice(&self.pending); + self.pending = combined; + } + } +} + fn find_direct_project_history_item_by_id_at( path: &Path, item_id: &str, ) -> Result, String> { fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED); - let mut file = File::open(path) - .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; - let mut position = file - .metadata() - .map_err(|error| { - format!( - "读取 DirectProject 历史元数据失败:{}: {error}", - path.display() - ) - })? - .len(); - let mut pending = Vec::new(); - let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; - - loop { - if position == 0 { - break; - } - let read_len = usize::try_from(position) - .unwrap_or(usize::MAX) - .min(chunk.len()); - position -= read_len as u64; - file.seek(SeekFrom::Start(position)) - .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; - file.read_exact(&mut chunk[..read_len]) - .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; - - let mut combined = Vec::with_capacity(read_len + pending.len()); - combined.extend_from_slice(&chunk[..read_len]); - combined.extend_from_slice(&pending); - let mut line_end = combined.len(); - while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { - let line = &combined[newline + 1..line_end]; - if !line.is_empty() { - if let Some(item) = direct_project_history_item_from_line(path, line)? { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + let mut lines = DirectProjectHistoryReverseLines::open(path)?; + while let Some(line) = lines.next_line()? { + match direct_project_history_item_from_line(path, &line.bytes) { + Ok(Some(item)) => { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); } } - line_end = newline; - } - pending = combined[..line_end].to_vec(); - } - - if !pending.is_empty() { - // The append path repairs an unterminated final JSONL record before - // writing. A duplicate scan must not reject that repairable tail. - if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + Ok(None) => {} + // 末行没有换行符:这是 append 侧会修复的截断尾,跳过它继续往前找, + // 不能因为尾部半行就让整次幂等回扫失败。 + Err(_) if !line.terminated => continue, + Err(error) => return Err(error), } } Ok(None) @@ -427,7 +492,12 @@ fn append_direct_project_history_item_once(root: &Path, item: &Value) -> Result< append_jsonl_line_unlocked(&path, &line, "DirectProject 历史") } -fn is_direct_project_codex_user_item(item: &Value) -> bool { +/// Codex app-server 回显的用户条目(`userMessage` / 非 AGC 的 `role=user` message)。 +/// +/// AGC 自己预写的用户条目 id 固定是 `direct-codex:{clientTurnId}:user`,因此这里必须 +/// 把两者区分开:落盘侧(本模块)与运行态事件侧(`codex_app_server`)共用同一口径, +/// 任何一侧漏判都会让同一条用户消息出现第二个身份。 +pub(crate) fn is_direct_project_codex_user_item(item: &Value) -> bool { if item.get("type").and_then(Value::as_str) == Some("userMessage") { return true; } @@ -482,38 +552,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 +589,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,87 +597,148 @@ 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), + Ok(items) +} + +/// 分页锚点用的原始 item id:`project.jsonl` 里这条条目自己的 `id`。 +/// +/// 工具条目在文件里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一的聊天 +/// 身份因此不唯一,当锚点会让翻页原地打转;缺 `id` 的条目才退回归一身份兜底。 +fn direct_project_history_anchor_id(item: &Value) -> Option { + item.get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| direct_thread_item_identity(item)) +} + +/// 一屏历史窗口在新端(较新一侧)的锚点。 +/// +/// 锚点一律是 `project.jsonl` 里的原始 item id(见 [`direct_project_history_anchor_id`])。 +pub(crate) enum DirectProjectHistoryAnchor<'a> { + /// 不锚定:直接取文件尾最近的一屏。 + Newest, + /// 取锚点条目**之前**的一屏:锚点本身不进窗口,是"下一屏"的边界(向后翻页用)。 + Before(&'a str), + /// 取到锚点条目**为止**的一屏:锚点在窗口内(订阅回执给出的首屏边界用)。 + Through(&'a str), +} + +impl<'a> DirectProjectHistoryAnchor<'a> { + /// 锚点的原始 item id;不锚定时为 `None`。 + fn item_id(&self) -> Option<&'a str> { + match self { + Self::Newest => None, + Self::Before(item_id) | Self::Through(item_id) => Some(item_id), + } + } + + /// 锚点条目本身是否属于窗口:`Through` 含锚点,`Before` 把锚点留给下一屏。 + fn includes_anchor(&self) -> bool { + matches!(self, Self::Through(_)) + } + + /// 这一条是不是锚点条目(按原始 item id 比对,不看归一身份)。 + fn matches_item(&self, item: &Value) -> bool { + self.item_id() + .is_some_and(|wanted| direct_project_history_anchor_id(item).as_deref() == Some(wanted)) } } -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, - )) -} - +/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。 +/// +/// 窗口的新端由 `anchor` 给出:`Before` 用于向后翻页(锚点本身不进窗口),`Through` 用于订阅 +/// 回执给出的首屏边界(锚点进窗口),`Newest` 直接取文件尾。收满 `limit` 条可显示条目后再多 +/// 看一眼"还有没有更早的条目"就停,不回读整份历史。 pub(crate) fn read_direct_project_history_items_slice_at( root: &Path, - before_item_id: Option<&str>, + anchor: DirectProjectHistoryAnchor<'_>, limit: usize, -) -> Result<(Vec, bool, BTreeMap), String> { - let items = read_direct_project_history_entries_at(root)?; - let end = match before_item_id { - Some(item_id) => items - .iter() - .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) - .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, - None => items.len(), - }; +) -> Result<(Vec, bool, BTreeMap, Option), String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok((Vec::new(), false, BTreeMap::new(), None)); + } let bounded_limit = limit.clamp(1, 200); - let start = end.saturating_sub(bounded_limit); - let slice = &items[start..end]; - let timestamps = slice + let mut lines = DirectProjectHistoryReverseLines::open(&path)?; + let mut newest_first: Vec<(Value, u64)> = Vec::new(); + // 锚点命中之前先跳过更新的条目(首屏的 `Through` 会把订阅回执之后才完成的条目挡在外面)。 + let mut anchor_seen = matches!(anchor, DirectProjectHistoryAnchor::Newest); + let mut has_more = false; + while let Some(line) = lines.next_line()? { + if line.bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let parsed: Value = match serde_json::from_slice(&line.bytes) { + Ok(value) => value, + // 与顺序读取一致:只有末行没有换行符时才是"可修复的截断尾"。回扫是从文件尾向前的, + // 这一行是最新的一条,跳过它继续读更早的完整行,不能因此把整屏历史判成空的。 + Err(_) if !line.terminated => continue, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )) + } + }; + let Some(item) = direct_project_history_item_from_parsed_line(&path, &parsed)? else { + continue; + }; + if !anchor_seen { + if !anchor.matches_item(&item) { + continue; + } + anchor_seen = true; + // `Before` 的锚点所在行本身不进窗口:它是"下一屏"的边界。 + if !anchor.includes_anchor() { + continue; + } + } + if is_direct_project_internal_context_item(&item) { + continue; + } + if newest_first.len() == bounded_limit { + // 收满一屏之后再见一条可显示条目,就足以说明还有更早的历史。 + has_more = true; + break; + } + let recorded_at = parsed + .get("recordedAt") + .and_then(Value::as_u64) + .unwrap_or(0); + newest_first.push((item, recorded_at)); + } + if !anchor_seen { + return Err(format!( + "DirectProject 历史中不存在 item:{}", + anchor.item_id().unwrap_or_default() + )); + } + newest_first.reverse(); + let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let id = item.get("id").and_then(Value::as_str)?; - (*at > 0).then(|| (id.to_string(), *at)) + let identity = direct_thread_item_identity(item)?; + (*at > 0).then_some((identity, *at)) }) .collect(); + let first_item_id = newest_first + .first() + .and_then(|(item, _)| direct_project_history_anchor_id(item)); Ok(( - slice.iter().map(|(item, _)| item.clone()).collect(), - start > 0, - timestamps, + newest_first.into_iter().map(|(item, _)| item).collect(), + has_more, + recorded_at_ms, + first_item_id, )) } +/// 最新一条可显示条目的 itemId:首屏历史锚点。 +/// +/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。 pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { - Ok(read_direct_project_history_items_at(root)? - .into_iter() - .rev() - .find_map(|item| { - item.get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string) - })) + Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3) } pub(crate) fn read_direct_project_chat_history_at( @@ -692,6 +788,7 @@ mod tests { append_direct_project_history_item_at, append_direct_project_user_message_at, direct_project_history_scan_probe, history_path, is_direct_project_internal_context_item, read_direct_project_chat_history_at, read_direct_project_history_items_at, + DirectProjectHistoryAnchor as Anchor, }; use serde_json::{json, Value}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -713,165 +810,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"); @@ -880,13 +818,15 @@ mod tests { "content": [{"type": "input_text", "text": "修改游戏"}], }); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (items, _, timestamps) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (items, _, timestamps, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(items, vec![item.clone()]); assert!(timestamps["sent-message"] > 0); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (_, _, reloaded) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (_, _, reloaded, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert_eq!(timestamps, reloaded); } @@ -894,8 +834,9 @@ mod tests { fn old_history_without_envelope_time_stays_unknown() { let root = init_history_project("history-unknown-time"); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); - let (_, _, timestamps) = - super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + let (_, _, timestamps, _) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); assert!(timestamps.is_empty()); assert_eq!( read_direct_project_chat_history_at(root.path()) @@ -906,6 +847,265 @@ mod tests { ); } + /// 判据:读一屏只回扫文件尾,不碰文件头。 + /// + /// 文件头放一条坏行(JSON 不合法),只要读取只覆盖"尾部一屏 + 一条"的范围就必须成功; + /// 一旦实现退回"从文件头逐行读到尾",这条用例会因为坏行直接失败。 + #[test] + fn history_window_reads_from_the_tail_without_parsing_the_head() { + let root = init_history_project("history-tail-scan"); + let head = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"head-broken","content":[{"type":"input_text","text":"坏行"}"#; + write_history_lines( + root.path(), + &[ + head, + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + RESPONSE_ITEM_ROW, + ], + ); + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 1) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + } + + /// 判据:分页锚点是"本窗口最老一条的原始 item id",逐屏向前不重不漏。 + #[test] + fn history_window_paginates_upwards_by_item_id_anchor() { + let root = init_history_project("history-pagination"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2", "codex-item-3"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:首屏窗口的新端边界由 `Through` 锚点给出并**含**锚点条目。 + /// + /// 订阅回执里的 `lastCompletedItemId` 就是这条:比它更新的条目属于运行态事件,不能再从 + /// 历史带一遍,否则同一条目在历史与实时各来一次(此前只靠前端合并兜住)。 + #[test] + fn history_window_through_anchor_includes_it_and_drops_newer_items() { + let root = init_history_project("history-through-anchor"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-2"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!( + ids, + vec!["codex-item-1", "codex-item-2"], + "锚点本身在窗口内,比它更新的条目必须留给运行态" + ); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:锚点就是文件里最老一条时,`Through` 窗口只有它一条且 `hasMore=false`;同一份历史用 + /// `Before` 取锚点**之前**的一屏会取空——两者不是同一个窗口,方向搞反就会凭空多给一屏或吞掉一条。 + #[test] + fn history_window_through_anchor_at_the_oldest_item_still_keeps_it() { + let root = init_history_project("history-through-anchor-oldest"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + ], + ); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Through("codex-item-1"), + 20, + ) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + + let (empty, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before("codex-item-1"), + 20, + ) + .unwrap(); + assert!( + empty.is_empty(), + "`Before` 的锚点是下一屏的边界,本身不进窗口" + ); + assert!(!has_more); + assert_eq!(first_item_id, None); + } + + /// 判据:锚点在历史里不存在时失败关闭,错误里带锚点 id(首屏与翻页共用这条)。 + /// + /// 变异验证:把"锚点没命中"当成"读完整个文件"(例如沿用旧的 `anchor_seen` 初始化)时, + /// 首屏会绕过订阅回执的边界去取文件尾,这条用例与上面的 `Through` 用例会一起变红。 + #[test] + fn history_window_unknown_anchor_fails_closed() { + let root = init_history_project("history-unknown-anchor"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + + for anchor in [ + Anchor::Through("codex-missing"), + Anchor::Before("codex-missing"), + ] { + let error = super::read_direct_project_history_items_slice_at(root.path(), anchor, 20) + .expect_err("不存在的锚点必须失败关闭"); + assert!( + error.contains("不存在 item:codex-missing"), + "错误里要带锚点 id:{error}" + ); + } + } + + /// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。 + /// + /// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接 + /// 报解析失败;就地结束则返回空窗口,把更早的完整历史全部丢掉。 + #[test] + fn truncated_history_tail_is_skipped_without_losing_earlier_items() { + let root = init_history_project("history-truncated-tail"); + let path = history_path(root.path()); + std::fs::create_dir_all(path.parent().expect("history parent")).expect("history dir"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + let partial_tail = r#"{"type":"response_item","payload":{"type":"mess"#; + std::fs::write( + &path, + format!("{first_row}\n{RESPONSE_ITEM_ROW}\n{partial_tail}"), + ) + .expect("write truncated history tail"); + + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1", "codex-item-2"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + + /// 判据:只有"末尾没有换行"那一段放宽;换行结尾的坏行一律失败关闭,第一段也不例外。 + /// + /// 变异验证:把第一段也当可修复尾行(旧实现)时,这条用例会静默返回空历史而不是报错。 + #[test] + fn corrupt_first_line_fails_closed_like_any_newline_terminated_line() { + let root = init_history_project("history-corrupt-head"); + let first_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines( + root.path(), + &[ + r#"{"type":"response_item","payload":{"type":"mess"#, + &first_row, + ], + ); + + let error = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20) + .expect_err("换行结尾的坏行必须失败关闭"); + assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}"); + } + + /// 判据:分页锚点取文件里的原始 item id。工具条目的调用与输出共用调用 id,归一身份不唯一。 + /// + /// 变异验证:锚点按归一身份算(旧实现)时,翻页会先跳过同身份的 output,再把上一屏已经 + /// 显示过的 function_call 重新带进这一屏。 + #[test] + fn pagination_anchor_uses_raw_item_id_for_tool_call_pairs() { + let root = init_history_project("history-tool-call-anchor"); + let call_row = r#"{"type":"response_item","payload":{"type":"function_call","id":"fc-1","call_id":"call-1","name":"exec_command","arguments":"{\"cmd\":\"ls\"}"}}"#; + let output_row = r#"{"type":"response_item","payload":{"type":"function_call_output","id":"fc-2","call_id":"call-1","output":"ok"}}"#; + let earlier_row = RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"); + write_history_lines(root.path(), &[&earlier_row, call_row, output_row]); + + // 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。 + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2) + .unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["fc-1", "fc-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("fc-1")); + + // 用锚点翻上一屏:不能再把 fc-1 带一遍,也不能原地返回同一屏。 + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + Anchor::Before(first_item_id.as_deref().expect("分页锚点")), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 @@ -980,8 +1180,8 @@ mod tests { /// 判据:格式类失败**不**触发重试。同一份历史每次读都是同一个结论,重试只是白等 /// (而且会把"收尾失败"再拖一轮)。注入次数因此必须原样留着。 /// - /// fixture 形状说明:回扫只对"后面还有换行的完整行"做严格解析,文件里第一段(以及末尾 - /// 没有换行的那一段)按可修复尾行放宽。所以损坏行必须夹在两条完整行中间才会失败关闭。 + /// fixture 形状说明:回扫只对"末尾没有换行的那一段"按可修复尾行放宽,其余每一行(包括 + /// 文件第一段)都必须有换行结尾并被严格解析。所以损坏行夹在两条完整行中间必然失败关闭。 #[test] fn shape_failure_is_not_retried() { let root = init_history_project("shape-no-retry"); 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..a30816d82 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 @@ -4,12 +4,14 @@ //! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL //! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 -use serde::{Deserialize, Serialize}; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; +use crate::agent::{ + DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, +}; + const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -19,52 +21,11 @@ pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread- static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawEvent { - pub(crate) seq: u64, - #[serde(rename = "type")] - pub(crate) event_type: String, - pub(crate) turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct DirectThreadRawEventDraft { - pub(crate) event_type: String, - pub(crate) turn_id: String, - pub(crate) item_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadSubscriptionBootstrap { - pub(crate) subscription_id: String, - pub(crate) last_completed_item_id: Option, - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -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, -} - +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 #[derive(Clone, Debug)] struct StoredEvent { - event: DirectThreadRawEvent, + event: DirectThreadEvent, + seq: u64, bytes: usize, cleanable: bool, } @@ -82,8 +43,14 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, - lifecycle_anchor: Option, - last_completed_item_id: Option, + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 + lifecycle_anchor: Option<(u64, DirectThreadEvent)>, subscribers: HashMap, } @@ -97,7 +64,6 @@ impl Default for ThreadState { active_items: HashSet::new(), unresolved_requests: HashSet::new(), lifecycle_anchor: None, - last_completed_item_id: None, subscribers: HashMap::new(), } } @@ -132,33 +98,29 @@ impl DirectThreadManager { pub(crate) fn append( &mut self, thread_id: &str, - draft: DirectThreadRawEventDraft, - ) -> DirectThreadRawEvent { + event: DirectThreadEvent, + ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); - let event = DirectThreadRawEvent { - seq: thread.next_seq, - event_type: draft.event_type, - turn_id: draft.turn_id, - item_id: draft.item_id, - payload: draft.payload, - }; - let cleanable = Self::observe_event(thread, &event); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); let bytes = serde_json::to_vec(&event) .map(|value| value.len()) .unwrap_or_default(); thread.total_bytes = thread.total_bytes.saturating_add(bytes); thread.events.push(StoredEvent { event: event.clone(), + seq, bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_id.as_deref()); - if matches!( - event.event_type.as_str(), - "approval.resolved" | "request.resolved" | "ask.resolved" - ) { - Self::mark_request_events_cleanable(thread, request_id(&event).as_deref()); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } } self.evict(thread_id); event @@ -176,19 +138,20 @@ impl DirectThreadManager { .events .iter() .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, &stored.event, stored.cleanable)) - .map(|stored| stored.event.clone()) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) .collect::>(); - if let Some(anchor) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|event| event.seq == anchor.seq) { - events.push(anchor.clone()); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); } } - events.sort_by_key(|event| event.seq); + events.sort_by_key(|(seq, _)| *seq); DirectThreadSubscriptionBootstrap { subscription_id, - last_completed_item_id: thread.last_completed_item_id.clone(), - events, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), } } @@ -218,26 +181,28 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); if cursor.saturating_add(1) < oldest_seq { thread.subscribers.remove(subscription_id); return Err(SUBSCRIPTION_EXPIRED.to_string()); } + let mut last_seq = cursor; let events = thread .events .iter() .skip(thread.head) - .filter(|stored| stored.event.seq > cursor) - .map(|stored| stored.event.clone()) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) .collect::>(); - if let Some(last) = events.last() { - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last.seq; - } + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; let result = DirectThreadConsumeResult { events }; Self::trim_prefix(thread); Ok(result) @@ -254,86 +219,76 @@ impl DirectThreadManager { }) } - fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { - match event.event_type.as_str() { - "item.started" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.insert(item_id.to_string()); - } + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { + match event { + DirectThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); false } - "item.completed" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.remove(item_id); - thread.last_completed_item_id = Some(item_id.to_string()); + DirectThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); + true + } + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 + thread.lifecycle_anchor = Some((seq, event.clone())); + true + } + DirectThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); } - true - } - "turn.started" | "turn.completed" => { - thread.lifecycle_anchor = Some(event.clone()); - true - } - "approval.requested" | "request.requested" | "ask.requested" => { - let request_id = request_id(event); if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - request_id.is_none() - } - "approval.resolved" | "request.resolved" | "ask.resolved" => { - if let Some(request_id) = request_id(event) { - thread.unresolved_requests.remove(&request_id); + thread.unresolved_requests.remove(request_id); } true } - _ => true, + DirectThreadEvent::ItemDelta { .. } => true, } } - fn is_bootstrap_event( - thread: &ThreadState, - event: &DirectThreadRawEvent, - cleanable: bool, - ) -> bool { - if thread - .lifecycle_anchor - .as_ref() - .is_some_and(|anchor| anchor.seq == event.seq) - { - return true; - } - if let Some(item_id) = event.item_id.as_deref() { + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { return thread.active_items.contains(item_id); } - if let Some(request_id) = request_id(event) { - return thread.unresolved_requests.contains(&request_id); + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); } - !cleanable + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable } - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: Option<&str>) { - let Some(item_id) = item_id else { - return; - }; + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { if thread.active_items.contains(item_id) { return; } for stored in &mut thread.events { - if stored.event.item_id.as_deref() == Some(item_id) { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { stored.cleanable = true; } } } - fn mark_request_events_cleanable(thread: &mut ThreadState, resolved_request_id: Option<&str>) { - let Some(resolved_request_id) = resolved_request_id else { + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { return; }; for stored in &mut thread.events { - if matches!( - stored.event.event_type.as_str(), - "approval.requested" | "request.requested" | "ask.requested" - ) && request_id(&stored.event).as_deref() == Some(resolved_request_id) + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) { stored.cleanable = true; } @@ -351,7 +306,7 @@ impl DirectThreadManager { let can_pop = thread .events .get(thread.head) - .is_some_and(|stored| stored.event.seq <= min_cursor && stored.cleanable); + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); if !can_pop { break; } @@ -391,7 +346,7 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); let slowest = thread .subscribers @@ -420,13 +375,13 @@ pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { pub(crate) fn append_direct_thread_event( thread_id: &str, - draft: DirectThreadRawEventDraft, -) -> DirectThreadRawEvent { + event: DirectThreadEvent, +) -> DirectThreadEvent { let (event, subscriber_ids) = { let mut manager = global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let event = manager.append(thread_id, draft); + let event = manager.append(thread_id, event); let subscriber_ids = manager.subscriber_ids(thread_id); (event, subscriber_ids) }; @@ -458,37 +413,48 @@ pub(crate) fn consume_direct_thread( .consume(subscription_id) } -fn request_id(event: &DirectThreadRawEvent) -> Option { - event - .payload - .get("requestId") - .and_then(Value::as_str) - .or_else(|| event.payload.get("id").and_then(Value::as_str)) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; + use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - fn draft(event_type: &str, turn_id: &str, item_id: Option<&str>) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - payload: serde_json::json!({}), + fn message(item_id: &str) -> DirectThreadItem { + DirectThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, } } + fn item_started(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_started(message(item_id)) + } + + fn item_completed(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_completed(message(item_id)) + } + + fn item_delta(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_delta( + item_id.to_string(), + DirectThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { + DirectThreadEvent::request(kind, request_id.map(str::to_string)) + } + #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); + manager.append("thread-1", DirectThreadEvent::turn_started()); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let first_batch = manager .consume(&first.subscription_id) @@ -508,52 +474,49 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-2"))); + manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-1")); - assert_eq!( - bootstrap - .events - .iter() - .map(|event| event.event_type.as_str()) - .collect::>(), - vec!["turn.started", "item.started"] - ); + assert!(matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); } #[test] fn completion_releases_item_events_only_after_the_completion_event_is_appended() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let bootstrap = manager.subscribe("thread-1"); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); + manager.append("thread-1", item_completed("item-1")); let events = manager .consume(&bootstrap.subscription_id) .expect("consume completion") .events; - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "item.completed"); + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); } #[test] fn slow_subscriber_is_expired_when_queue_limit_is_reached() { let mut manager = DirectThreadManager::with_limits(2, 100_000); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + for _ in 0..3 { + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); + } assert_eq!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -563,11 +526,11 @@ mod tests { #[test] fn current_subscriber_is_not_expired_by_pinned_queue_head() { let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } assert_ne!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -579,23 +542,16 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let bootstrap = manager.subscribe("thread-1"); assert_eq!(bootstrap.events.len(), 1); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); assert_eq!( manager @@ -612,22 +568,15 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let subscription = manager.subscribe("thread-1"); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); manager .consume(&subscription.subscription_id) @@ -638,17 +587,25 @@ mod tests { #[test] fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.completed", "turn-1", None)); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string()), + ); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - assert_eq!(bootstrap.events[0].event_type, "turn.completed"); + assert!(matches!( + bootstrap.events.as_slice(), + [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + )); } #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); let subscription = manager.subscribe("thread-1"); manager .consume(&subscription.subscription_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs new file mode 100644 index 000000000..f6b781218 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -0,0 +1,872 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +/// 单条变更路径上限。 +const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl DirectThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl DirectThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted, + #[serde(rename = "turn.completed")] + TurnCompleted { status: String }, + #[serde(rename = "item.started")] + ItemStarted { item: DirectThreadItem }, + #[serde(rename = "item.completed")] + ItemCompleted { item: DirectThreadItem }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: DirectThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: DirectThreadRequestKind, + #[serde(default)] + request_id: Option, + }, +} + +impl DirectThreadEvent { + pub(crate) fn turn_started() -> Self { + Self::TurnStarted + } + + pub(crate) fn turn_completed(status: String) -> Self { + Self::TurnCompleted { status } + } + + pub(crate) fn item_started(item: DirectThreadItem) -> Self { + Self::ItemStarted { item } + } + + pub(crate) fn item_completed(item: DirectThreadItem) -> Self { + Self::ItemCompleted { item } + } + + pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadSubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadHistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_DETAIL_MAX_CHARS, + ) +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_TEXT_MAX_CHARS, + )) +} + +/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 +pub(crate) fn direct_thread_delta_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_TEXT_MAX_CHARS, + ) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectThreadFileChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(root: &Path, item: &Value, key: &str) -> Option { + item.get(key) + .and_then(value_text) + .map(|value| detail_text(root, &value)) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_item_from_value( + root: &Path, + item: &Value, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let item_id = direct_thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(root, item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => DirectThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => DirectThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => DirectThreadItem::FunctionCallOutput { + item_id, + output: field_text(root, item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => DirectThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => DirectThreadItem::FileChange { + item_id, + changes: item_changes(root, item), + at, + }, + "mcpToolCall" => DirectThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)) + .unwrap_or_default(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => DirectThreadItem::WebSearch { + item_id, + query: field_text(root, item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + .map(|query| detail_text(root, &query)) + }), + output: field_text(root, item, "output"), + at, + }, + "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, + other => DirectThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + DirectThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); + } + + #[test] + fn app_server_agent_message_defaults_to_assistant_role() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + DirectThreadItem::Message { role, .. } if role == "assistant" + )); + } + + #[test] + fn tool_item_identity_is_normalized_to_one_id() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + DirectThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); + } + + #[test] + fn command_execution_keeps_raw_status_and_exit_code() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + DirectThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + 0, + ) + .expect("assistant item"); + let DirectThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!( + projected[0], + DirectThreadItem::FunctionCall { .. } + )); + assert!(matches!( + projected[1], + DirectThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + } + + #[test] + fn unknown_item_types_are_passed_through_without_body() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 82e9989e8..718ed37a8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -1,4 +1,4 @@ -//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! GameAgent 对话「工具调用卡片」的采集与持久化。 //! //! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: //! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 @@ -8,11 +8,9 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use super::direct_thread_wire::sanitize_detail_text; +use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -24,7 +22,7 @@ use std::path::{Path, PathBuf}; pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; /// 条目 schema 版本。 pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; -/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +/// 落盘上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; /// `detail.command` / `detail.output` 的字符上限。 const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; @@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -/// -/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` -/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + -/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 -/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; -/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed -/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 -/// -/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { @@ -527,15 +397,6 @@ fn normalize_tool_calls(calls: Vec) -> Vec { normalized } -/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 -pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { - let path = tool_calls_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { - return Ok(Vec::new()); - } - Ok(normalize_tool_calls(read_tool_call_lines(&path))) -} - /// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 fn status_certainty(status: &str) -> u8 { match status { @@ -684,10 +545,22 @@ mod tests { use super::{ direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, - read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, - DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, + read_tool_call_lines, sanitize_detail_text, tool_call_from_line, tool_calls_path, + DirectToolCall, DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, + DIRECT_TOOL_CALL_SCHEMA_VERSION, }; use serde_json::json; + use std::path::Path; + + /// 写侧用例直接读文件:回读命令退役后不再经过 `normalize_tool_calls` 的合并与裁剪, + /// 断言因此落在「磁盘上到底写了什么」这一层。 + fn persisted_tool_calls(root: &Path) -> Vec { + std::fs::read_to_string(tool_calls_path(root)) + .unwrap_or_default() + .lines() + .filter_map(tool_call_from_line) + .collect() + } /// 一行合法的落盘信封(回读用例的夹具)。 fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { @@ -812,7 +685,7 @@ mod tests { .expect("completed tool call"); persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); - let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!(calls[0].status, "completed"); assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); @@ -912,85 +785,6 @@ mod tests { ); } - /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 - #[test] - fn tool_call_read_skips_corrupted_lines() { - let root = init_tool_call_project("tool-call-corrupt"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let good = serde_json::to_string(&json!({ - "type": "tool_call_item", - "payload": { - "schemaVersion": "agc-tool-call.v1", - "id": "item-good", - "turnId": "turn-1", - "kind": "command", - "title": "执行命令", - "summary": "npm run build", - "status": "completed", - "detail": {"command": "npm run build"}, - "startedAt": 1, - "updatedAt": 2 - } - })) - .expect("serialize good row"); - std::fs::write( - &path, - format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), - ) - .expect("write fixture"); - - let missing = tempfile::tempdir().expect("missing dir"); - assert!( - read_direct_tool_calls_at(missing.path()) - .expect("missing file is empty") - .is_empty(), - "历史文件缺失必须返回空数组" - ); - - let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); - assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); - assert_eq!(calls[0].id, "item-good"); - } - - /// 判据:回读按时间正序,且超出上限时保留最新。 - #[test] - fn tool_call_read_is_ordered_and_capped() { - let root = init_tool_call_project("tool-call-cap"); - let total = DIRECT_TOOL_CALL_LIMIT + 5; - let calls = (0..total) - .map(|index| { - direct_tool_call_from_item( - root.path(), - &json!({ - "id": format!("item-{index:04}"), - "type": "commandExecution", - "command": format!("run {index}"), - "startedAtMs": 1000 + index as u64, - }), - "turn-1", - false, - 1000 + index as u64, - ) - .expect("tool call") - }) - .collect::>(); - persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); - - let read = read_direct_tool_calls_at(root.path()).expect("read capped"); - assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); - assert_eq!( - read.first().expect("first").id, - format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), - "最早被裁掉的是最旧的条目" - ); - assert!( - read.windows(2) - .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" - ); - } - /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 #[test] fn tool_call_file_change_title_counts_unique_paths() { @@ -1189,7 +983,7 @@ mod tests { persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + let calls = persisted_tool_calls(root.path()); assert_eq!(calls.len(), 1, "同一 id 只能有一行"); assert_eq!( calls[0].status, "completed", @@ -1201,7 +995,7 @@ mod tests { // 回合末整批落盘那条路径同样不得回退。 persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) .expect("persist stale running batch"); - let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + let calls = persisted_tool_calls(root.path()); assert_eq!( calls[0].status, "completed", "整批落盘路径同样不得把 completed 打回 running" @@ -1209,28 +1003,6 @@ mod tests { assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); } - /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 - #[test] - fn tool_call_read_merges_duplicate_rows_monotonically() { - let root = init_tool_call_project("tool-call-read-monotonic"); - let path = tool_calls_path(root.path()); - std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); - let completed = tool_call_row("item-1", 1000, 2000); - let stale_running = tool_call_row("item-1", 1000, 1000) - .replace("\"status\":\"completed\"", "\"status\":\"running\""); - assert!(stale_running.contains("\"status\":\"running\"")); - std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); - - let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); - assert_eq!(calls.len(), 1, "同 id 归并成一条"); - assert_eq!( - calls[0].status, "completed", - "磁盘上更旧的快照不得把状态打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); - assert_eq!(calls[0].started_at, 1000); - } - /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 #[test] fn tool_call_paths_become_project_relative() { @@ -1283,9 +1055,10 @@ mod tests { ); } - /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回, + /// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。 #[test] - fn tool_call_read_skips_invalid_utf8_line() { + fn tool_call_pre_read_skips_invalid_utf8_line() { let root = init_tool_call_project("tool-call-invalid-utf8"); let path = tool_calls_path(root.path()); std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); @@ -1298,7 +1071,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1318,7 +1091,7 @@ mod tests { bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); bytes.push(b'\n'); std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); - let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + let calls = read_tool_call_lines(&path); assert_eq!( calls.len(), 2, @@ -1326,10 +1099,16 @@ mod tests { ); assert_eq!(calls[0].id, "item-a"); assert_eq!(calls[1].id, "item-c"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_tool_call_lines(&tool_calls_path(missing.path())).is_empty(), + "历史文件缺失时写前读取必须返回空表" + ); } - /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 - /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + /// 判据:落到磁盘上的行同样受 200 条上限约束——「按时间保留最新 200 条」,更早回合的 + /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 #[test] fn tool_call_cap_drops_oldest_turn_cards() { let root = init_tool_call_project("tool-call-cap-oldest"); @@ -1366,7 +1145,7 @@ mod tests { .expect("newest tool call"); persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); - let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + let read = persisted_tool_calls(root.path()); assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); assert_eq!( read.last().expect("last").id, @@ -1381,7 +1160,7 @@ mod tests { assert!( read.windows(2) .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "回读必须按时间正序" + "落盘顺序必须按时间正序" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs index a2c8f1b30..dcdc5829b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs @@ -1,4 +1,4 @@ -//! GameAgent 对话「回合流」的采集、持久化与回读。 +//! GameAgent 对话「回合流」的采集与持久化。 //! //! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** //! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 @@ -11,7 +11,7 @@ //! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 use crate::agent::sanitize_detail_text; -use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::config::write_game_creator_private_file; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -339,15 +339,6 @@ pub(crate) fn upsert_direct_turn_stream_item_at( }) } -/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。 -pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result, String> { - let path = turn_stream_path(root); - if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? { - return Ok(Vec::new()); - } - Ok(normalize_stream_items(read_stream_lines(&path))) -} - /// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 /// /// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 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 426efa9b8..400b768bc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use super::*; use crate::agent::{ - direct_codex_canonical_project_identity, read_direct_project_chat_history_at, - read_direct_project_last_item_id_at, + read_direct_project_chat_history_at, read_direct_project_last_item_id_at, + DirectProjectHistoryAnchor, }; use crate::ui_editor::resource::font::FontAsset; use sha2::{Digest, Sha256}; @@ -5678,31 +5678,6 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_tool_calls( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_tool_calls_at(root) - }) - .await - .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn read_direct_turn_stream( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_turn_stream_at(root) - }) - .await - .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? -} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5717,13 +5692,7 @@ pub(crate) async fn subscribe_direct_project_thread( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (canonical_root, _) = direct_codex_canonical_project_identity(root)?; - let thread_root = canonical_root - .to_str() - .and_then(|value| value.strip_prefix("\\\\?\\")) - .map(Path::new) - .unwrap_or(canonical_root.as_path()); - let thread_id = thread_root.to_string_lossy().into_owned(); + let thread_id = direct_thread_id_for_project(root); let mut bootstrap = subscribe_direct_thread(&thread_id); if bootstrap.last_completed_item_id.is_none() { bootstrap.last_completed_item_id = read_direct_project_last_item_id_at(root)?; @@ -5741,34 +5710,43 @@ pub(crate) fn consume_direct_project_thread( consume_direct_thread(subscription_id.trim()) } +/// 读一屏项目对话历史。 +/// +/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含 +/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的 +/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。 #[tauri::command] pub(crate) async fn read_direct_project_history_slice( project_path: String, before_item_id: Option, + through_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 anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) { + (Some(_), Some(_)) => { + return Err( + "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" + .to_string(), + ) + } + (Some(before), None) => DirectProjectHistoryAnchor::Before(before), + (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), + (None, None) => DirectProjectHistoryAnchor::Newest, }; - 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 (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?; + let items = direct_thread_items_from_history(root, &raw_items, |item| { + direct_thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) + .unwrap_or_default() + }); Ok(DirectThreadHistorySlice { items, has_more, - item_timestamps, - oldest_item_id, + first_item_id, }) }) .await diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 68ba228eb..973197ae6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2647,8 +2647,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_direct_tool_calls, - read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index be33bfe93..bfd4cb80d 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -58,9 +58,6 @@ import type { DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, - GameCreatorDirectActiveTurn, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -99,7 +96,6 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, - TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -136,7 +132,6 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; -import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -237,15 +232,26 @@ import { } from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; +import { + type DirectHistoryAnchorGate, + directHistoryAnchorGateToWaitFor, + reuseOrOpenDirectHistoryAnchorGate, +} from './features/project-workspace/directHistoryAnchorGate'; +import { readDirectHistoryPages } from './features/project-workspace/directHistoryPaging'; +import { + applyDirectThreadConsumeResult, + type DirectThreadChatState, + emptyDirectThreadChatState, + mergeDirectHistoryItems, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from './features/project-workspace/directThreadChat'; import { type DirectThreadConsumeResult, - directThreadHistoryPage, type DirectThreadHistorySlice, + type DirectThreadItem, type DirectThreadSubscriptionBootstrap, - isDirectTurnInProgress, - prependDirectHistoryMessages, } from './features/project-workspace/directThreadEvents'; -import { normalizeDirectTimestamp } from './features/project-workspace/directTurnPresentation'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { appendMemoryContent, @@ -315,13 +321,6 @@ const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = /** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = '当前项目已有另一条 Direct 客户端回合正在运行'; -/** - * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, - * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 - */ -const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; -const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = - '该回合已无响应,可在输入盒点「终止」结束它以继续'; // Platform access tokens are short lived. DirectProject can spend several // minutes in image generation, build and browser validation, so keep the // client-owned native session current while a turn is running. The singleflight @@ -357,136 +356,6 @@ async function withDirectCodexSessionRefresh(operation: () => Promise) { } } -const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ - 'accepted', - 'running', - 'streaming', - 'finalizing', - 'completed', - 'failed', -]); - -function ensureDirectProcessPrefix(text: string) { - const trimmed = text.trim(); - if (!trimmed) { - return ''; - } - if (trimmed.startsWith('正在')) { - return trimmed; - } - if (/^(?:执行|调用|读取|写入|验证|搜索|整理|修改|生成)/u.test(trimmed)) { - return `正在${trimmed}`; - } - return `正在处理:${trimmed}`; -} - -function directCodexActivityDetail( - activity: string | null | undefined, - status: string | null | undefined, -) { - switch (activity) { - case 'request-accepted': - return '正在等待陶泥儿开始'; - case 'preparing': - return '正在思考中'; - case 'file-read': - return '正在读取文件'; - case 'file-write': - return status === 'finalizing' ? '正在同步项目文件' : '正在写入文件'; - case 'game-verify': - return '正在验证游戏'; - case 'command-exec': - return '正在执行命令'; - case 'controlled-tool': - return '正在调用工具'; - case 'web-search': - return '正在搜索资料'; - case 'context-compaction': - return '正在整理上下文'; - case 'response-finalization': - return '正在整理回复'; - case 'none': - default: - switch (status) { - case 'accepted': - return '正在等待陶泥儿开始'; - case 'finalizing': - return '正在整理结果'; - case 'completed': - case 'failed': - return ''; - default: - return '正在处理任务'; - } - } -} - -const DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES = [ - '正在写入文件:', - '正在浏览项目文件', - '正在读取素材库', - '正在读取账户素材', - '正在导入素材', - '正在生成图片', - '正在编辑图片', - '正在准备美术素材', - '正在创建素材资源', - '正在去除图片背景', - '正在试玩游戏', - '正在搜索资料:', - '正在执行命令:', - '正在验证游戏:', -] as const; - -function directCodexProcessDetail({ - accumulatedText, - activity, - status, -}: { - accumulatedText?: string | null; - activity?: string | null; - status: string; -}) { - if (status === 'completed' || status === 'failed') { - return ''; - } - if (status === 'streaming') { - return '正在生成回复'; - } - if (status === 'finalizing') { - return directCodexActivityDetail(activity, status); - } - const text = accumulatedText?.trim(); - if (text) { - return ensureDirectProcessPrefix(text); - } - return directCodexActivityDetail(activity, status); -} - -function isDirectCodexSpecificWorkDetail(text: string) { - return DIRECT_CODEX_SPECIFIC_WORK_DETAIL_PREFIXES.some((prefix) => - text.startsWith(prefix), - ); -} - -function directCodexTransientReplyText({ - accumulatedText, - status, -}: { - accumulatedText?: string | null; - status: string; -}) { - if ( - status !== 'streaming' && - status !== 'finalizing' && - status !== 'completed' - ) { - return null; - } - const text = accumulatedText?.trim(); - return text || null; -} - function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -496,70 +365,6 @@ function directCodexConversationMessageId( export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; -/** - * 回合流排序:`seq`(条目首次出现时钉死)优先,其次 `at`,最后按 id 兜底。 - * 与 Rust 侧同一口径——前端不自己发明顺序。 - */ -function sortTurnStreamItems(items: readonly TurnStreamItem[]) { - return [...items].sort( - (left, right) => - left.seq - right.seq || - left.at - right.at || - left.id.localeCompare(right.id), - ); -} - -/** - * 归并一批回合流条目:同 id 幂等覆盖(`updatedAt` 单调,同刻取更长文本),新 id 追加。 - * 实时增量与回读历史共用这一处,所以界面上的顺序只有一份来源。 - */ -function mergeTurnStreamItems( - existing: readonly TurnStreamItem[], - incoming: readonly TurnStreamItem[], -): TurnStreamItem[] { - if (incoming.length === 0) { - return [...existing]; - } - const byId = new Map(); - for (const item of existing) { - const id = item.id?.trim(); - if (id) { - byId.set(id, item); - } - } - for (const item of incoming) { - const id = item.id?.trim(); - if (!id) { - continue; - } - const previous = byId.get(id); - const normalized: TurnStreamItem = { ...item, id }; - if (!previous) { - byId.set(id, normalized); - continue; - } - // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 - const textLength = (value: TurnStreamItem) => - value.kind === 'text' ? (value.text?.length ?? 0) : 0; - // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 - const takeIncoming = - normalized.updatedAt > previous.updatedAt || - (normalized.updatedAt === previous.updatedAt && - textLength(normalized) > textLength(previous)); - byId.set(id, { - ...(takeIncoming ? normalized : previous), - id, - updatedAt: Math.max(previous.updatedAt, normalized.updatedAt), - seq: Math.min(previous.seq, normalized.seq), - at: - previous.at > 0 && normalized.at > 0 - ? Math.min(previous.at, normalized.at) - : Math.max(previous.at, normalized.at), - } as TurnStreamItem); - } - return sortTurnStreamItems([...byId.values()]); -} - export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -626,7 +431,7 @@ function claimInitialSupervisorMessageForPage(projectPath: string, scope = '') { * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 - * 这里只把当前 messages 里"非历史来源、运行时拥有、且回读结果里没有"的消息保留在末尾。 + * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 */ function mergeLoadedConversationWithPendingRuntimeMessages( loaded: ChatMessage[], @@ -644,7 +449,7 @@ function mergeLoadedConversationWithPendingRuntimeMessages( loaded.map((message) => `${message.role}\u0000${message.text}`), ); const pending = current.filter((message) => { - if (!message.runtimeOwned || message.fromHistory) { + if (!message.runtimeOwned) { return false; } if (message.messageId) { @@ -800,7 +605,6 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; - ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -936,125 +740,26 @@ export function App({ chatTurnQueueRef.current = []; }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); - const [directCodexProgress, setDirectCodexProgress] = useState(''); - const [directCodexStatus, setDirectCodexStatus] = useState< - GameCreatorDirectTurnUpdateEvent['status'] | null - >(null); - const [directCodexProcessKey, setDirectCodexProcessKey] = useState(''); - const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = - useState(null); - const [directCodexTransientReply, setDirectCodexTransientReply] = - useState(''); - const directCodexTransientReplyRef = useRef(''); - // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 - const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = - useState(''); - /** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */ - const [ - directCodexTransientReplyUpdatedAt, - setDirectCodexTransientReplyUpdatedAt, - ] = useState(null); - const activeDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - lastSequence: number; - receivedDirectUpdate: boolean; - restored?: boolean; - } | null>(null); - const directActiveSnapshotVersionRef = useRef(0); - const directTurnLifecycleRef = useRef<{ - reset: () => void; - loadHistory: (projectPath: string) => Promise; - restore: (projectPath: string) => Promise; - } | null>(null); - const lastDirectCodexActivityRef = useRef(null); - /** - * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, - * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 - */ - const recoveredDirectCodexTurnRef = useRef<{ - projectPath: string; - turnId: string; - } | null>(null); - const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); - // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 - // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 - const [directToolCalls, setDirectToolCalls] = useState< - GameCreatorDirectToolCall[] - >([]); - const directToolCallsRef = useRef([]); - const directTimelineProjectPathRef = useRef(null); - - function ensureDirectTimelineProject(project: string | null) { - if (directTimelineProjectPathRef.current === project) return; - directTimelineProjectPathRef.current = project; - directToolCallsRef.current = []; - directTurnStreamRef.current = []; - setDirectToolCalls([]); - setDirectTurnStream([]); - } /** - * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), - * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + * DirectProject 聊天真相源:历史切片与运行态事件归并成同一份条目。 + * + * 运行态只来自 Thread Manager(`subscribe` 的 bootstrap + `notify` 唤醒的 `consume`); + * 前端不再订阅 DirectRuntime 的回合进度事件,也不再各存一份回合流 / 工具卡片。 */ - function applyDirectToolCalls( - incoming: readonly GameCreatorDirectToolCall[], - ) { - if (incoming.length === 0) { - return; - } - const merged = [...directToolCallsRef.current]; - for (const call of incoming) { - const id = call.id?.trim(); - if (!id) { - continue; - } - const existingIndex = merged.findIndex( - (existing) => existing.id === id && existing.turnId === call.turnId, - ); - const normalized: GameCreatorDirectToolCall = { - ...call, - id, - startedAt: normalizeDirectTimestamp(call.startedAt), - updatedAt: normalizeDirectTimestamp(call.updatedAt), - detail: call.detail ?? { changes: [] }, - }; - // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 - const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; - if (existing) { - if ( - existing.updatedAt > normalized.updatedAt || - (existing.status !== 'running' && normalized.status === 'running') - ) - continue; - normalized.detail = { - ...normalized.detail, - command: normalized.detail.command ?? existing.detail.command, - output: normalized.detail.output ?? existing.detail.output, - }; - } - if ( - existing && - existing.startedAt > 0 && - (normalized.startedAt === 0 || - existing.startedAt < normalized.startedAt) - ) { - normalized.startedAt = existing.startedAt; - } - if (existingIndex >= 0) { - merged[existingIndex] = normalized; - } else { - merged.push(normalized); - } - } - merged.sort( - (left, right) => - left.startedAt - right.startedAt || left.id.localeCompare(right.id), - ); - directToolCallsRef.current = merged; - setDirectToolCalls(merged); - } + const [directThreadChat, setDirectThreadChat] = + useState(() => emptyDirectThreadChatState()); + /** 最新回合是否在跑:历史里留下的半截回合一律按已结束渲染。 */ + const directTurnRunning = directThreadChat.turnRunning; + /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ + const supervisorChatBusy = + chatAgentBusy || (directCodexProductRuntime && directTurnRunning); + const chatAgentBusyRef = useRef(chatAgentBusy); + const directTurnRunningRef = useRef(directTurnRunning); + chatAgentBusyRef.current = chatAgentBusy; + directTurnRunningRef.current = directTurnRunning; + const previousDirectTurnRunningRef = useRef(directTurnRunning); + const directTurnCompletionPendingRef = useRef(false); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -1074,193 +779,11 @@ export function App({ return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; } - function resetDirectCodexTurn() { - directActiveSnapshotVersionRef.current += 1; - clearRecoveredDirectCodexTurnWatch(); - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexProgress(''); - setDirectCodexStatus(null); - setDirectCodexProcessKey(''); - setDirectCodexProgressUpdatedAt(null); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); + /** 换项目 / 清空对话:聊天条目回到初始态(运行态与已加载历史一起清掉)。 */ + function resetDirectThreadChat() { + setDirectThreadChat(emptyDirectThreadChatState()); } - /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ - function clearRecoveredDirectCodexTurnWatch() { - if (recoveredDirectCodexTurnTimerRef.current !== null) { - window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); - recoveredDirectCodexTurnTimerRef.current = null; - } - recoveredDirectCodexTurnRef.current = null; - } - - /** - * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 - * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 - * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 - * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 - */ - function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { - clearRecoveredDirectCodexTurnWatch(); - recoveredDirectCodexTurnRef.current = { projectPath, turnId }; - recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { - recoveredDirectCodexTurnTimerRef.current = null; - const watch = recoveredDirectCodexTurnRef.current; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !watch || - watch.projectPath !== projectPath || - watch.turnId !== turnId || - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId || - activeTurn.receivedDirectUpdate - ) { - return; - } - setDirectCodexStatus('running'); - setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - setDirectCodexProgressUpdatedAt(Date.now()); - setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); - }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); - } - - /** - * 重进会话时接管仍在运行的 Direct 回合。 - * - * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 - * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 - * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 - * - * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 - */ - async function restoreRunningDirectCodexTurn( - projectPath: string, - reconcile = false, - ) { - if (!directCodexProductRuntime || !projectPath) { - return; - } - const invoke = resolveTauriInvoke(); - if (!invoke) { - return; - } - const owner = activeDirectCodexTurnRef.current; - const sequence = owner?.lastSequence; - const scopeVersion = projectScopeVersionRef.current; - if (!reconcile && owner?.projectPath === projectPath) { - return; - } - const readVersion = ++directActiveSnapshotVersionRef.current; - let turns: GameCreatorDirectActiveTurn[]; - try { - turns = await invoke( - 'list_game_creator_direct_active_turns', - ); - } catch { - // 读取失败不等于没有活动回合。 - return; - } - if (!Array.isArray(turns)) return; - if ( - localProjectPathRef.current !== projectPath || - planningV2ActiveRef.current || - designAgentLaneRef.current || - projectScopeVersionRef.current !== scopeVersion || - directActiveSnapshotVersionRef.current !== readVersion || - activeDirectCodexTurnRef.current !== owner || - owner?.lastSequence !== sequence - ) { - return; - } - const activeView = turns.find( - (turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath) && - isDirectTurnInProgress(turn.status), - ); - if (!activeView || !isDirectTurnInProgress(activeView.status)) { - // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 - if (owner && !owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - setChatAgentBusy(false); - if (owner && reconcile) { - void loadProjectConversation(projectPath, false, 'replace'); - } - return; - } - const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; - if (owner && !matchingOwner) { - if (!owner.restored && owner.lastSequence < 0) return; - resetDirectCodexTurn(); - } - if (!matchingOwner) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeView.turnId, - lastSequence: -1, - receivedDirectUpdate: false, - restored: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); - setProjectSupervisorRuntimeError(''); - watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); - } - setChatAgentBusy(true); - // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 - if ( - !matchingOwner?.receivedDirectUpdate || - activeView.sequence > matchingOwner.lastSequence - ) { - setDirectCodexStatus(activeView.status); - setDirectCodexProgress( - directCodexActivityDetail(activeView.activity, activeView.status), - ); - setDirectCodexProgressUpdatedAt(activeView.updatedAt); - } - } - - directTurnLifecycleRef.current = { - reset: resetDirectCodexTurn, - loadHistory: (projectPath) => - loadProjectConversation(projectPath, false, 'replace'), - restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), - }; - - // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, - // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 - const [directTurnStream, setDirectTurnStream] = useState( - [], - ); - const directTurnStreamRef = useRef([]); - - /** 归并一批回合流条目(实时事件里的 `streamItems`)。 */ - function applyTurnStreamItems(incoming: readonly TurnStreamItem[]) { - if (incoming.length === 0) { - return; - } - const merged = mergeTurnStreamItems(directTurnStreamRef.current, incoming); - directTurnStreamRef.current = merged; - setDirectTurnStream(merged); - } - - function clearDirectCodexTransientReply(projectPath: string, turnId: string) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - activeTurn?.projectPath !== projectPath || - activeTurn.turnId !== turnId - ) { - return false; - } - activeDirectCodexTurnRef.current = null; - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - return true; - } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -1959,7 +1482,10 @@ export function App({ ); const [directHistoryHasMore, setDirectHistoryHasMore] = useState(false); const directHistoryOldestItemIdRef = useRef(null); - const directHistoryLoadingRef = useRef(null); + const directHistoryLoadingRef = useRef(false); + const directHistoryAnchorGateRef = useRef( + null, + ); const [pendingCommand, setPendingCommand] = useState( null, ); @@ -2152,7 +1678,6 @@ export function App({ function resetProjectSupervisorState() { projectSupervisorHistoryLoadVersionRef.current += 1; - directHistoryLoadingRef.current = null; projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; @@ -2291,117 +1816,7 @@ export function App({ }, [initialProjectPath, projectSupervisorOnly]); useEffect(() => { - if (!directCodexProductRuntime) { - return; - } - if (!canSubscribeTauriEvents()) { - return; - } - let cleanup: (() => void) | null = null; - let disposed = false; - void subscribeTauriEvent( - 'game-creator-direct-turn-update', - (event) => { - const payload = event.payload; - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - payload.projectPath !== localProjectPathRef.current || - payload.projectPath !== activeTurn.projectPath || - payload.turnId !== activeTurn.turnId || - !Number.isSafeInteger(payload.sequence) || - payload.sequence < 0 || - !DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) || - payload.sequence <= activeTurn.lastSequence - ) { - return; - } - activeTurn.lastSequence = payload.sequence; - activeTurn.receivedDirectUpdate = true; - // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 - if ( - recoveredDirectCodexTurnRef.current?.projectPath === - payload.projectPath && - recoveredDirectCodexTurnRef.current.turnId === payload.turnId - ) { - clearRecoveredDirectCodexTurnWatch(); - setChatComposerNotice((current) => - current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE - ? '' - : current, - ); - } - ensureDirectTimelineProject(payload.projectPath); - // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 - if (payload.toolCalls?.length) { - applyDirectToolCalls( - payload.toolCalls.map((call) => ({ - ...call, - turnId: payload.turnId, - })), - ); - } - if (typeof payload.reasoningText === 'string') { - setDirectCodexTransientReasoning(payload.reasoningText); - } - // 回合流的顺序真相:字段可选,老事件(undefined)走原路径。 - if (payload.streamItems?.length) { - applyTurnStreamItems(payload.streamItems); - } - const updatedAt = - Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 - ? payload.updatedAt - : Date.now(); - const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed' || payload.status === 'completed') { - directTurnLifecycleRef.current?.reset(); - setChatAgentBusy(false); - void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); - return; - } - setDirectCodexStatus(payload.status); - const genericActivity = payload.activity ?? null; - const previousActivity = lastDirectCodexActivityRef.current; - setDirectCodexProgress((current) => { - const heartbeatWouldDowngrade = - genericActivity !== null && - !payload.accumulatedText?.trim() && - payload.status === 'running' && - previousActivity === genericActivity && - current !== processDetail && - isDirectCodexSpecificWorkDetail(current); - return heartbeatWouldDowngrade ? current : processDetail; - }); - if (genericActivity !== null) { - lastDirectCodexActivityRef.current = genericActivity; - } - setDirectCodexProgressUpdatedAt(updatedAt); - const transientReply = directCodexTransientReplyText(payload); - if (transientReply !== null) { - setDirectCodexTransientReply(transientReply); - directCodexTransientReplyRef.current = transientReply; - setDirectCodexTransientReplyUpdatedAt(updatedAt); - } - }, - ) - .then((unlisten) => { - if (disposed) { - unlisten(); - return; - } - cleanup = unlisten; - }) - .catch(() => { - // The existing safe progress event remains the activity fallback. - }); - return () => { - disposed = true; - cleanup?.(); - }; - }, [directCodexProductRuntime]); - - useEffect(() => { - if (!directCodexProductRuntime || !chatAgentBusy) { + if (!directCodexProductRuntime || !supervisorChatBusy) { return; } const timer = window.setInterval(() => { @@ -2411,30 +1826,60 @@ export function App({ }); }, DIRECT_CODEX_SESSION_KEEPALIVE_MS); return () => window.clearInterval(timer); - }, [chatAgentBusy, directCodexProductRuntime]); + }, [supervisorChatBusy, directCodexProductRuntime]); + /** + * DirectProject 聊天真相源。 + * + * `subscribe` 的 bootstrap 就是此刻要处理的事件(游标已在队尾),此后只由 + * `game-creator-direct-thread-notify` 唤醒 `consume`;前端不再订阅 DirectRuntime 的回合 + * 进度事件,也不轮询"有没有回合在跑"。 + */ useEffect(() => { + if (!directCodexProductRuntime) { + return; + } const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) return; + // 闸门先建(或复用首屏读取已经开好的那道):同一轮渲染里首屏会等订阅回执里的锚点。 + const anchorGate = reuseOrOpenDirectHistoryAnchorGate( + directHistoryAnchorGateRef.current, + projectPath ?? '', + ); + directHistoryAnchorGateRef.current = anchorGate; + if (!projectPath || !directInvoke || !canSubscribeTauriEvents()) { + // 订阅不可用:首屏退化成"取文件尾",不阻塞加载。 + anchorGate.settle(null); + return; + } let disposed = false; let cleanup: (() => void) | null = null; let subscriptionId: string | null = null; let consuming = false; let consumeAgain = false; + // 通知先于 subscribe 回执到达时,前端还不知道自己的 subscriptionId,没法立刻 consume。 + // 记一笔欠账,拿到回执后立刻补一次,事件就不会卡在队伍里等下一次通知。 + let notifyBeforeSubscription = false; - // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 - const refreshActive = () => { - if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); - }; const bootstrap = async () => { const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, ); - if (disposed) return; + if (disposed) { + anchorGate.settle(null); + return; + } subscriptionId = result.subscriptionId; - refreshActive(); + // 首屏边界:回执里这一刻的最后一条已完成条目(含该条)。 + anchorGate.settle(result.lastCompletedItemId ?? null); + setDirectThreadChat((state) => + resolveDirectThreadBootstrap(state, result), + ); + if (notifyBeforeSubscription) { + notifyBeforeSubscription = false; + void consume(); + } }; const consume = async () => { if (!subscriptionId || disposed) return; @@ -2451,30 +1896,18 @@ export function App({ { subscriptionId }, ); if (disposed) return; - if ( - result.events.some( - (event) => - event.type === 'turn.started' || - event.type === 'turn.completed', - ) - ) { - refreshActive(); - } - if ( - result.events.some((event) => event.type === 'turn.completed') && - !activeDirectCodexTurnRef.current?.receivedDirectUpdate - ) { - // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 - void directTurnLifecycleRef.current?.loadHistory(projectPath); - } + setDirectThreadChat((state) => + applyDirectThreadConsumeResult(state, result), + ); } while (consumeAgain && !disposed); } catch (error) { + // 订阅被别人顶掉时重订一次;通知允许丢,下一次通知会再唤醒。 if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { subscriptionId = null; try { await bootstrap(); } catch { - /* 活动快照轮询仍然有效。 */ + /* 让下一次通知再试。 */ } } } finally { @@ -2486,7 +1919,11 @@ export function App({ const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if (event.payload.subscriptionId === subscriptionId) void consume(); + if (event.payload.subscriptionId === subscriptionId) { + void consume(); + return; + } + if (!subscriptionId) notifyBeforeSubscription = true; }, ); if (disposed) { @@ -2495,21 +1932,19 @@ export function App({ } cleanup = unlisten; await bootstrap(); - await consume(); } catch { - // 历史仍可使用;订阅失败不伪造忙碌态。 + // 历史仍可使用;订阅失败不伪造忙碌态,锚点缺失时首屏按文件尾取尾屏。 + anchorGate.settle(null); } }; - refreshActive(); - const timer = window.setInterval( - refreshActive, - DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, - ); + // 换项目就是换一份聊天:先回到初始态,再订阅新线程。 + setDirectThreadChat(emptyDirectThreadChatState()); void setup(); return () => { disposed = true; cleanup?.(); - window.clearInterval(timer); + // 首屏可能还在等这道闸门(例如切项目打断了订阅),不能让它永远等下去。 + anchorGate.settle(null); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -2529,20 +1964,7 @@ export function App({ return; } if (directCodexProductRuntime) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - activeTurn.projectPath !== event.payload.projectPath || - activeTurn.receivedDirectUpdate - ) { - return; - } - const progressDetail = ensureDirectProcessPrefix( - event.payload.message, - ); - setDirectCodexStatus('running'); - setDirectCodexProgress(progressDetail); - setDirectCodexProgressUpdatedAt(Date.now()); + // DirectProject 的进度已经从线程事件流进聊天条目,这条通用进度事件不再参与。 return; } setMessages((current) => [ @@ -3082,10 +2504,7 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, - directCodexProgress, - directCodexProgressUpdatedAt, - directCodexTransientReply, - directCodexTransientReplyUpdatedAt, + directThreadChat, directCodexProductRuntime, planningV2Active, projectSupervisorOnly, @@ -4011,8 +3430,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,24 +3526,62 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; - let loadedDirectHistoryCursor: string | null = null; + let loadedDirectHistoryFirstItemId: string | null = null; + // 首屏连拉到的条目先暂存,待下方 staleness 守卫通过后再并入聊天 reducer: + // 迟到的切片属于已经切走的项目,不能在守卫之前就写进全局聊天状态。 + let loadedDirectHistoryItems: DirectThreadItem[] = []; + // 首屏切片的新端边界只认订阅回执里的 `lastCompletedItemId`(含该条):比它更新的条目 + // 只能来自运行态事件。打开项目时订阅 effect 还没跑,这里就先把闸门开好,订阅侧会复用 + // 同一道闸门并 settle 它。同一道闸门只锚定一次:同一订阅下再读一次(例如重开同一个项目) + // 没有新回执可等,退回"按当前文件尾取尾屏",与 `/history` 手动重读一致。 + let directHistoryThroughItemId: string | null = null; + if (directCodexProductRuntime && mode !== 'replace') { + const anchorGate = directHistoryAnchorGateToWaitFor( + directHistoryAnchorGateRef.current, + nextProjectPath, + ); + if (anchorGate) { + directHistoryAnchorGateRef.current = anchorGate; + directHistoryThroughItemId = await anchorGate.anchor; + anchorGate.consumed = true; + } + } const projectConversation = directCodexProductRuntime ? (() => { - return invoke( - 'read_direct_project_history_slice', - { - projectPath: nextProjectPath, - limit: CONVERSATION_INITIAL_VISIBLE_COUNT, - messagesOnly: true, - }, - ).then((slice) => { - const page = directThreadHistoryPage(slice); - loadedDirectHistoryHasMore = page.hasMore; - loadedDirectHistoryCursor = page.cursor; + // 首屏铺的就是这份视图的基线,"新回合"比较没有意义:判据退化为"这一页得有能渲染 + // 的条目",所以基线取空,免得拿上一份对话的残留状态去比。首屏失败仍按原有语义 + // 抛出,交给调用方的 catch 处理。 + return readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath: nextProjectPath, + beforeItemId, + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + } + : { + projectPath: nextProjectPath, + ...(directHistoryThroughItemId + ? { throughItemId: directHistoryThroughItemId } + : {}), + limit: CONVERSATION_INITIAL_VISIBLE_COUNT, + }, + ), + }).then((pages) => { + if (pages.error) { + throw pages.error; + } + loadedDirectHistoryHasMore = pages.hasMore; + loadedDirectHistoryFirstItemId = pages.firstItemId; + loadedDirectHistoryItems = pages.items; return { path: nextProjectPath, agentId: null, - messages: page.messages, + messages: [], } satisfies LocalConversationResult; }); })() @@ -4135,35 +3590,7 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; - // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 - // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, - // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 - // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 - if (directCodexProductRuntime) { - const persistedToolCalls = await invoke( - 'read_direct_tool_calls', - { projectPath: nextProjectPath }, - ).catch(() => []); - // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 - // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 - // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 - const persistedTurnStream = await invoke( - 'read_direct_turn_stream', - { projectPath: nextProjectPath }, - ).catch(() => []); - if ( - projectSupervisorHistoryLoadVersionRef.current !== loadVersion || - localProjectPathRef.current !== nextProjectPath - ) - return; - ensureDirectTimelineProject(nextProjectPath); - // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 - applyDirectToolCalls(persistedToolCalls); - applyTurnStreamItems(persistedTurnStream); - // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 - // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 - await restoreRunningDirectCodexTurn(nextProjectPath); - } + // 首屏历史直接进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -4202,7 +3629,7 @@ export function App({ const conversationMessages = mergeProjectSupervisorConversation( resolvedProjectConversation.messages, supervisorConversation?.messages ?? [], - ).map((message) => ({ ...message, fromHistory: true })); + ); if ( conversationContainsProjectSupervisorResponseStream( supervisorConversation?.messages ?? [], @@ -4223,7 +3650,12 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = loadedDirectHistoryCursor; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; + if (loadedDirectHistoryItems.length > 0) { + const items = loadedDirectHistoryItems; + // 首屏历史进聊天 reducer;`messages` 在 DirectProject 下只剩运行期本地消息。 + setDirectThreadChat((state) => mergeDirectHistoryItems(state, items)); + } } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -4284,10 +3716,6 @@ export function App({ : workspaceStatus, ); // Keep the default greeting when history is missing or blocked. - } finally { - if (directHistoryLoadingRef.current === loadVersion) { - directHistoryLoadingRef.current = null; - } } } @@ -4323,7 +3751,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); - resetDirectCodexTurn(); + resetDirectThreadChat(); updateClientPreview(null); setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); @@ -4403,9 +3831,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; @@ -7170,21 +6595,7 @@ export function App({ index === existingIndex ? nextMessage : message, ); }; - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; setChatAgentBusy(true); - setDirectCodexStatus('accepted'); - setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); - setDirectCodexProgress('正在等待陶泥儿开始'); - setDirectCodexTransientReply(''); - setDirectCodexTransientReasoning(''); - setDirectCodexProgressUpdatedAt(Date.now()); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { const directTurnInput: { @@ -7207,15 +6618,9 @@ export function App({ directTurnInput.attachments = attachments; } directTurnInput.userItem = effectiveUserItem; - const reply = await withDirectCodexSessionRefresh(() => { - // 每次调用都会新建 Rust 事件流;续期重试需重新接收同一回合的进度。 - activeDirectCodexTurnRef.current = { - projectPath: directProjectPath, - turnId: clientTurnId, - lastSequence: -1, - receivedDirectUpdate: false, - }; - setDirectCodexStatus('accepted'); + // 回复正文按条目身份从线程事件 / 历史切片进聊天,本地不再补一条, + // 因此这里只等回合跑完,不接返回值。 + await withDirectCodexSessionRefresh(() => { return directInvoke( 'chat_with_game_creator_direct_codex', directTurnInput, @@ -7227,10 +6632,6 @@ export function App({ projectSupervisorHistoryLoadVersionRef.current += 1; } if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setMessages((current) => - appendDirectAssistantMessage(current, reply), - ); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -7239,16 +6640,9 @@ export function App({ isDirectCodexAnotherTurnRunningError(error) ) { if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); - // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 - // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally - // 里 setChatAgentBusy(false) 的复位竞态。 - window.setTimeout(() => { - void restoreRunningDirectCodexTurn(directProjectPath); - }, 0); } return; } @@ -7257,9 +6651,6 @@ export function App({ } if (isDirectCodexTurnInterruptedError(error)) { // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress(''); setProjectSupervisorRuntimeError(''); setChatComposerNotice('已终止本次回合'); setMessages((current) => @@ -7294,9 +6685,6 @@ export function App({ ); projectSupervisorHistoryLoadVersionRef.current += 1; if (localProjectPathRef.current === directProjectPath) { - clearDirectCodexTransientReply(directProjectPath, clientTurnId); - setDirectCodexStatus('failed'); - setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => appendDirectAssistantMessage(current, visibleMessage, true), @@ -7308,17 +6696,11 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - localProjectPathRef.current === directProjectPath && - (!activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId)) - ) { + if (localProjectPathRef.current === directProjectPath) { setChatAgentBusy(false); setDirectCodexTurnCancelling(false); - resetDirectCodexTurn(); - // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + chatAgentBusyRef.current = false; + // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 dispatchNextQueuedChatTurn(); } } @@ -12490,63 +11872,53 @@ 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, - limit: CONVERSATION_VISIBLE_STEP, - messagesOnly: true, - }, - ); - if (!isCurrentLoad()) { + // 一次点击连拉,直到出现新回合或取不动为止:一屏全是工具卡片 / 思考文本时, + // 单发一页会让用户点了"显示更早"却看不到任何变化。 + const pages = await readDirectHistoryPages({ + existingEntries: selectDirectChatEntries(directThreadChat), + beforeItemId: directHistoryOldestItemIdRef.current, + readSlice: (beforeItemId) => + invoke( + 'read_direct_project_history_slice', + beforeItemId + ? { + projectPath, + beforeItemId, + limit: CONVERSATION_VISIBLE_STEP, + } + : { projectPath, limit: CONVERSATION_VISIBLE_STEP }, + ), + }); + if (localProjectPathRef.current !== projectPath) { return; } - const page = directThreadHistoryPage(slice, beforeItemId); - const older = page.messages.map((message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - fromHistory: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - })); - setMessages((current) => - prependDirectHistoryMessages(current, older), + // 连拉到的页一次性进聊天 reducer:与运行态同形、同身份,重复读取只补不重。 + setDirectThreadChat((state) => + mergeDirectHistoryItems(state, pages.items), ); - setConversationVisibleCount((current) => current + older.length); - setDirectHistoryHasMore(page.hasMore); - directHistoryOldestItemIdRef.current = page.cursor; + // 首页或中途读取失败时保留旧值:失败页没有可靠的 hasMore,不能因为 + // 一次瞬时 IO 抖动把「显示更早」按钮永久收掉。 + if (!pages.error) { + setDirectHistoryHasMore(pages.hasMore); + } + directHistoryOldestItemIdRef.current = + pages.firstItemId ?? directHistoryOldestItemIdRef.current; + if (pages.error) { + // 已经取到的页照常进视图,失败只走下面同一个报错出口。 + throw pages.error; + } } 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; @@ -12741,8 +12113,12 @@ export function App({ } } - /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ function dispatchNextQueuedChatTurn() { + if (chatAgentBusyRef.current || directTurnRunningRef.current) { + return; + } + directTurnCompletionPendingRef.current = false; const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); if (!next) { return; @@ -12760,16 +12136,42 @@ export function App({ }); } + /** + * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 + */ + useEffect(() => { + if (!directCodexProductRuntime) { + directTurnCompletionPendingRef.current = false; + previousDirectTurnRunningRef.current = directTurnRunning; + return; + } + const wasRunning = previousDirectTurnRunningRef.current; + previousDirectTurnRunningRef.current = directTurnRunning; + if (wasRunning && !directTurnRunning) { + directTurnCompletionPendingRef.current = true; + } + if ( + directTurnCompletionPendingRef.current && + !chatAgentBusy && + !directTurnRunning + ) { + directTurnCompletionPendingRef.current = false; + dispatchNextQueuedChatTurn(); + } + // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ async function handleCancelDirectCodexTurn() { if (directCodexTurnCancelling) { return; } const invoke = resolveTauriInvoke(); - const activeTurn = activeDirectCodexTurnRef.current; - const directProjectPath = - activeTurn?.projectPath ?? resolveChatProjectPath(localProject); - if (!invoke || !directProjectPath || !activeTurn) { + const directProjectPath = resolveChatProjectPath(localProject); + // 繁忙判据与「终止」按钮的可见条件一致:本地 invoke 正在跑,或订阅说还有一条回合没结束。 + // 只认 `turn.started` 推导出来的 `directTurnRunning` 会漏掉"刚提交、事件还没到"的窗口。 + if (!invoke || !directProjectPath || !supervisorChatBusy) { setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); return; } @@ -12778,17 +12180,13 @@ export function App({ try { const result = await invoke( 'cancel_direct_codex_turn', - { - projectPath: directProjectPath, - clientTurnId: activeTurn.turnId, - }, + { projectPath: directProjectPath }, ); const message = result?.message?.trim(); if (result?.outcome === 'released') { - // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 - // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 - // 与"任务执行中"会一直挂着,用户仍然发不出消息。 - resetDirectCodexTurn(); + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧强制释放了 + // 守卫并补了终态事件;这里同步把界面复位,不等 IPC 通知。 + setDirectThreadChat((state) => ({ ...state, turnRunning: false })); setChatAgentBusy(false); setProjectSupervisorRuntimeError(''); setChatComposerNotice( @@ -12796,7 +12194,6 @@ export function App({ ); return; } - setDirectCodexProgress('正在终止当前回合'); if (message) { setChatComposerNotice(message); } @@ -12839,7 +12236,7 @@ export function App({ ) { return; } - if (chatAgentBusy) { + if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { @@ -12966,9 +12363,7 @@ export function App({ transientReply={ planningV2Active ? planningV2TransientReply - : directCodexProductRuntime - ? directCodexTransientReply - : projectSupervisorTransientReply + : projectSupervisorTransientReply } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} @@ -12984,7 +12379,6 @@ export function App({ if (projectSupervisorOnly) { return ( ` 这一变体)。 */ export interface GameCreatorDirectToolCall { schemaVersion: string; @@ -1145,69 +1121,6 @@ export interface GameCreatorDirectToolCall { updatedAt: number; } -/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ -export type GameCreatorDirectTurnToolCall = Omit< - GameCreatorDirectToolCall, - 'turnId' ->; - -export interface GameCreatorDirectTurnUpdateEvent { - projectPath: string; - turnId: string; - sequence: number; - status: GameCreatorDirectTurnUpdateStatus; - activity?: GameCreatorDirectTurnActivity | null; - accumulatedText?: string | null; - /** - * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 - * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 - */ - toolCalls?: GameCreatorDirectTurnToolCall[] | null; - /** - * 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。 - */ - reasoningText?: string | null; - /** - * 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。 - * - * 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据, - * 前端不再自己猜切点。可选:老版本事件没有这个字段。 - */ - streamItems?: TurnStreamItem[] | null; - updatedAt: number; -} - -/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */ -export interface TurnStreamTextItem extends TurnStreamItemBase { - kind: 'text'; - text: string; -} - -/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */ -export interface TurnStreamToolItem extends TurnStreamItemBase { - kind: 'tool'; - callId: string; -} - -interface TurnStreamItemBase { - schemaVersion: string; - /** 幂等身份:文本段 `text::`、工具 `tool::`。 */ - id: string; - turnId: string; - /** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */ - seq: number; - /** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */ - at: number; - updatedAt: number; -} - -/** - * 回合流条目(`read_direct_turn_stream` 的返回元素)。 - * - * 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。 - */ -export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem; - /** `cancel_direct_codex_turn` 的返回值。 */ export interface DirectTurnCancelView { /** diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 604b908e0..dca93b24b 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -15,13 +15,10 @@ import { import type { AgentStatusCard, ChatMessage, - GameCreatorDirectToolCall, - GameCreatorDirectTurnUpdateStatus, PendingCommand, PendingUiConfirmation, PlanGddDecisionAction, PlanGddStateViewV1, - TurnStreamItem, } from '../../app/types'; import type { DesignClarificationRequest, @@ -60,11 +57,12 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import type { DirectChatEntry } from './directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + type DirectChatBlock, + type DirectChatTurn, directMessageTimestamp, - type DirectTurnPresentation, - splitDirectTurnContent, } from './directTurnPresentation'; import { PlanGddSurface } from './GddApprovalCard'; import { @@ -86,111 +84,6 @@ import { formatTurnDuration, } from './toolCallGroupPresentation'; -/** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */ -type TurnStreamToolRun = { - kind: 'tools'; - key: string; - callIds: string[]; -}; -type TurnStreamRun = - | { kind: 'text'; key: string; text: string } - | TurnStreamToolRun; - -function turnStreamRuns(items: readonly TurnStreamItem[]) { - const runs: TurnStreamRun[] = []; - for (const item of items) { - if (item.kind === 'text') { - const text = item.text ?? ''; - if (!text.trim()) { - continue; - } - runs.push({ kind: 'text', key: item.id, text }); - continue; - } - const callId = item.callId?.trim() ?? ''; - if (!callId) { - continue; - } - const last = runs[runs.length - 1]; - if (last?.kind === 'tools') { - // 连续的工具合并成一块:块的身份用首个工具条目 id,块头顺序不变。 - last.callIds.push(callId); - continue; - } - runs.push({ kind: 'tools', key: item.id, callIds: [callId] }); - } - return runs; -} - -/** - * 一个回合的对话流:**按条目顺序**渲染文本段与工具块。 - * - * 工具是普通元素,位置在它出现的地方:文本段 → 工具块 → 文本段 → …,最后是最终回复; - * 连续的工具合并成一块,中间夹了文本就分块。顺序只来自 `seq`,不做任何切点猜测。 - * 回合末尾由调用方补 `renderTurnUsage`(结束于 xxx,总耗时 xxx)。 - */ -function TurnStreamSequence({ - items, - toolCalls, - active, - userSentAt, - className, - tone = 'body', -}: { - items: readonly TurnStreamItem[]; - toolCalls: readonly GameCreatorDirectToolCall[]; - /** 这个回合是否正在跑(决定工具行显示"执行中")。 */ - active: boolean; - userSentAt: number; - className?: string; - tone?: AgentMessageTone; -}) { - const runs = turnStreamRuns(items); - const callsById = new Map(); - for (const call of toolCalls) { - const id = call.id?.trim(); - if (id && !callsById.has(id)) { - callsById.set(id, call); - } - } - return ( - <> - {runs.map((run, index) => - run.kind === 'text' ? ( -
- - - -
- ) : ( - callsById.get(callId)) - .filter((call): call is GameCreatorDirectToolCall => - Boolean(call), - )} - userSentAt={userSentAt} - active={active} - className="message-tool-call" - /> - ), - )} - - ); -} - /** 当前 Agent 和策划 Agent 的实时/历史思考使用同一个折叠入口。 */ function AgentReasoning({ text, @@ -217,25 +110,6 @@ function AgentReasoning({ type RuntimePanelProps = ComponentProps; -function directStatusTitle(status: string | null | undefined) { - switch (status) { - case 'accepted': - return '需求已接收'; - case 'running': - return '任务执行中'; - case 'streaming': - return '回复生成中'; - case 'finalizing': - return '结果整理中'; - case 'completed': - return '回复已生成'; - case 'failed': - return '处理失败'; - default: - return '任务执行中'; - } -} - type ProjectSupervisorViewProps = RuntimePanelProps & { activeVersionId?: string | null; /** 输入盒待发送附件:随下次提交进入回合(direct-codex 才渲染)。 */ @@ -247,9 +121,10 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { chatProjectAssets: import('../../../../../packages/shared/src/contracts/gameCreationApp').GameCreationAppAssetManifestEntry[]; composerRef?: RefObject; directCodex?: boolean; - directStatus?: GameCreatorDirectTurnUpdateStatus | null; - directProcessDetail?: string; - directProcessKey?: string; + /** 聊天条目:历史切片与运行态事件归并后的唯一顺序(工具卡片也在里面)。 */ + directEntries?: DirectChatEntry[]; + /** 最新回合是否还在跑;只由生命周期事件决定。 */ + directTurnRunning?: boolean; hiddenConversationCount: number; hasEarlierConversationMessages?: boolean; messagesRef: RefObject; @@ -276,28 +151,14 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { pendingConfirmation: PendingUiConfirmation | null; pendingCommand: PendingCommand | null; projectPath: string; - /** 本回合(含历史回读)的工具调用卡片,按 `startedAt` 升序,同一 id 只会出现一次。 */ - toolCalls?: GameCreatorDirectToolCall[]; - /** - * 「文本段 + 工具」的顺序真相(`turn-stream.jsonl` / 回合事件里的 `streamItems`)。 - * - * 有值的回合按它渲染(文本段 → 工具块 → 文本段 → …),没有值的回合回退到 - * 「工具块 + 整轮消息 + 整轮用量」的老渲染——老项目、缺文件、读取失败都不能白屏。 - */ - turnStreamItems?: TurnStreamItem[]; - /** 当前正在跑的回合 id;卡片在 assistant 消息落盘前锚到它。 */ - activeTurnId?: string | null; initialSupervisorMessage?: string; showProfessionalCollaboration?: boolean; transientReply: string; - /** 流式思考过程(direct-codex):拿不到就为空,空则不渲染。 */ - transientReasoning?: string; showDesignReasoning?: boolean; designReasoning?: string; designReasoningEntries?: DesignReasoningEntry[]; visibleMessages: ChatMessage[]; conversationMessages?: ChatMessage[]; - hasUnloadedHistory?: boolean; visibleProfessionalAgentCards: AgentStatusCard[]; walletEntry?: ReactNode; workspaceStatus: string; @@ -332,9 +193,8 @@ export function ProjectSupervisorView({ chatProjectAssets, composerRef, directCodex = false, - directStatus = null, - directProcessDetail = '', - directProcessKey = '', + directEntries = [], + directTurnRunning = false, hiddenConversationCount, hasEarlierConversationMessages = false, messagesRef, @@ -357,19 +217,14 @@ export function ProjectSupervisorView({ pendingConfirmation, pendingCommand, projectPath, - toolCalls = [], - turnStreamItems = [], - activeTurnId = null, initialSupervisorMessage = '', showProfessionalCollaboration = true, transientReply, - transientReasoning = '', showDesignReasoning = false, designReasoning = '', designReasoningEntries = [], visibleMessages, conversationMessages = visibleMessages, - hasUnloadedHistory = false, visibleProfessionalAgentCards, walletEntry, workspaceStatus, @@ -390,26 +245,18 @@ export function ProjectSupervisorView({ }: ProjectSupervisorViewProps) { const planningSurfaceActive = planningLane || isPlanningLaneRuntime(runtimePanelProps.runtime); - const [expandedProcessKey, setExpandedProcessKey] = useState( - null, - ); + const [settingsOpen, setSettingsOpen] = useState(false); + // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 + const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); + // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 + const [voiceNotice, setVoiceNotice] = useState(''); + const [approvalOpen, setApprovalOpen] = useState(false); + const [approvalMode, setApprovalMode] = useState('strict'); + const [approvalNotice, setApprovalNotice] = useState(''); useEffect(() => { - if (!activeTurnId) { - return; - } - setTurnUsageNow(Date.now()); - const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); - return () => clearInterval(timer); - }, [activeTurnId]); - - useEffect(() => { - setExpandedProcessKey(null); - }, [directProcessKey]); - useEffect(() => { - setActiveTurnStartedAt(activeTurnId ? Date.now() : 0); - }, [activeTurnId]); - const processDetailExpanded = - Boolean(directProcessKey) && expandedProcessKey === directProcessKey; + setApprovalOpen(false); + setApprovalNotice(''); + }, [settingsOpen]); const submitLabel = needsUserInput ? '等待回答' : runtimePanelProps.controlBusy @@ -420,34 +267,24 @@ export function ProjectSupervisorView({ const [modelValidating, setModelValidating] = useState(false); const modelSelectRef = useRef(null); const modelValidateInFlightRef = useRef(false); - // 设置浮层:Codex 顶栏只剩状态与齿轮,运行配置 / 审批模式 / 钱包都收进这里。 - const [settingsOpen, setSettingsOpen] = useState(false); - // 整轮会话的耗时在回合进行中要每秒刷新:用 tick 驱动的 `now` 计算"现在 - 开始"。 - const [turnUsageNow, setTurnUsageNow] = useState(() => Date.now()); - const [activeTurnStartedAt, setActiveTurnStartedAt] = useState(0); - // 语音输入的降级/失败提示:不支持时按钮本身就带提示,这里只承载启动失败与权限类错误。 - const [voiceNotice, setVoiceNotice] = useState(''); - const [approvalOpen, setApprovalOpen] = useState(false); - const [approvalMode, setApprovalMode] = useState('strict'); - const [approvalNotice, setApprovalNotice] = useState(''); useEffect(() => { - setApprovalOpen(false); - setApprovalNotice(''); - }, [settingsOpen]); + if (!directTurnRunning) { + return; + } + setTurnUsageNow(Date.now()); + const timer = setInterval(() => setTurnUsageNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [directTurnRunning]); const runBusy = runtimePanelProps.controlBusy || submitting; const directTurns = directCodex - ? buildDirectTurnPresentations({ - messages: conversationMessages, - visibleMessages, - items: turnStreamItems, - calls: toolCalls, - activeTurnId, - transientReply, - hasUnloadedHistory, + ? buildDirectChatTurns({ + entries: directEntries, + localMessages: conversationMessages, + turnRunning: directTurnRunning, }) : []; - const turnStartedAtFor = (turnId: string) => - directTurns.find((turn) => turn.turnId === turnId)?.startedAt ?? 0; + const activeTurnStartedAt = + directTurns.find((turn) => turn.active)?.startedAt ?? 0; const clockTimeWithSeconds = (timestamp: number) => { const date = new Date(timestamp); @@ -455,14 +292,13 @@ export function ProjectSupervisorView({ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; - const renderTurnUsage = (turn: DirectTurnPresentation) => { - if (!turn.turnId || turn.active || !turn.startedAt) return null; + const renderTurnUsage = (turn: DirectChatTurn) => { + if (turn.active || !turn.startedAt) return null; const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

{`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`} @@ -568,7 +404,7 @@ export function ProjectSupervisorView({ aria-hidden="true" /> {runBusy - ? directStatusTitle(directStatus) + ? '陶泥儿正在处理' : projectWorkspaceStatusForDisplay(workspaceStatus)} - ) : null} - - ) : null} ) : null}

; + settle: (anchor: string | null) => void; + /** 是否已经被一次首屏读取用掉:同一道闸门只锚定"这次订阅的第一次首屏"。 */ + consumed: boolean; +}; + +/** 为某个项目开一道闸门:`settle` 由订阅侧调用,`anchor` 由首屏读取 `await`。 */ +export function openDirectHistoryAnchorGate( + projectPath: string, +): DirectHistoryAnchorGate { + let settle: (anchor: string | null) => void = () => {}; + const anchor = new Promise((resolve) => { + settle = resolve; + }); + return { projectPath, anchor, settle, consumed: false }; +} + +/** 订阅侧用:已有同项目闸门就复用(首屏可能已经开好),换项目才新开一道。 */ +export function reuseOrOpenDirectHistoryAnchorGate( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate { + if (current && current.projectPath === projectPath) { + return current; + } + return openDirectHistoryAnchorGate(projectPath); +} + +/** + * 首屏读取侧用:取这次要等的闸门,`null` 表示这次首屏不该等锚点。 + * + * 没有这个项目的闸门(打开项目时订阅 effect 还没跑)就新开一道;同一个订阅下已经用过 + * (重开同一个项目)没有新回执可等,返回 `null` 让调用方按当前文件尾取尾屏。 + */ +export function directHistoryAnchorGateToWaitFor( + current: DirectHistoryAnchorGate | null, + projectPath: string, +): DirectHistoryAnchorGate | null { + const sameProjectGate = + current && current.projectPath === projectPath ? current : null; + if (!sameProjectGate) { + return openDirectHistoryAnchorGate(projectPath); + } + return sameProjectGate.consumed ? null : sameProjectGate; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts new file mode 100644 index 000000000..3065848b0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directHistoryPaging.ts @@ -0,0 +1,84 @@ +/** + * DirectProject 历史分页连拉:一次翻页操作连续取页,直到出现用户看得见的变化。 + * + * 可见性判断留在前端聊天投影(后端切片只按原始条目切片):工具卡片与思考文本同样占满一屏, + * 所以一整页完全可能都落进已经渲染的回合里,对用户就是"点了没变化"。判据因此取**合并后 + * 聊天投影的回合数增加**(出现新的用户气泡),而不是"这一页里有没有能渲染的条目"。 + * + * 这里是该判据的唯一出处:首屏与「显示更早」共用同一条循环,别在调用点各写一份。 + */ + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../../app/constants'; +import { + type DirectChatEntry, + mergeHistoryEntries, + projectDirectHistoryItems, +} from './directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from './directThreadEvents'; +import { buildDirectChatTurns } from './directTurnPresentation'; + +export type DirectHistoryPages = { + /** 累积到的条目,保持文件顺序(旧 → 新)。 */ + items: DirectThreadItem[]; + /** 后端声明的"还有更早的历史";取不动时收口为 false。 */ + hasMore: boolean; + /** 下一屏的锚点;取不动或失败时停在最后一个可用的锚点上。 */ + firstItemId: string | null; + /** 中断原因;`null` 表示正常停止。 */ + error: unknown; +}; + +function visibleTurnCount(entries: readonly DirectChatEntry[]): number { + return buildDirectChatTurns({ entries }).length; +} + +/** 取页是"从新往旧"的,拼回文件顺序要整个翻过来。 */ +function inFileOrder(pages: readonly (readonly DirectThreadItem[])[]) { + return [...pages].reverse().flat(); +} + +export async function readDirectHistoryPages({ + existingEntries, + beforeItemId, + readSlice, +}: { + /** 当前聊天视图里的条目:判据必须和用户看到的是同一份。 */ + existingEntries: readonly DirectChatEntry[]; + beforeItemId: string | null; + readSlice: (beforeItemId: string | null) => Promise; +}): Promise { + const baseTurnCount = visibleTurnCount(existingEntries); + const pages: DirectThreadItem[][] = []; + let anchor = beforeItemId; + let hasMore = false; + let error: unknown = null; + for (let page = 0; page < DIRECT_HISTORY_MAX_PAGES_PER_ACTION; page += 1) { + let slice: DirectThreadHistorySlice; + try { + slice = await readSlice(anchor); + } catch (thrown) { + // 已经取到的页照常交给调用方:第 N 页失败不该丢掉前 N-1 页。 + error = thrown; + break; + } + pages.push([...slice.items]); + hasMore = slice.hasMore; + const nextAnchor = slice.firstItemId; + if (slice.items.length === 0 || !nextAnchor || nextAnchor === anchor) { + // 锚点不前进就再也取不到更早的页:继续只会重复拿回同一个窗口。按"取不动了"收口。 + hasMore = false; + break; + } + anchor = nextAnchor; + if (!hasMore) break; + const merged = mergeHistoryEntries( + projectDirectHistoryItems(inFileOrder(pages)), + existingEntries, + ); + if (visibleTurnCount(merged) > baseTurnCount) break; + } + return { items: inFileOrder(pages), hasMore, firstItemId: anchor, error }; +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts new file mode 100644 index 000000000..76e4a5fa3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,272 @@ +/** + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject + * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; +import { projectDirectThreadItem } from './directThreadItemProjection'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: DirectChatToolCard | null; + at?: number; +}; + +export type DirectThreadChatState = { + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ + live: DirectChatEntry[]; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + turnRunning: false, + history: [], + live: [], + }; +} + +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; +} + +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; + return { + ...left, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), + detail: { + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, + }, + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: Math.max(left.updatedAt, right.updatedAt), + }; +} + +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + return { + itemId: existing.itemId || incoming.itemId, + kind: + existing.kind === 'tool' || incoming.kind === 'tool' + ? 'tool' + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, + }; +} + +function upsertLiveEntry( + state: DirectThreadChatState, + entry: DirectChatEntry, +): DirectThreadChatState { + const index = state.live.findIndex( + (existing) => existing.itemId === entry.itemId, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +function appendLiveText( + state: DirectThreadChatState, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': + return { ...state, turnRunning: true }; + case 'turn.completed': + // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 + return { + ...state, + turnRunning: false, + history: mergeHistoryEntries(state.history, state.live), + live: [], + }; + case 'item.delta': + return appendLiveText(state, event); + case 'item.started': + case 'item.completed': { + const entry = projectDirectThreadItem(event.item); + return entry ? upsertLiveEntry(state, entry) : state; + } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; + default: + return state; + } +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 订阅身份与首屏历史锚点(`subscriptionId` / `lastCompletedItemId`)是订阅循环自己的局部 + * 事实,不进聊天状态:这里只把 bootstrap 事件 reduce 进现有状态。 + */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, bootstrap.events); +} + +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( + state: DirectThreadChatState, + result: DirectThreadConsumeResult, +): DirectThreadChatState { + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); + continue; + } + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); + } + return entries; +} + +/** 历史切片条目 → 聊天条目:可见性判定的唯一入口,分页判据也读这一份。 */ +export function projectDirectHistoryItems( + items: readonly DirectThreadItem[], +): DirectChatEntry[] { + return items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); +} + +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadItem[], +): DirectThreadChatState { + return { + ...state, + history: mergeHistoryEntries( + projectDirectHistoryItems(items), + state.history, + ), + }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +export function selectDirectChatEntries( + state: DirectThreadChatState, +): DirectChatEntry[] { + return mergeHistoryEntries(state.history, state.live); +} 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..741e694f3 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,121 +1,17 @@ -import type { - ChatMessage, - LocalConversationMessageRecord, -} from '../../app/types'; +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -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> = {}, -): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; - if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; - return [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, - agentId: null, - messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, - }, - ]; - }); -} - -/** 只有这些状态表示仍持有活动回合;Provider 回放的终态不是活动快照。 */ -export function isDirectTurnInProgress( - status: string | null | undefined, -): status is 'accepted' | 'running' | 'streaming' | 'finalizing' { - return ( - status === 'accepted' || - status === 'running' || - status === 'streaming' || - status === 'finalizing' - ); -} +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts new file mode 100644 index 000000000..1f44ddda0 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,269 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + if (status === 'completed') return 'completed'; + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; + // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 + if (typeof exitCode === 'number') { + return exitCode === 0 ? 'completed' : 'failed'; + } + return status ? 'running' : 'completed'; +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + at: number; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; + if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || + detail.command || + changes[0]?.path || + input.tool || + ''; + return { + schemaVersion: 'agc-tool-call.v1', + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + status: input.status, + detail, + startedAt: input.at, + updatedAt: input.at, + }; +} + +function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }); + case 'function_call_output': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }); + case 'commandExecution': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }); + case 'fileChange': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }); + case 'mcpToolCall': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }); + case 'webSearch': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }); + case 'contextCompaction': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }); + default: + return null; + } +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 + */ +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, +): DirectChatEntry | null { + if (!item) return null; + // 身份是条目唯一的主键:拿不到身份的载荷既不能渲染也不能合并,只丢弃这一条。 + const itemId = typeof item.itemId === 'string' ? item.itemId.trim() : ''; + if (!itemId) return null; + + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } + } +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts index cd5327ef5..a481c03ed 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts @@ -1,30 +1,12 @@ -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../../app/types'; -import { projectSupervisorChatMessageText } from '../agent-runtime'; +/** + * DirectProject 聊天呈现:把聊天条目切成"用户气泡 / 过程 / 最终回复"三段。 + * + * 输入是唯一一份聊天条目(历史切片 + 运行态事件归并的结果),顺序就是条目顺序; + * 这里只做分区与合并(连续工具合成一块),不认回合身份,也不再从文本长度 / 标点猜切点。 + */ -export function directConversationTurnId(messageId: string | null | undefined) { - const match = /^direct-codex:(.+):(user|assistant|failure)$/.exec( - messageId ?? '', - ); - return match?.[1] ?? null; -} - -export type DirectTurnPresentation = { - key: string; - turnId: string | null; - messages: ChatMessage[]; - notices: ChatMessage[]; - items: TurnStreamItem[]; - calls: GameCreatorDirectToolCall[]; - source: 'stream' | 'messages'; - active: boolean; - transientReply: string; - startedAt: number; - endedAt: number; -}; +import type { ChatMessage } from '../../app/types'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; /** 统一 Direct 时间戳为 Unix 毫秒;历史/异常 payload 可能传 Unix 秒。 */ export function normalizeDirectTimestamp(value: number | undefined) { @@ -42,236 +24,264 @@ export function normalizeDirectTimestamp(value: number | undefined) { export const directMessageTimestamp = normalizeDirectTimestamp; -/** 从唯一呈现来源分区,完成后只把最终回复及失败提示留在过程折叠区之外。 */ -export function splitDirectTurnContent(turn: DirectTurnPresentation) { - const assistants = turn.messages.filter( - (message) => message.role === 'assistant', - ); - const isFailureMessage = (message: ChatMessage) => - Boolean( - message.messageId?.endsWith(':failure') && - directConversationTurnId(message.messageId), +export type DirectChatBlock = + | { kind: 'user'; key: string; text: string; at: number } + | { + kind: 'assistant'; + key: string; + text: string; + at: number; + notice?: boolean; + } + | { kind: 'reasoning'; key: string; text: string } + | { kind: 'tools'; key: string; calls: DirectChatToolCard[] }; + +export type DirectChatTurn = { + key: string; + /** 用户气泡:顺序即发出顺序。 */ + users: DirectChatBlock[]; + /** 过程块:工具与中间正文按条目顺序,连续工具合成一块。 */ + process: DirectChatBlock[]; + /** 最终回复,以及失败 / 终止这类只存在于运行期的说明。 */ + finals: DirectChatBlock[]; + active: boolean; + startedAt: number; + endedAt: number; +}; + +type DirectChatTurnEntries = { + key: string; + entries: DirectChatEntry[]; + /** 本地乐观用户气泡:还没有任何落盘条目时的用户消息。 */ + localUsers: DirectChatBlock[]; + notices: ChatMessage[]; + active: boolean; +}; + +function blockFromEntry( + entry: DirectChatEntry, + key: string, +): DirectChatBlock | null { + if (entry.kind === 'tool') { + return entry.toolCall + ? { kind: 'tools', key, calls: [entry.toolCall] } + : null; + } + const text = entry.text ?? ''; + if (!text.trim()) return null; + if (entry.kind === 'reasoning') { + return { kind: 'reasoning', key, text }; + } + return entry.role === 'user' + ? { kind: 'user', key, text, at: normalizeDirectTimestamp(entry.at) } + : { + kind: 'assistant', + key, + text, + at: normalizeDirectTimestamp(entry.at), + }; +} + +/** 连续的工具条目合成一块;中间夹了正文就分块。 */ +function mergeToolBlocks(blocks: DirectChatBlock[]): DirectChatBlock[] { + const merged: DirectChatBlock[] = []; + for (const block of blocks) { + const last = merged[merged.length - 1]; + if (block.kind === 'tools' && last?.kind === 'tools') { + last.calls.push(...block.calls); + continue; + } + merged.push( + block.kind === 'tools' ? { ...block, calls: [...block.calls] } : block, ); - const isFailureItem = (item: TurnStreamItem) => - item.id === `text:${turn.turnId}:failure`; - const failed = - assistants.some(isFailureMessage) || turn.items.some(isFailureItem); - const lastText = - !turn.active && !failed - ? turn.items - .filter((item) => item.kind === 'text' && item.text.trim()) - .at(-1) - : undefined; - const lastMessage = !turn.active && !failed ? assistants.at(-1) : undefined; + } + return merged; +} + +function blockFromLocalMessage( + message: ChatMessage, + index: number, +): DirectChatBlock | null { + const text = message.text.trim(); + if (!text) return null; + const key = message.messageId ?? `local:${index}`; + if (message.role === 'user') { + return { + kind: 'user', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + }; + } return { - processItems: turn.items.filter( - (item) => item !== lastText && !isFailureItem(item), - ), - finalItems: turn.items.filter( - (item) => item === lastText || isFailureItem(item), - ), - processMessages: assistants.filter( - (message) => message !== lastMessage && !isFailureMessage(message), - ), - finalMessages: assistants.filter( - (message) => message === lastMessage || isFailureMessage(message), - ), + kind: 'assistant', + key, + text: message.text, + at: normalizeDirectTimestamp(message.updatedAt), + notice: true, }; } -/** 完整历史先按身份归属,再决定可见回合;渲染层只消费这一个列表。 */ -export function buildDirectTurnPresentations({ - messages, - visibleMessages, - items, - calls, - activeTurnId, - transientReply, - hasUnloadedHistory = false, -}: { - messages: readonly ChatMessage[]; - visibleMessages: readonly ChatMessage[]; - items: readonly TurnStreamItem[]; - calls: readonly GameCreatorDirectToolCall[]; - activeTurnId?: string | null; - transientReply: string; - hasUnloadedHistory?: boolean; -}): DirectTurnPresentation[] { - const rows = new Map(); - const itemTurnIds = new Map(); - for (const item of items) { - const prefix = `text:${item.turnId}:`; - if (item.kind === 'text' && item.id.startsWith(prefix)) { - itemTurnIds.set(item.id.slice(prefix.length), item.turnId); - } - } - const ensure = (turnId: string | null, key: string) => { - const existing = rows.get(key); - if (existing) return existing; - const row: DirectTurnPresentation = { - key, - turnId, - messages: [], - notices: [], - items: [], - calls: [], - source: 'messages', - active: Boolean(turnId && turnId === activeTurnId), - transientReply: '', - startedAt: 0, - endedAt: 0, - }; - rows.set(key, row); - return row; +function newTurn(key: string): DirectChatTurnEntries { + return { + key, + entries: [], + localUsers: [], + notices: [], + active: false, }; +} - // 原始 assistant id 可通过流 item 精确反查。旧用户记录无 turn id 时, - // 只用同一用户记录区间内的这种精确证据关联,不与第 N 个工具回合配对。 - const boundaryTurnIds = new Map(); - let boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const id = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? ''); - if (id && boundary >= 0 && !boundaryTurnIds.has(boundary)) { - boundaryTurnIds.set(boundary, id); - } - }); - const visible = new Set(visibleMessages); - const visibleKeys = new Set(); - const seenMessages = new Set(); - boundary = -1; - messages.forEach((message, index) => { - if (message.role === 'user') boundary = index; - const turnId = - directConversationTurnId(message.messageId) ?? - itemTurnIds.get(message.messageId ?? '') ?? - boundaryTurnIds.get(boundary) ?? - null; - const key = turnId - ? `turn:${turnId}` - : `history:${boundary < 0 ? index : boundary}`; - const row = ensure(turnId, key); - if (visible.has(message)) visibleKeys.add(key); - if (message.messageId && seenMessages.has(message.messageId)) return; - if (message.messageId) seenMessages.add(message.messageId); - row.messages.push(message); - }); - - const itemsByKey = new Map(); - for (const item of items) { - const key = `${item.turnId}\0${item.id}`; - const previous = itemsByKey.get(key); - if (!previous || item.updatedAt >= previous.updatedAt) - itemsByKey.set(key, item); - } - for (const item of itemsByKey.values()) { - ensure(item.turnId, `turn:${item.turnId}`).items.push(item); - } - const callsByKey = new Map(); - for (const call of calls) { - const key = `${call.turnId}\0${call.id}`; - const previous = callsByKey.get(key); - if (!previous || call.updatedAt >= previous.updatedAt) - callsByKey.set(key, call); - } - for (const call of callsByKey.values()) { - ensure(call.turnId, `turn:${call.turnId}`).calls.push(call); - } - if (activeTurnId) ensure(activeTurnId, `turn:${activeTurnId}`); - - for (const row of rows.values()) { - row.items.sort((a, b) => a.seq - b.seq || a.id.localeCompare(b.id)); - row.calls.sort( - (a, b) => a.startedAt - b.startedAt || a.id.localeCompare(b.id), - ); - const assistants = row.messages.filter( - (message) => message.role === 'assistant', - ); - const rawAssistants = assistants.filter( - (message) => - message.messageId && !directConversationTurnId(message.messageId), - ); - const textIds = new Set( - row.items.filter((item) => item.kind === 'text').map((item) => item.id), - ); - // 精确 item id 才能补齐旧快照正文;无法证明流覆盖完整历史时,整轮使用历史, - // 不同时渲染“部分流 + 全文”,也不按长度比例猜测是否重复。 - const historyCovered = assistants.every( - (message) => - Boolean(directConversationTurnId(message.messageId)) || - Boolean( - message.messageId && - textIds.has(`text:${row.turnId}:${message.messageId}`), - ), - ); - row.source = - row.items.length > 0 && - historyCovered && - (textIds.size > 0 || assistants.length === 0) - ? 'stream' - : 'messages'; - if (row.source === 'stream') { - row.notices = assistants.filter( - (message) => - message.messageId === `direct-codex:${row.turnId}:failure` && - !textIds.has(`text:${row.turnId}:failure`), - ); - const historyById = new Map( - rawAssistants.map((message) => [ - `text:${row.turnId}:${message.messageId}`, - message, - ]), - ); - row.items = row.items.map((item) => { - const history = historyById.get(item.id); - return item.kind === 'text' && history - ? { ...item, text: projectSupervisorChatMessageText(history) } - : item; - }); - } - if (row.source === 'messages' && rawAssistants.length > 0) { - const lastText = row.items.filter((item) => item.kind === 'text').at(-1); - const lastRaw = rawAssistants.at(-1); - // 最后一个原始 item 与流尾身份一致时,GUI 整轮回执不是另一条回复。 - if ( - lastText && - lastRaw && - lastText.id === `text:${row.turnId}:${lastRaw.messageId}` - ) { - row.messages = row.messages.filter( - (message) => - message.messageId !== `direct-codex:${row.turnId}:assistant`, - ); +/** + * 条目 + 运行期本地消息 → 回合列表。 + * + * 每个用户条目开一个新回合;本地用户气泡(乐观发送)也算开新回合;本地 assistant 消息 + * (失败 / 终止说明)挂到当前回合末尾。同身份的本地消息不重复渲染:条目赢。 + */ +export function buildDirectChatTurns({ + entries, + localMessages = [], + turnRunning = false, +}: { + entries: readonly DirectChatEntry[]; + localMessages?: readonly ChatMessage[]; + turnRunning?: boolean; +}): DirectChatTurn[] { + const turns: DirectChatTurnEntries[] = []; + let current: DirectChatTurnEntries | null = null; + // 分页切片的开头可能落在半截回合里(那一条用户条目还在更早的一屏):这些前导条目先攒着, + // 交给后面第一个用户条目开的回合,避免渲染出一个没有用户气泡的孤儿回合。 + const leadingEntries: DirectChatEntry[] = []; + for (const entry of entries) { + if (entry.kind === 'message' && entry.role === 'user') { + current = newTurn(entry.itemId); + turns.push(current); + if (leadingEntries.length > 0) { + current.entries.push(...leadingEntries); + leadingEntries.length = 0; } } - if (row.active && row.source === 'messages' && assistants.length === 0) { - row.transientReply = transientReply; + if (!current) { + leadingEntries.push(entry); + continue; } - const starts = [ - ...row.messages - .filter((message) => message.role === 'user') - .map((message) => directMessageTimestamp(message.updatedAt)), - ...row.items.map((item) => directMessageTimestamp(item.at)), - ...row.calls.map((call) => directMessageTimestamp(call.startedAt)), - ].filter((at) => at > 0); - row.startedAt = starts.length ? Math.min(...starts) : 0; - row.endedAt = Math.max( - 0, - ...row.messages.map((message) => - directMessageTimestamp(message.updatedAt), - ), - ...row.items.map((item) => directMessageTimestamp(item.updatedAt)), - ...row.calls.map((call) => directMessageTimestamp(call.updatedAt)), - ); + current.entries.push(entry); } - return [...rows.values()].filter( - (row) => - row.active || - visibleKeys.has(row.key) || - (!hasUnloadedHistory && - row.messages.length === 0 && - (row.items.length > 0 || row.calls.length > 0)), - ); + if (!current && leadingEntries.length > 0) { + // 整份历史都没有用户条目:仍然保留一个回合承载正文。 + current = newTurn(`history:${turns.length}`); + current.entries.push(...leadingEntries); + turns.push(current); + } + + const entryIds = new Set(entries.map((entry) => entry.itemId)); + localMessages.forEach((message, index) => { + if (message.messageId && entryIds.has(message.messageId)) return; + if (message.role === 'user') { + const block = blockFromLocalMessage(message, index); + current = newTurn(message.messageId ?? `local:${index}`); + turns.push(current); + if (block) current.localUsers.push(block); + return; + } + if (!current) { + current = newTurn(`history:${turns.length}`); + turns.push(current); + } + current.notices.push(message); + }); + if (current) current.active = turnRunning; + + return turns.map((turn) => { + const lastAssistant = turn.active + ? -1 + : turn.entries.reduce( + (found, entry, index) => + entry.kind === 'message' && entry.role === 'assistant' + ? index + : found, + -1, + ); + const users: DirectChatBlock[] = []; + const process: DirectChatBlock[] = []; + const finals: DirectChatBlock[] = []; + turn.entries.forEach((entry, index) => { + const block = blockFromEntry(entry, `${turn.key}:${entry.itemId}`); + if (!block) return; + if (block.kind === 'user') { + users.push(block); + return; + } + if (index === lastAssistant) { + finals.push(block); + return; + } + process.push(block); + }); + users.push(...turn.localUsers); + turn.notices.forEach((message, index) => { + const block = blockFromLocalMessage(message, index); + if (block) finals.push(block); + }); + const times = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ...users.map((block) => (block.kind === 'user' ? block.at : 0)), + ].filter((at) => at > 0); + const startedAt = times.length ? Math.min(...times) : 0; + const endedAt = [ + ...turn.entries.map((entry) => normalizeDirectTimestamp(entry.at)), + ...turn.notices.map((message) => + normalizeDirectTimestamp(message.updatedAt), + ), + ].reduce((latest, at) => Math.max(latest, at), 0); + return { + key: turn.key, + users, + process: mergeToolBlocks(process), + finals, + active: turn.active, + startedAt, + endedAt, + } satisfies DirectChatTurn; + }); +} + +/** 条目里的用户 / 助手消息转聊天消息(独立窗口只渲染气泡,不渲染工具卡片)。 */ +export function directChatEntriesToMessages( + entries: readonly DirectChatEntry[], +): ChatMessage[] { + return entries.flatMap((entry): ChatMessage[] => { + if (entry.kind !== 'message') return []; + const text = entry.text ?? ''; + if (!text.trim()) return []; + return [ + { + role: entry.role === 'user' ? 'user' : 'assistant', + text, + runtimeOwned: true, + messageId: entry.itemId, + updatedAt: normalizeDirectTimestamp(entry.at), + }, + ]; + }); +} + +/** 同一 messageId 只留一条:条目版本在前,本地运行期消息只补空位。 */ +export function mergeDirectChatMessages( + leading: readonly ChatMessage[], + trailing: readonly ChatMessage[], +): ChatMessage[] { + const seen = new Set(); + const merged: ChatMessage[] = []; + for (const message of [...leading, ...trailing]) { + if (message.messageId) { + if (seen.has(message.messageId)) continue; + seen.add(message.messageId); + } + merged.push(message); + } + return merged; } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts new file mode 100644 index 000000000..ea7e6e112 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadConsumeResult = { events: Array }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts new file mode 100644 index 000000000..215819e1a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 增量正文属于哪类条目。 + */ +export type DirectThreadDeltaKind = 'message' | 'reasoning'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts new file mode 100644 index 000000000..a4b0d7688 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +import type { DirectThreadItem } from './DirectThreadItem'; +import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; + +/** + * Thread Manager 下发的运行态事件。 + * + * 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: + * 游标是 Thread Manager 的内部事实,不下发。 + * + * 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 + * 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 + */ +export type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { + type: 'item.delta'; + itemId: string; + kind: DirectThreadDeltaKind; + delta: string; + } + | { + type: 'request'; + kind: DirectThreadRequestKind; + requestId: string | null; + }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts new file mode 100644 index 000000000..a510ccb68 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条文件变更。 + */ +export type DirectThreadFileChange = { + path: string; + /** + * `add` | `update` | `delete` + */ + kind: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts new file mode 100644 index 000000000..9ac30f139 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadItem } from './DirectThreadItem'; + +export type DirectThreadHistorySlice = { + /** + * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + */ + items: Array; + hasMore: boolean; + /** + * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + */ + firstItemId: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts new file mode 100644 index 000000000..f1257ddcc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts @@ -0,0 +1,76 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadFileChange } from './DirectThreadFileChange'; + +/** + * 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 + * + * `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 + * 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 + * + * 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, + * 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 + */ +export type DirectThreadItem = + | { + itemType: 'message'; + /** + * 归一身份:全链路只有这一个 id。 + */ + itemId: string; + /** + * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + */ + role: string; + text: string; + at: number; + } + | { itemType: 'reasoning'; itemId: string; text: string; at: number } + | { + itemType: 'function_call'; + itemId: string; + name: string; + arguments: string; + at: number; + } + | { + itemType: 'function_call_output'; + itemId: string; + output: string; + at: number; + } + | { + itemType: 'commandExecution'; + itemId: string; + command: string; + output: string | null; + /** + * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + */ + status: string | null; + exitCode: number | null; + at: number; + } + | { + itemType: 'fileChange'; + itemId: string; + changes: Array; + at: number; + } + | { + itemType: 'mcpToolCall'; + itemId: string; + tool: string; + arguments: string; + output: string | null; + status: string | null; + at: number; + } + | { + itemType: 'webSearch'; + itemId: string; + query: string | null; + output: string | null; + at: number; + } + | { itemType: 'contextCompaction'; itemId: string; at: number } + | { itemType: 'other'; itemId: string; rawType: string; at: number }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts new file mode 100644 index 000000000..13ee401f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 + */ +export type DirectThreadRequestKind = + | 'approval.requested' + | 'ask.requested' + | 'request.resolved'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts new file mode 100644 index 000000000..45efbf4b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadSubscriptionBootstrap = { + subscriptionId: string; + /** + * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + */ + lastCompletedItemId: string | null; + /** + * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + */ + events: Array; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aeeebc8f8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +export type { DirectThreadEvent } from './DirectThreadEvent'; +export type { DirectThreadFileChange } from './DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; +export type { DirectThreadItem } from './DirectThreadItem'; +export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..e41ca0354 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,5 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial< /** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], + calls: DirectChatToolCard[], running = false, ) { const counts = new Map(); @@ -77,7 +75,7 @@ export function toolCallGroupSummary( } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) { * 这两种情况不显示耗时,不显示 `0s` / 负数。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; @@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) { /** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ export function turnToolCallDurationMs( - calls: Array>, + calls: Array>, ): number | null { let minStartedAt = Number.POSITIVE_INFINITY; let maxUpdatedAt = Number.NEGATIVE_INFINITY; @@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) { /** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ export function turnToolCallEndedAt( - calls: Array>, + calls: Array>, ) { let maxUpdatedAt = 0; for (const call of calls) { @@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) { * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 */ export function turnToolCallTimeLabel( - calls: Array>, + calls: Array>, userSentAt: number | null | undefined, ) { const endLabel = formatClockTime(turnToolCallEndedAt(calls)); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 45f59dcd4..d299cdd55 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) { } /** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */ -async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { +async function openDirectCodexSurface( + overrides: InvokeOverrides = {}, + beforeOpen?: ( + harness: ReturnType, + ) => void, +) { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath: DYNAMIC_GAME_PROJECT_PATH, initialSessionExists: false, }); + beforeOpen?.(supervisorHarness); const manifest = createGameCreationAppManifest( 'local-project-draft', '输入盒控件项目', @@ -110,7 +116,12 @@ async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(DYNAMIC_GAME_PROJECT_PATH); const surface = await screen.findByLabelText('陶泥儿项目对话'); - return { invoke, path: DYNAMIC_GAME_PROJECT_PATH, surface }; + return { + invoke, + path: DYNAMIC_GAME_PROJECT_PATH, + surface, + harness: supervisorHarness, + }; } async function submitDirectTurn( @@ -474,14 +485,69 @@ export function registerChatComposerControlTests() { }); }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, surface, harness } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }, + (directHarness) => { + directHarness.emitDirectThreadEvents({ type: 'turn.started' }); + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + + await setComposerText(composer, '恢复后排队的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(0); + + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '恢复后排队的消息' }), + ); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); + + await act(async () => { + pending[0]?.resolve('排队回合回复'); + }); + }); + it('terminates the running turn and returns the composer to the idle state', async () => { const pending: Array<{ resolve: (value: string) => void; reject: (error: Error) => void; }> = []; - const { invoke, path, surface } = await openDirectCodexSurface({ + const { invoke, path, surface, harness } = await openDirectCodexSurface({ chat_with_game_creator_direct_codex: () => new Promise((resolve, reject) => { + // 回合真正开跑:生命周期事件由订阅下发,界面据此进入"可终止"。 + harness.emitDirectThreadEvents({ type: 'turn.started' }); pending.push({ resolve, reject }); }), cancel_direct_codex_turn: async () => undefined, @@ -502,13 +568,16 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { projectPath: path, - clientTurnId: expect.any(String), }); }); // app-server 的中断原因回到前端:不是失败,UI 必须回到可用态。 act(() => { pending[0]?.reject(new Error('Codex app-server turn 已中断')); + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'interrupted', + }); }); await waitFor(() => { const send = within(surface).getByRole('button', { name: '发送' }); @@ -518,6 +587,43 @@ export function registerChatComposerControlTests() { expect(within(surface).getByText('已终止本次回合。')).not.toBeNull(); }); + it('terminates a turn that was submitted before its lifecycle event arrived', async () => { + // 刚提交、`turn.started` 还没下发:按钮已经变成「终止」,点击不能回"没有正在运行的回合"。 + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, path, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + cancel_direct_codex_turn: async () => undefined, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '做一个小游戏'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '做一个小游戏' }), + ); + }); + + const stopButton = await within(surface).findByRole('button', { + name: '终止', + }); + fireEvent.click(stopButton); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('cancel_direct_codex_turn', { + projectPath: path, + }); + }); + expect( + within(surface).queryByText('当前没有正在运行的回合,无法终止。'), + ).toBeNull(); + + await act(async () => { + pending[0]?.resolve('回合回复'); + }); + }); + it('moves the reasoning effort control next to the model selector and persists only for later turns', async () => { let stored = 'high'; const { invoke, surface } = await openDirectCodexSurface({ diff --git a/apps/ai-game-creator-shell/tests/appSurface/harness.ts b/apps/ai-game-creator-shell/tests/appSurface/harness.ts index 5dbedafbf..98436f59b 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/harness.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/harness.ts @@ -627,6 +627,23 @@ function createPlanGddStateView( }; } +/** + * 运行态事件里的条目身份:只有 `item.started` / `item.completed` 带条目。 + * + * `item.delta` 只带 itemId 不带条目,属于瞬时事件,不参与 bootstrap 回放。 + */ +function directThreadEventItemId( + event: Record, +): string | null { + if (event.type !== 'item.started' && event.type !== 'item.completed') { + return null; + } + const item = event.item; + if (!item || typeof item !== 'object') return null; + const itemId = (item as Record).itemId; + return typeof itemId === 'string' && itemId ? itemId : null; +} + function createProjectSupervisorRuntimeHarness({ projectPath = '/tmp/authorized-game', sessionId = 'supervisor-session-active', @@ -744,6 +761,31 @@ function createProjectSupervisorRuntimeHarness({ let designAgentUpdateHandler: | ((event: { payload: Record }) => void) | null = null; + let directThreadNotifyHandler: + | ((event: { payload: { subscriptionId: string } }) => void) + | null = null; + let directThreadSubscriptionId: string | null = null; + let directThreadSubscriptionSequence = 0; + // 未消费的运行态事件队列:`subscribe` 的 bootstrap 与 `consume` 都从这里取, + // 与 Rust 侧"游标在队尾、事件按序下发"的语义一致。 + let pendingDirectThreadEvents: Array> = []; + let directThreadHistoryItems: Array> = []; + let directThreadLastCompletedItemId: string | null = null; + // 运行态事件里只有一个"最新回合是否在跑"的布尔:与 Thread Manager 只保留一条生命周期 + // 锚点一致,重复 `turn.started` 在生产里不会出现。 + let directThreadTurnRunning = false; + // bootstrap 的回放规则与 Rust 侧 `is_bootstrap_event` 对齐:只补"这一刻还没结束的条目" + // 与最新一条生命周期锚点;已完成条目、增量正文这类瞬时事件都不回放。 + const directThreadActiveItemIds = new Set(); + const directThreadPendingEventSeqs = new Map< + Record, + number + >(); + let directThreadEventSequence = 0; + let directThreadLifecycleAnchor: { + seq: number; + event: Record; + } | null = null; const conversationRecord = ( role: 'user' | 'assistant', @@ -938,6 +980,93 @@ function createProjectSupervisorRuntimeHarness({ const states = runtimeMapLoader ? await runtimeMapLoader() : []; return states.map((state) => runtimeResult(state)); } + if (command === 'subscribe_direct_project_thread') { + directThreadSubscriptionSequence += 1; + directThreadSubscriptionId = `direct-thread-${directThreadSubscriptionSequence}`; + const pending = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + // 游标落在队尾:只有"还没结束的条目"与最新生命周期锚点作为 bootstrap 回放, + // 已完成条目与增量正文不再补发(Rust 侧 `is_bootstrap_event` 的同一口径)。 + const replay = pending + .filter((event) => { + const itemId = directThreadEventItemId(event); + return Boolean(itemId && directThreadActiveItemIds.has(itemId)); + }) + .map((event) => ({ + seq: directThreadPendingEventSeqs.get(event) ?? 0, + event, + })); + for (const event of pending) { + directThreadPendingEventSeqs.delete(event); + } + if ( + directThreadLifecycleAnchor && + !replay.some( + (entry) => entry.event === directThreadLifecycleAnchor?.event, + ) + ) { + replay.push(directThreadLifecycleAnchor); + } + replay.sort((left, right) => left.seq - right.seq); + return { + subscriptionId: directThreadSubscriptionId, + lastCompletedItemId: directThreadLastCompletedItemId, + events: replay.map((entry) => entry.event), + }; + } + if (command === 'consume_direct_project_thread') { + if (String(args?.subscriptionId ?? '') !== directThreadSubscriptionId) { + throw new Error('SUBSCRIPTION_EXPIRED'); + } + const events = pendingDirectThreadEvents; + pendingDirectThreadEvents = []; + return { events }; + } + if (command === 'read_direct_project_history_slice') { + // 生产口径:从文件尾反向取一屏可显示条目——`throughItemId` 是窗口新端边界(含该条, + // 首屏用),`beforeItemId` 是旧端边界(不含该条,翻页用);收满一屏之后再见一条才算 + // `hasMore`,`firstItemId` 是本屏最老一条的 itemId。 + const requestedLimit = Number(args?.limit ?? 20); + const limit = Math.min( + Math.max(Number.isFinite(requestedLimit) ? requestedLimit : 20, 1), + 200, + ); + const throughItemId = + typeof args?.throughItemId === 'string' && args.throughItemId + ? args.throughItemId + : null; + const beforeItemId = + typeof args?.beforeItemId === 'string' && args.beforeItemId + ? args.beforeItemId + : null; + let end = directThreadHistoryItems.length; + if (throughItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === throughItemId, + ); + if (anchorIndex < 0) { + throw new Error( + `DirectProject 历史中不存在 item:${throughItemId}`, + ); + } + end = anchorIndex + 1; + } else if (beforeItemId) { + const anchorIndex = directThreadHistoryItems.findIndex( + (item) => String(item.itemId ?? '') === beforeItemId, + ); + end = + anchorIndex >= 0 ? anchorIndex : directThreadHistoryItems.length; + } + const start = Math.max(0, end - limit); + const window = directThreadHistoryItems.slice(start, end); + const oldest = window[0]; + return { + items: [...window], + hasMore: start > 0, + firstItemId: + oldest && typeof oldest.itemId === 'string' ? oldest.itemId : null, + }; + } if (command === 'start_game_creator_supervisor_runtime_task') { if (args?.runProfile !== expectedRunProfile) { throw new Error('unexpected Project Supervisor run profile'); @@ -1120,6 +1249,10 @@ function createProjectSupervisorRuntimeHarness({ designAgentUpdateHandler = handler as unknown as typeof designAgentUpdateHandler; } + if (eventName === 'game-creator-direct-thread-notify') { + directThreadNotifyHandler = + handler as unknown as typeof directThreadNotifyHandler; + } return () => { if (runtimeUpdateHandler === handler) { runtimeUpdateHandler = null; @@ -1133,10 +1266,45 @@ function createProjectSupervisorRuntimeHarness({ if (designAgentUpdateHandler === handler) { designAgentUpdateHandler = null; } + if (directThreadNotifyHandler === handler) { + directThreadNotifyHandler = null; + } }; }, ); + /** 追加运行态事件并唤醒订阅者:bootstrap / consume 共用同一份队列。 */ + const emitDirectThreadEvents = ( + ...events: Array> + ) => { + for (const event of events) { + directThreadEventSequence += 1; + directThreadPendingEventSeqs.set(event, directThreadEventSequence); + if (event.type === 'turn.started') directThreadTurnRunning = true; + if (event.type === 'turn.completed') directThreadTurnRunning = false; + if (event.type === 'turn.started' || event.type === 'turn.completed') { + // 生命周期锚点只留最新一条,与 Thread Manager 的 `lifecycle_anchor` 一致。 + directThreadLifecycleAnchor = { + seq: directThreadEventSequence, + event, + }; + } + const itemId = directThreadEventItemId(event); + if (itemId && event.type === 'item.started') { + directThreadActiveItemIds.add(itemId); + } + if (itemId && event.type === 'item.completed') { + directThreadActiveItemIds.delete(itemId); + } + } + pendingDirectThreadEvents.push(...events); + if (directThreadSubscriptionId) { + directThreadNotifyHandler?.({ + payload: { subscriptionId: directThreadSubscriptionId }, + }); + } + }; + return { invoke, listen, @@ -1229,6 +1397,67 @@ function createProjectSupervisorRuntimeHarness({ }, }); }, + emitDirectThreadEvents, + /** + * 一轮 Direct 回合的标准事件序列:生命周期 → 落盘用户条目 → 助手正文 → 终态。 + * + * 与 Rust 侧一致:消息身份是 `direct-codex:{turnId}:{role}`,工具条目另配 itemId。 + */ + completeDirectThreadTurn({ + turnId, + reply, + prompt = '', + at = 9_000, + status = 'completed', + }: { + turnId: string; + reply: string; + prompt?: string; + at?: number; + status?: string; + }) { + // 生命周期锚点只有一份:回合已经在跑时不再补 `turn.started`。 + const events: Array> = directThreadTurnRunning + ? [] + : [{ type: 'turn.started' }]; + if (prompt.trim()) { + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:user`, + role: 'user', + text: prompt, + at, + }, + }); + } + events.push({ + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${turnId}:assistant`, + role: 'assistant', + text: reply, + at: at + 1, + }, + }); + directThreadLastCompletedItemId = `direct-codex:${turnId}:assistant`; + events.push({ type: 'turn.completed', status }); + emitDirectThreadEvents(...events); + }, + setDirectThreadHistory(items: Array>) { + directThreadHistoryItems = [...items]; + // 生产口径:`subscribe` 回执里的 `lastCompletedItemId` 是订阅那一刻文件里最后一条可显示 + // 条目(Rust 侧由 `read_direct_project_last_item_id_at` 从磁盘回填)。 + const newest = directThreadHistoryItems.at(-1); + directThreadLastCompletedItemId = + typeof newest?.itemId === 'string' ? newest.itemId : null; + }, + /** 模拟"订阅回执给的就是这一刻的最后一条已完成条目":之后落盘的条目只应从运行态事件来。 */ + setDirectThreadLastCompletedItemId(itemId: string | null) { + directThreadLastCompletedItemId = itemId; + }, }; } 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 475242de0..b327549b4 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1553,6 +1553,11 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '你好,今天多少号', + reply: '别这么骂自己,具体发生什么了?', + }); return '别这么骂自己,具体发生什么了?'; } return supervisorHarness.invoke(command, args); @@ -1690,6 +1695,11 @@ export function registerHomeProjectCreationTests() { return '角色参考游戏'; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '按这个角色做游戏', + reply: '附件已经进入当前项目。', + }); return '附件已经进入当前项目。'; } return supervisorHarness.invoke(command, args); @@ -2520,8 +2530,12 @@ export function registerHomeProjectCreationTests() { }; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); - return { items: [...persistedMessages], hasMore: false }; + expect(args).toEqual({ projectPath, limit: 20 }); + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_conversation_message') { throw new Error( @@ -2530,14 +2544,22 @@ export function registerHomeProjectCreationTests() { } if (command === 'chat_with_game_creator_direct_codex') { const clientTurnId = String(args?.clientTurnId ?? ''); - persistedMessages.push(args?.userItem as Record, { - role: 'assistant', - type: 'message', - content: [ - { type: 'output_text', text: 'DIRECT_EXISTING_PROJECT_OK' }, - ], - id: `direct-codex:${clientTurnId}:assistant`, - }); + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '继续修改已有项目', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: 'DIRECT_EXISTING_PROJECT_OK', + at: 9_001, + }, + ); return 'DIRECT_EXISTING_PROJECT_OK'; } throw new Error(`unexpected invoke ${command}`); @@ -2606,8 +2628,12 @@ export function registerHomeProjectCreationTests() { return manifest; } if (command === 'read_direct_project_history_slice') { - expect(args).toEqual({ projectPath, limit: 20, messagesOnly: true }); - return { items: [...persistedMessages], hasMore: false }; + expect(args).toEqual({ projectPath, limit: 20 }); + return { + items: [...persistedMessages], + hasMore: false, + firstItemId: null, + }; } if (command === 'append_local_permission_log') { return {}; @@ -2631,17 +2657,23 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - persistedMessages.push(args?.userItem as Record, { - id: `direct-codex:${clientTurnId}:assistant`, - type: 'message', - role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], - }); + // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 + persistedMessages.push( + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '生成一个游戏', + at: 9_000, + }, + { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:assistant`, + role: 'assistant', + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, + }, + ); throw new Error('codex-app-server-error:unauthorized'); } throw new Error(`unexpected invoke ${command}`); @@ -2666,21 +2698,18 @@ export function registerHomeProjectCreationTests() { }); expect(persistedMessages).toEqual([ { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), role: 'user', - content: [{ type: 'input_text', text: '生成一个游戏' }], + text: '生成一个游戏', + at: 9_000, }, { - id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - type: 'message', + itemType: 'message', + itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), role: 'assistant', - content: [ - { - type: 'output_text', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - }, - ], + text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', + at: 9_001, }, ]); expect(JSON.stringify(persistedMessages)).not.toContain( 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 18bda4813..1d12fb99a 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 @@ -7323,6 +7323,11 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { + supervisorHarness.completeDirectThreadTurn({ + turnId: String(args?.clientTurnId ?? ''), + prompt: '从旧任务状态继续生成,但使用 direct Codex', + reply: 'DIRECT_AFTER_RECONCILIATION_OK', + }); return 'DIRECT_AFTER_RECONCILIATION_OK'; } if (command === 'get_local_game_preview_status') { @@ -7717,7 +7722,7 @@ export function registerProjectSupervisorSurfaceTests() { }); }); - it.skip('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { + it('opens an existing project without hydrating legacy Supervisor history, then sends consecutive direct Codex turns', async () => { const projectPath = '/tmp/launcher-supervisor-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -7726,28 +7731,9 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); - }, - ); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -7766,20 +7752,28 @@ export function registerProjectSupervisorSurfaceTests() { return manifest; } if (command === 'chat_with_game_creator_direct_codex') { - return `DIRECT_REPLY:${String(args?.prompt ?? '')}`; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; + } + if (command === 'get_local_game_preview_status') { + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + return { status: 'stopped', url: null, port: null, root: null }; } return supervisorHarness.invoke(command, args); }, ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // DirectProject 拥有自己的会话身份:打开项目不得触碰老 Supervisor 会话与运行态。 expect( within(supervisorSurface).queryByText('已恢复的项目总控历史'), ).toBeNull(); @@ -7796,10 +7790,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toBe(false); expect(screen.queryByLabelText('专业 Agent 协作状态')).toBeNull(); - expect(screen.queryByRole('status', { name: '自动执行' })).toBeNull(); expect(screen.queryByLabelText('子 Agent 状态栏')).toBeNull(); expect(screen.queryByText('严格审批')).toBeNull(); - expect(screen.queryByRole('button', { name: /审批配置/ })).toBeNull(); expect(screen.queryByLabelText('选择 Agent')).toBeNull(); expect(screen.queryByRole('dialog', { name: 'Agent 对话' })).toBeNull(); expect(supervisorHarness.listen).not.toHaveBeenCalledWith( @@ -7810,53 +7802,31 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const firstDirectReply = createDeferred(); - const secondDirectReply = createDeferred(); - let directReplyCount = 0; - invoke.mockImplementation( - async (command: string, args?: Record) => { - if (command === 'chat_with_game_creator_direct_codex') { - directReplyCount += 1; - return directReplyCount === 1 - ? firstDirectReply.promise - : secondDirectReply.promise; - } - return supervisorHarness.invoke(command, args); - }, - ); - await setComposerText( screen.getByLabelText('陶泥儿对话内容'), '先完成正式客户端玩法拆解', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); - await waitFor(() => - expect( - within( - within(supervisorSurface).getByTestId('agent-tool-call-group'), - ).getByText('需求已接收'), - ).not.toBeNull(), - ); - const directMessageList = - within(supervisorSurface).getByLabelText('陶泥儿消息'); - Object.defineProperties(directMessageList, { - clientHeight: { configurable: true, value: 180 }, - scrollHeight: { configurable: true, value: 640 }, - scrollTop: { configurable: true, value: 0, writable: true }, + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + { + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: expect.any(String), + userItem: { + id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], + }, + }, + ); }); - const waitingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(waitingProcessCard.parentElement).toBe(directMessageList); - expect( - within(waitingProcessCard).getByText('正在等待陶泥儿开始'), - ).not.toBeNull(); - expect( - within(waitingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在等待陶泥儿开始'); const firstDirectCall = invoke.mock.calls.find( ([command]) => command === 'chat_with_game_creator_direct_codex', ); @@ -7865,223 +7835,16 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(firstTurnId).not.toBe(''); - expect(firstDirectCall?.[1]).toEqual({ - projectPath, - prompt: '先完成正式客户端玩法拆解', - clientTurnId: firstTurnId, - userItem: { - id: `direct-codex:${firstTurnId}:user`, - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: '先完成正式客户端玩法拆解' }], - }, - }); - await act(async () => { - supervisorHarness.emitProgress('direct', '正在准备安全阶段'); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 0, - status: 'accepted', - activity: 'request-accepted', - updatedAt: 1000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'running', - activity: 'preparing', - updatedAt: 1200, - }, - }); - }); - const thinkingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(thinkingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(thinkingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在思考中'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 2, - status: 'running', - activity: 'file-read', - accumulatedText: `正在读取 game/index.html,${'这是一段非常长的执行内容。'.repeat(12)}用于验证执行详情展开状态不会因为后续事件更新而被重置。`, - updatedAt: 1500, - }, - }); - }); - const runningProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(runningProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ).not.toBeNull(); - fireEvent.click( - within(runningProcessCard).getByRole('button', { name: '展开' }), - ); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 3, - status: 'streaming', - activity: 'controlled-tool', - accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', - updatedAt: 2000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 1, - status: 'streaming', - activity: 'file-write', - accumulatedText: '乱序事件不能回退正文', - updatedAt: 1500, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: 'old-direct-turn', - sequence: 99, - status: 'streaming', - activity: 'validation', - accumulatedText: '旧回合不能覆盖正文', - updatedAt: 3000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath: '/tmp/wrong-direct-project', - turnId: firstTurnId, - sequence: 100, - status: 'streaming', - activity: 'response-finalization', - accumulatedText: '错误项目不能覆盖正文', - updatedAt: 4000, - }, - }); - supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); - }); - const streamingProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect(within(streamingProcessCard).getByText('回复生成中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect(directMessageList.scrollTop).toBe(640); - expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); - expect( - within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), - ).toBeNull(); - directMessageList.scrollTop = 100; - fireEvent.scroll(directMessageList); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 4, - status: 'running', - accumulatedText: `正在执行 npm test,${'工具输出很长时需要保持展开状态。'.repeat(10)}`, - updatedAt: 4500, - }, - }); - }); - expect(directMessageList.scrollTop).toBe(100); - expect(within(streamingProcessCard).getByText('任务执行中')).not.toBeNull(); - expect( - within(streamingProcessCard).getByText(/正在执行 npm test/u), - ).not.toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 5, - status: 'running', - activity: 'command-exec', - accumulatedText: `正在执行命令:npm run smoke,${'heartbeat 不得覆盖具体命令。'.repeat(12)}`, - updatedAt: 5000, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 6, - status: 'running', - activity: 'command-exec', - updatedAt: 5100, - }, - }); - }); - expect( - within(streamingProcessCard).getByText(/heartbeat 不得覆盖具体命令/u), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在执行命令')).toBeNull(); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 7, - status: 'running', - activity: 'controlled-tool', - accumulatedText: `正在写入文件:game/index.html,${'写入详情需要保持展开状态。'.repeat(12)}`, - updatedAt: 5200, - }, - }); - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: firstTurnId, - sequence: 8, - status: 'running', - activity: 'controlled-tool', - updatedAt: 5300, - }, - }); - }); - expect( - within(streamingProcessCard).getByText( - /正在写入文件:game\/index\.html/u, - ), - ).not.toBeNull(); - expect(within(streamingProcessCard).queryByText('正在调用工具')).toBeNull(); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('DIRECT_STREAM:先完成正式客户端玩法拆解'); - expect( - ( - within(streamingProcessCard).getByRole('button', { - name: '收起', - }) as HTMLButtonElement - ).getAttribute('aria-expanded'), - ).toBe('true'); await act(async () => { firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + supervisorHarness.completeDirectThreadTurn({ + turnId: firstTurnId, + prompt: '先完成正式客户端玩法拆解', + reply: 'DIRECT_REPLY:先完成正式客户端玩法拆解', + }); }); + // 用户条目由落盘事件回填:乐观气泡与事件条目同身份,只渲染一次。 await waitFor(() => { expect( within(supervisorSurface).getAllByText( @@ -8089,16 +7852,8 @@ export function registerProjectSupervisorSurfaceTests() { ), ).toHaveLength(1); expect( - within(supervisorSurface).queryByLabelText('陶泥儿正在执行的内容'), - ).toBeNull(); - expect( - within(supervisorSurface).queryByText( - 'DIRECT_STREAM:先完成正式客户端玩法拆解', - ), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); + within(supervisorSurface).getAllByText('先完成正式客户端玩法拆解'), + ).toHaveLength(1); }); await waitFor(() => { expect( @@ -8114,8 +7869,10 @@ export function registerProjectSupervisorSurfaceTests() { screen.getByLabelText('陶泥儿对话内容'), '补充:优先复用现有素材', ); - fireEvent.click( - within(supervisorSurface).getByRole('button', { name: '发送' }), + fireEvent.submit( + screen + .getByLabelText('陶泥儿对话内容') + .closest('form') as HTMLFormElement, ); await waitFor(() => { expect(invoke).toHaveBeenCalledWith( @@ -8141,68 +7898,12 @@ export function registerProjectSupervisorSurfaceTests() { ?.clientTurnId ?? '', ); expect(secondTurnId).not.toBe(firstTurnId); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 0, - status: 'streaming', - activity: 'not-a-public-activity', - accumulatedText: '即将失败的临时正文', - updatedAt: 5000, - }, - }); - }); - const failedTurnProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedTurnProcessCard).getByText('回复生成中'), - ).not.toBeNull(); - expect( - within(failedTurnProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在生成回复'); - expect( - within(supervisorSurface).getByLabelText('陶泥儿实时回复').textContent, - ).toBe('即将失败的临时正文'); - await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: secondTurnId, - sequence: 1, - status: 'failed', - activity: 'none', - accumulatedText: '失败事件不得保留这段正文', - updatedAt: 5100, - }, - }); - }); - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - const failedStatusProcessCard = within(directMessageList).getByTestId( - 'agent-tool-call-group', - ); - expect( - within(failedStatusProcessCard).getByText('处理失败'), - ).not.toBeNull(); - expect( - within(failedStatusProcessCard).getByLabelText('陶泥儿正在执行的内容') - .textContent, - ).toBe('正在记录失败原因'); + + // 失败回合:运行期说明只留在界面,不进历史;输入盒必须回到可用态。 await act(async () => { secondDirectReply.reject(new Error('模拟 direct 失败')); }); await waitFor(() => { - expect( - within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), - ).toBeNull(); - expect( - within(directMessageList).queryByTestId('agent-tool-call-group'), - ).toBeNull(); expect( ( within(supervisorSurface).getByRole('button', { @@ -8210,6 +7911,9 @@ export function registerProjectSupervisorSurfaceTests() { }) as HTMLButtonElement ).disabled, ).toBe(false); + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); }); expect( invoke.mock.calls.filter( @@ -8226,8 +7930,7 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); - // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 - // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 + // 首次进入项目时核验一次预览活体;开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', @@ -8245,7 +7948,7 @@ export function registerProjectSupervisorSurfaceTests() { ).toHaveLength(policyReadCountBeforeChat + 1); }); - it.skip('renders one collapsed tool-call group per turn from the direct turn event and keeps it after the turn completes', async () => { + it('renders one collapsed tool-call group per turn from thread events and keeps it after the turn completes', async () => { const projectPath = '/tmp/launcher-tool-call-card-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8254,28 +7957,23 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen( - eventName, - handler as Parameters[1], - ); + // 打开项目时已有上一轮对话:这一轮的块必须落在消息流末尾,而不是被锚到别人头上。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:user', + role: 'user', + text: '先做一个主菜单', + at: 900, }, - ); + { + itemType: 'message', + itemId: 'direct-codex:turn-existing:assistant', + role: 'assistant', + text: '上一轮已完成', + at: 1000, + }, + ]); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8297,35 +7995,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 打开项目时有一轮已落盘的对话:工具调用卡要挂在它的 assistant 消息之后。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个跑酷游戏', - agentId: null, - messageId: 'direct-codex:turn-existing:user', - updatedAt: 900, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮已完成', - agentId: null, - messageId: 'direct-codex:turn-existing:assistant', - updatedAt: 1000, - }, - ], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8334,7 +8003,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8370,31 +8039,33 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(clientTurnId).not.toBe(''); - // 实时增量:同一条 `item-command` 从 running 走到 completed,`item-file` 由下一条事件带出。 + // 运行中:命令开始执行。同一 itemId 的后续事件就地更新,不新起一张卡。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1000, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1000, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 995, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 一回合一个折叠块(默认折叠):块头是按钮,正文用 `hidden` 收起。 const runningGroups = within(supervisorSurface).getAllByTestId( @@ -8413,52 +8084,49 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningBody).not.toBeNull(); expect(runningBody?.hasAttribute('hidden')).toBe(true); - // 此刻只应看到本回合的那一条命令:不在对话里、也不属于当前回合的 turnId - // 会在 App 侧被过滤掉,不会漂在消息流里。 expect(runningHead.textContent).toContain('1 个命令'); + // 命令完成 + 文件变更:同一回合两块合成一个块,顺序按 startedAt。 await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 2, - status: 'running', - activity: 'file-write', - updatedAt: 1100, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build', output: 'build ok' }, - startedAt: 1000, - updatedAt: 1100, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'item-file', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'failed', - detail: { - changes: [ - { path: 'game/src/hero.ts', kind: 'update' }, - { path: 'game/src/hero.ts', kind: 'delete' }, - ], - }, - startedAt: 1050, - updatedAt: 1100, - }, - ], + supervisorHarness.emitDirectThreadEvents( + { + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'item-command', + command: 'npm run build', + output: 'build ok', + status: 'completed', + exitCode: 0, + at: 1100, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1050, + }, + }, + { + type: 'item.completed', + item: { + itemType: 'fileChange', + itemId: 'item-file', + changes: [ + { path: 'game/src/hero.ts', kind: 'update' }, + { path: 'game/src/hero.ts', kind: 'delete' }, + ], + at: 1100, + }, + }, + ); }); - // 同一回合的两条工具只产生一个块;同回合内按 startedAt 升序。 await waitFor(() => { expect( within(supervisorSurface).getAllByTestId('agent-tool-call-group'), @@ -8467,18 +8135,11 @@ export function registerProjectSupervisorSurfaceTests() { const group = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', )[0] as HTMLElement; - expect(group.getAttribute('data-status')).toBe('failed'); const groupHead = within(group).getByTestId('agent-tool-call-group-head'); expect(groupHead.textContent).toContain('已执行 1 个命令、1 个文件变更'); // 块头右侧是总用时(min(startedAt) → max(updatedAt)),并在 data-* 上暴露原始毫秒。 expect(group.getAttribute('data-duration-ms')).toBe('100'); - expect(groupHead.textContent).toContain('用时 1秒'); - // 同一回合的用户消息时间(App 提交时写入)→ 显示「发送 → 结束」。 - expect( - groupHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2}:\d{2} → \d{2}:\d{2}:\d{2}$/); - // 实时回合的块落在消息流末尾:该回合 assistant 消息还没落盘, - // 所以它在上一条 assistant 消息之后,而不是被锚到别人头上。 + // 实时回合的块落在消息流末尾:该回合还没有正文,不依赖任何锚点消息。 const liveChildren = Array.from((messageList as HTMLElement).children); expect( liveChildren.findIndex((node) => node === group), @@ -8489,29 +8150,23 @@ export function registerProjectSupervisorSurfaceTests() { await waitFor(() => { expect(groupHead.getAttribute('aria-expanded')).toBe('true'); }); - const groupBody = group.querySelector( - `#${groupHead.getAttribute('aria-controls')}`, - ); - expect(groupBody?.hasAttribute('hidden')).toBe(false); const rows = within(group).getAllByTestId('agent-tool-call-row'); expect(rows).toHaveLength(2); expect(rows.map((row) => row.getAttribute('data-kind'))).toEqual([ 'command', 'file_change', ]); - // 同一 id 只渲染一次,状态由 completed 覆盖;failed 行标「失败」。 - expect(rows[0]?.getAttribute('data-status')).toBe('completed'); - expect(rows[1]?.getAttribute('data-status')).toBe('failed'); const commandRow = rows[0] as HTMLElement; const fileRow = rows[1] as HTMLElement; - expect(within(commandRow).getByText('npm run build')).not.toBeNull(); - expect(within(fileRow).getByText('game/src/hero.ts')).not.toBeNull(); - expect(within(fileRow).getByText('失败')).not.toBeNull(); + expect( + within(commandRow).getAllByText('npm run build').length, + ).toBeGreaterThan(0); + expect( + within(fileRow).getAllByText('game/src/hero.ts').length, + ).toBeGreaterThan(0); // 每行右侧显示该工具自己的耗时(`startedAt` → `updatedAt`)。 expect(commandRow.getAttribute('data-duration-ms')).toBe('100'); - expect(within(commandRow).getByText('0.1s')).not.toBeNull(); expect(fileRow.getAttribute('data-duration-ms')).toBe('50'); - expect(within(fileRow).getByText('0.1s')).not.toBeNull(); // 行可二级展开:默认折叠,展开后看到命令 / 路径 + 变更类型 / 输出。 const commandRowHead = within(commandRow).getByRole('button'); @@ -8545,10 +8200,14 @@ export function registerProjectSupervisorSurfaceTests() { expect(within(fileDetail as HTMLElement).getByText('修改')).not.toBeNull(); expect(within(fileDetail as HTMLElement).getByText('删除')).not.toBeNull(); - // 回合结束:assistant 消息落盘后,块移到该回合 assistant 消息**之前**(工具在上、答复在下), - // 不重复、不消失。 + // 回合结束:正文进历史,工具块收进「执行过程」折叠区,不重复、不消失。 await act(async () => { directReply.resolve('DIRECT_REPLY:做一个跑酷游戏'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:做一个跑酷游戏', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8564,24 +8223,12 @@ export function registerProjectSupervisorSurfaceTests() { const settledGroups = within(supervisorSurface).getAllByTestId( 'agent-tool-call-group', ); - const children = Array.from((messageList as HTMLElement).children); - const assistantIndex = children.findIndex( - (node) => - node.classList.contains('message--assistant') && - node.textContent?.includes('DIRECT_REPLY:做一个跑酷游戏'), + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(settledGroups[0] ?? null)).toBe(true); + expect(processSection.textContent).not.toContain( + 'DIRECT_REPLY:做一个跑酷游戏', ); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - const settledGroupIndex = children.findIndex( - (node) => node === settledGroups[0], - ); - expect(settledGroupIndex).toBeGreaterThanOrEqual(0); - // 块在该回合的 user 消息与 assistant 消息之间:紧邻 assistant 消息之前。 - expect(settledGroupIndex).toBeLessThan(assistantIndex); - expect(settledGroupIndex).toBe(assistantIndex - 1); - expect( - children[settledGroupIndex - 1]?.classList.contains('message--user'), - ).toBe(true); - // 重新挂载后的块回到默认折叠;键盘可达:块头就是按钮(Tab 可达), // Enter/Space 触发的 click 同步 aria-expanded 与 hidden。 const settledGroup = settledGroups[0] as HTMLElement; @@ -8610,8 +8257,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(settledBody?.hasAttribute('hidden')).toBe(true); }); }); - - it.skip('reads the persisted tool-call history whenever a project is opened', async () => { + it('renders persisted tool cards from the history slice whenever a project is opened', async () => { const projectPath = '/tmp/launcher-tool-call-persisted-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8620,7 +8266,42 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let readToolCallCount = 0; + // 历史切片的条目已经是投影形状:工具的调用与输出共用同一个 itemId。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:user', + role: 'user', + text: '做一个小球弹跳游戏', + at: 1000, + }, + { + itemType: 'function_call', + itemId: 'persisted-command', + name: 'exec_command', + arguments: 'npm run build', + at: 1000, + }, + { + itemType: 'function_call_output', + itemId: 'persisted-command', + output: 'built in 500ms', + at: 1500, + }, + { + itemType: 'fileChange', + itemId: 'persisted-file', + changes: [{ path: 'game/src/hero.ts', kind: 'add' }], + at: 1500, + }, + { + itemType: 'message', + itemId: 'direct-codex:turn-persisted:assistant', + role: 'assistant', + text: '上一轮的答复', + at: 2000, + }, + ]); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_design_agent_runtime_mode') return null; @@ -8638,61 +8319,98 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'get_local_game_manifest') { return manifest; } - if (command === 'read_direct_project_conversation') { + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + // 打开项目时读一屏历史:这是「刷新后卡片仍在」的数据来源,工具卡片由前端投影。 + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + // 首屏的新端边界是订阅回执里的 `lastCompletedItemId`(含该条)。 + throughItemId: 'direct-codex:turn-persisted:assistant', + limit: 20, + }); + expect( + await within(supervisorSurface).findByText('做一个小球弹跳游戏'), + ).not.toBeNull(); + // 工具在「执行过程」折叠区里,最终回复在折叠区外:默认折叠、展开后行数 = 工具数。 + const persistedGroups = await within(supervisorSurface).findAllByTestId( + 'agent-tool-call-group', + ); + expect(persistedGroups).toHaveLength(1); + const persistedGroup = persistedGroups[0] as HTMLElement; + const persistedHead = within(persistedGroup).getByTestId( + 'agent-tool-call-group-head', + ); + expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); + expect(persistedHead.textContent).toContain( + '已执行 1 个命令、1 个文件变更', + ); + const processSection = + within(supervisorSurface).getByTestId('turn-process'); + expect(processSection.contains(persistedGroup)).toBe(true); + expect(processSection.textContent).not.toContain('上一轮的答复'); + fireEvent.click(persistedHead); + const persistedRows = within(persistedGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(persistedRows).toHaveLength(2); + // 行标题与展开后的命令详情都写着这条命令,这里只要求它确实出现在这一行里。 + expect( + within(persistedRows[0] as HTMLElement).getAllByText('npm run build'), + ).toHaveLength(2); + }); + + it('loads the earlier direct history page from the first-item anchor', async () => { + // 分页锚点走的是首屏切片给出的 `firstItemId`:切片本身从队尾取,老的一屏靠锚点继续向前。 + const projectPath = '/tmp/launcher-direct-history-pagination-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-pagination-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 26 条 > 首屏 20 条:首屏只显示最后 20 条,前面 6 条要靠"显示更早"翻页取回。 + supervisorHarness.setDirectThreadHistory( + Array.from({ length: 26 }, (_, index) => { + const label = `历史对话 ${String(index + 1).padStart(2, '0')}`; + return { + itemType: 'message', + itemId: `direct-codex:turn-${label}:user`, + role: 'user', + text: label, + at: 1000 + index, + }; + }), + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [ - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'user', - content: '做一个小球弹跳游戏', - agentId: null, - messageId: 'direct-codex:turn-persisted:user', - updatedAt: 1000, - }, - { - schemaVersion: 'agc-direct-project-context.v1', - role: 'assistant', - content: '上一轮的答复', - agentId: null, - messageId: 'direct-codex:turn-persisted:assistant', - updatedAt: 2000, - }, - ], + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-pagination-game', + recentRunStatus: null, + recentRunStopReason: null, }; } - if (command === 'read_direct_tool_calls') { - readToolCallCount += 1; - return [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-command', - turnId: 'turn-persisted', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'completed', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1500, - }, - { - schemaVersion: 'agc-tool-call.v1', - id: 'persisted-file', - turnId: 'turn-persisted', - kind: 'file_change', - title: '编辑 1 个文件', - summary: 'game/src/hero.ts', - status: 'completed', - detail: { - changes: [{ path: 'game/src/hero.ts', kind: 'add' }], - }, - startedAt: 1500, - updatedAt: 2000, - }, - ]; + if (command === 'get_local_game_manifest') { + return manifest; } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; @@ -8709,59 +8427,333 @@ export function registerProjectSupervisorSurfaceTests() { pickProjectFromLauncher(projectPath); const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); - // 打开项目时必须回读一次工具调用历史:这是「刷新后卡片仍在」的数据来源。 - await waitFor(() => { - expect(readToolCallCount).toBeGreaterThanOrEqual(1); - }); - expect(invoke).toHaveBeenCalledWith('read_direct_tool_calls', { - projectPath, - }); - // 回读到的工具调用挂在自己回合的 assistant 消息**之前**(工具在上、答复在下), - // 默认折叠、展开后行数 = 回读到的工具数。 - const persistedGroups = await within(supervisorSurface).findAllByTestId( - 'agent-tool-call-group', - ); - expect(persistedGroups).toHaveLength(1); - const persistedGroup = persistedGroups[0] as HTMLElement; - const persistedHead = within(persistedGroup).getByTestId( - 'agent-tool-call-group-head', - ); - expect(persistedHead.getAttribute('aria-expanded')).toBe('false'); - expect(persistedHead.textContent).toContain( - '已执行 1 个命令、1 个文件变更', - ); - // 回读回来的时间戳一样能算总用时与「发送 → 结束」。 - expect(persistedGroup.getAttribute('data-duration-ms')).toBe('1000'); - expect(persistedHead.textContent).toContain('用时 1秒'); expect( - persistedHead.querySelector('.agent-tool-call-group-time')?.textContent, - ).toMatch(/^\d{2}:\d{2} → \d{2}:\d{2}$/); - const messageList = supervisorSurface.querySelector( - '.project-supervisor-message-list', - ) as HTMLElement; - const children = Array.from(messageList.children); - const assistantIndex = children.findIndex((node) => - node.classList.contains('message--assistant'), - ); - const groupIndex = children.findIndex((node) => node === persistedGroup); - expect(assistantIndex).toBeGreaterThanOrEqual(0); - expect(groupIndex).toBe(assistantIndex - 1); - fireEvent.click(persistedHead); - const persistedRows = within(persistedGroup).getAllByTestId( - 'agent-tool-call-row', - ); - expect(persistedRows).toHaveLength(2); - // 每行右侧是该工具自己的耗时(0.5s / 0.5s)。 - expect(persistedRows[0]?.getAttribute('data-duration-ms')).toBe('500'); - expect( - within(persistedRows[0] as HTMLElement).getByText('0.5s'), + await within(supervisorSurface).findByText('历史对话 26'), ).not.toBeNull(); + // 首屏是尾部的 20 条:第 7 条起可见,更早的 6 条还没读进来。 + expect(within(supervisorSurface).getByText('历史对话 07')).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 06')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'direct-codex:turn-历史对话 26:user', + limit: 20, + }); + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 翻页按首屏最老一条的锚点向前取;取到文件头后按钮消失。 + expect( + await within(supervisorSurface).findByText('历史对话 01'), + ).not.toBeNull(); + expect(within(supervisorSurface).getByText('历史对话 06')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'direct-codex:turn-历史对话 07:user', + limit: 20, + }); }); - it('renders the running turn tool-call group in a fresh chat that has no anchored assistant message', async () => { + it('anchors the first history page at the subscribe receipt instead of the file tail', async () => { + // 首屏切片的新端边界只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执之后才 + // 完成的条目只能从运行态事件来,不能再被"取文件尾"带进历史。 + const projectPath = '/tmp/launcher-direct-history-anchor-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-anchor-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'turn-1-user', + role: 'user', + text: '历史对话 01', + at: 1000, + }, + { + itemType: 'message', + itemId: 'turn-2-user', + role: 'user', + text: '历史对话 02', + at: 1100, + }, + // 订阅回执之后才落盘的两条:只应从运行态事件来。 + { + itemType: 'function_call', + itemId: 'process-1', + name: 'exec_command', + arguments: 'npm run build', + at: 1200, + }, + { + itemType: 'message', + itemId: 'turn-3-user', + role: 'user', + text: '历史对话 03', + at: 1300, + }, + ]); + supervisorHarness.setDirectThreadLastCompletedItemId('turn-2-user'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-anchor-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'turn-2-user', + limit: 20, + }), + ); + // 首屏 = 锚点那一刻的尾部一屏:锚点之后的条目不在这一屏里。 + expect( + await within(supervisorSurface).findByText('历史对话 02'), + ).not.toBeNull(); + expect(within(supervisorSurface).queryByText('历史对话 03')).toBeNull(); + // 锚点只能来自订阅回执:回执必须先于首屏读取发生。 + const commands = invoke.mock.calls.map(([command]) => command); + expect( + commands.indexOf('subscribe_direct_project_thread'), + ).toBeGreaterThanOrEqual(0); + expect(commands.indexOf('subscribe_direct_project_thread')).toBeLessThan( + commands.indexOf('read_direct_project_history_slice'), + ); + }); + + it('keeps pulling earlier pages while the page only deepens the rendered turn', async () => { + // 用户报的现象:一屏 20 条全是同一个回合的工具卡片,落在折叠的「执行过程」里, + // 点一次「显示更早」看不到任何变化。连拉必须越过这一屏,直到出现新的用户气泡。 + const projectPath = '/tmp/launcher-direct-history-cross-page-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-direct-history-cross-page-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 51 条:用户气泡 + 50 条工具条目。首屏取尾 20 条(process-31..process-50), + // 第一次翻页再取 20 条(process-11..process-30),都还在同一个回合里。 + supervisorHarness.setDirectThreadHistory([ + { + itemType: 'message', + itemId: 'direct-codex:turn-更早:user', + role: 'user', + text: '更早的用户提问', + at: 1000, + }, + ...Array.from({ length: 50 }, (_, index) => ({ + itemType: 'function_call', + itemId: `process-${index + 1}`, + name: 'exec_command', + arguments: `echo ${index + 1}`, + at: 1100 + index, + })), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-direct-history-cross-page-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + throughItemId: 'process-50', + limit: 20, + }), + ); + // 首屏只有这个回合的执行过程:用户气泡还在更早的页里。 + expect(within(supervisorSurface).queryByText('更早的用户提问')).toBeNull(); + + // 视口停在列表中部(既不在顶部触发自动翻页,也不在底部跟随最新)。 + const messageList = screen.getByLabelText('陶泥儿消息') as HTMLDivElement; + let messageScrollHeight = 600; + Object.defineProperty(messageList, 'scrollHeight', { + configurable: true, + get: () => messageScrollHeight, + }); + Object.defineProperty(messageList, 'clientHeight', { + configurable: true, + get: () => 100, + }); + messageList.scrollTop = 100; + fireEvent.scroll(messageList); + // 插入后列表会变长:跟随最新的话视口会被拉到这个高度。 + messageScrollHeight = 2000; + + fireEvent.click(screen.getByRole('button', { name: '显示更早的对话' })); + + // 一次点击连拉两页:第一页仍然只有执行过程,第二页才带回用户气泡。 + expect( + await within(supervisorSurface).findByText('更早的用户提问'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-31', + limit: 20, + }); + expect(invoke).toHaveBeenCalledWith('read_direct_project_history_slice', { + projectPath, + beforeItemId: 'process-11', + limit: 20, + }); + // 取到文件头后按钮收起。 + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + // 连拉插入的旧内容不得把视口弹到列表底部。 + expect(messageList.scrollTop).toBe(100); + }); + + it('drains a notify that lands before the subscribe receipt so the running turn is not stranded', async () => { + // 真实竞态:`subscribe` 在 Rust 侧已经注册好 subscriber 并开始通知,但前端还没拿到 + // subscriptionId。此时的通知不能白丢——拿到回执后必须补一次 consume,否则整个回合会卡在队列里。 + const projectPath = '/tmp/launcher-notify-before-subscribe-game'; + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'launcher-notify-before-subscribe-game', + ); + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath, + }); + // 订阅回执被挂住:Rust 侧已经建好订阅(因此事件会带通知),前端还在等回执。 + const subscribeReceipt = createDeferred(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'get_design_agent_runtime_mode') return null; + if (command === 'inspect_local_project_directory') { + return { + projectPath, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'launcher-notify-before-subscribe-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'subscribe_direct_project_thread') { + const result = await supervisorHarness.invoke(command, args); + await subscribeReceipt.promise; + return result; + } + return supervisorHarness.invoke(command, args); + }, + ); + window.__TAURI__ = { + core: { invoke }, + event: { listen: supervisorHarness.listen }, + }; + renderLauncherProjectsAt('/?launcher'); + + pickProjectFromLauncher(projectPath); + + const supervisorSurface = await screen.findByLabelText('陶泥儿项目对话'); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('subscribe_direct_project_thread', { + projectPath, + }); + }); + + // 回执还没回到前端,整轮事件已经到了:通知此刻拿不到 subscriptionId,只能记账。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: 'direct-codex:notify-window:assistant', + role: 'assistant', + text: '回执前就到达的回复', + at: 900, + }, + }, + { type: 'turn.completed', status: 'completed' }, + ); + }); + expect( + within(supervisorSurface).queryByText('回执前就到达的回复'), + ).toBeNull(); + + // 回执到达:前端补一次 consume,把队列里的事件取回来。 + await act(async () => { + subscribeReceipt.resolve(); + }); + expect( + await within(supervisorSurface).findByText('回执前就到达的回复'), + ).not.toBeNull(); + // 补取之后不再有残留的忙碌态。 + expect( + within(supervisorSurface).queryByRole('button', { name: '终止' }), + ).toBeNull(); + }); + + it('renders the running turn tool-call group in a fresh chat from the thread subscription', async () => { // 空对话首轮:历史为空,消息列表里只有 App 落的默认问候(`描述你的想法,或 @ 引用素材`,没有 messageId)。 - // 这种回合没有任何「可锚」的 assistant 消息,块必须兜底落在消息列表末尾, - // 而不是等到回合结束、assistant 消息带上 messageId 之后才出现。 + // 运行中的回合没有任何落盘正文,工具块必须靠订阅事件直接出现在消息列表末尾, + // 而不是等到回合结束、历史切片回来之后才出现。 const projectPath = '/tmp/launcher-tool-call-fresh-turn-game'; const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -8770,25 +8762,6 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); - let directTurnUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; - const listen = vi.fn( - async ( - eventName: string, - handler: (event: { payload: Record }) => void, - ) => { - if (eventName === 'game-creator-direct-turn-update') { - directTurnUpdateHandler = handler; - return () => { - if (directTurnUpdateHandler === handler) { - directTurnUpdateHandler = null; - } - }; - } - return supervisorHarness.listen(eventName, handler); - }, - ); const directReply = createDeferred(); const invoke = vi.fn( async (command: string, args?: Record) => { @@ -8810,18 +8783,6 @@ export function registerProjectSupervisorSurfaceTests() { if (command === 'chat_with_game_creator_direct_codex') { return directReply.promise; } - if (command === 'read_direct_tool_calls') { - return []; - } - if (command === 'read_direct_project_conversation') { - // 真正的空对话:没有任何历史消息,App 会落一条没有 messageId 的默认问候。 - return { - path: `${projectPath}/.agent/conversations/project.jsonl`, - agentId: null, - sessionId: null, - messages: [], - }; - } if (command === 'get_local_game_preview_status') { return { status: 'stopped', url: null, port: null, root: null }; } @@ -8830,7 +8791,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen }, + event: { listen: supervisorHarness.listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -8870,29 +8831,32 @@ export function registerProjectSupervisorSurfaceTests() { expect(clientTurnId).not.toBe(''); await act(async () => { - directTurnUpdateHandler?.({ - payload: { - projectPath, - turnId: clientTurnId, - sequence: 1, - status: 'running', - activity: 'command-exec', - updatedAt: 1400, - toolCalls: [ - { - schemaVersion: 'agc-tool-call.v1', - id: 'fresh-turn-command', - kind: 'command', - title: '执行命令', - summary: 'npm run build', - status: 'running', - detail: { command: 'npm run build' }, - startedAt: 1000, - updatedAt: 1400, - }, - ], + // 订阅事件就是运行态:生命周期 + 用户条目 + 命令开始执行。 + supervisorHarness.emitDirectThreadEvents( + { type: 'turn.started' }, + { + type: 'item.completed', + item: { + itemType: 'message', + itemId: `direct-codex:${clientTurnId}:user`, + role: 'user', + text: '做一个跑酷游戏', + at: 900, + }, }, - }); + { + type: 'item.started', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: null, + status: 'inProgress', + exitCode: null, + at: 1000, + }, + }, + ); }); // 回合进行中:块已经可见,且落在消息列表末尾(默认问候之后),不依赖任何带 messageId 的 assistant 消息。 @@ -8902,7 +8866,6 @@ export function registerProjectSupervisorSurfaceTests() { expect(runningGroups).toHaveLength(1); const runningGroup = runningGroups[0] as HTMLElement; expect(runningGroup.getAttribute('data-status')).toBe('running'); - expect(runningGroup.getAttribute('data-duration-ms')).toBe('400'); const runningHead = within(runningGroup).getByTestId( 'agent-tool-call-group-head', ); @@ -8918,14 +8881,51 @@ export function registerProjectSupervisorSurfaceTests() { ); expect(runningRows).toHaveLength(1); expect(runningRows[0]?.getAttribute('data-kind')).toBe('command'); - expect(runningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); + + // 同一条命令完成:同一个 itemId 就地更新,不新起一张卡,耗时按 startedAt → updatedAt 计算。 + await act(async () => { + supervisorHarness.emitDirectThreadEvents({ + type: 'item.completed', + item: { + itemType: 'commandExecution', + itemId: 'fresh-turn-command', + command: 'npm run build', + output: 'built in 400ms', + status: 'completed', + exitCode: 0, + at: 1400, + }, + }); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByTestId('agent-tool-call-group'), + ).toHaveLength(1); + }); + const settledRunningGroup = within(supervisorSurface).getAllByTestId( + 'agent-tool-call-group', + )[0] as HTMLElement; + expect(settledRunningGroup.getAttribute('data-duration-ms')).toBe('400'); + fireEvent.click( + within(settledRunningGroup).getByTestId('agent-tool-call-group-head'), + ); + const settledRunningRows = within(settledRunningGroup).getAllByTestId( + 'agent-tool-call-row', + ); + expect(settledRunningRows).toHaveLength(1); + expect(settledRunningRows[0]?.getAttribute('data-duration-ms')).toBe('400'); expect( - within(runningRows[0] as HTMLElement).getByText('0.4s'), + within(settledRunningRows[0] as HTMLElement).getByText('0.4s'), ).not.toBeNull(); - // 回合结束:assistant 消息落盘后块回到它之前,且**只有一份**(末尾兜底不留下重复块)。 + // 回合结束:assistant 正文进历史,工具块收进「执行过程」折叠区,且**只有一份**。 await act(async () => { directReply.resolve('DIRECT_REPLY:空对话首轮'); + supervisorHarness.completeDirectThreadTurn({ + turnId: clientTurnId, + reply: 'DIRECT_REPLY:空对话首轮', + at: 1500, + }); }); await waitFor(() => { expect( @@ -8956,9 +8956,8 @@ export function registerProjectSupervisorSurfaceTests() { chatProjectAssets: [], composerRef: createRef(), directCodex: true, - directStatus: null, - directProcessDetail: '', - directProcessKey: '', + directEntries: [], + directTurnRunning: false, hiddenConversationCount: 0, messagesRef: createRef(), needsUserInput: false, @@ -9152,7 +9151,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/directHistoryAnchorGate.test.ts b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts new file mode 100644 index 000000000..524af7152 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; + +import { + directHistoryAnchorGateToWaitFor, + openDirectHistoryAnchorGate, + reuseOrOpenDirectHistoryAnchorGate, +} from '../src/features/project-workspace/directHistoryAnchorGate'; + +const projectA = '/tmp/项目A'; +const projectB = '/tmp/项目B'; + +/** 订阅侧的回执:一次订阅只 settle 一次。 */ +function subscribeReceipt(gate: { settle: (anchor: string | null) => void }) { + gate.settle('turn-2-user'); +} + +describe('openDirectHistoryAnchorGate', () => { + it('默认不解析:回执到达前首屏必须等它', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + const settled = { value: 'pending' as string | null | 'pending' }; + void gate.anchor.then((anchor) => { + settled.value = anchor; + }); + + // 让微任务跑一轮:没有回执就不该有结果。 + await Promise.resolve(); + await Promise.resolve(); + expect(settled.value).toBe('pending'); + expect(gate.projectPath).toBe(projectA); + expect(gate.consumed).toBe(false); + }); + + it('settle 之后 anchor 解析成回执里的 lastCompletedItemId', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(gate); + + await expect(gate.anchor).resolves.toBe('turn-2-user'); + }); + + it('订阅不可用时 settle(null):首屏退化成取文件尾', async () => { + const gate = openDirectHistoryAnchorGate(projectA); + gate.settle(null); + + await expect(gate.anchor).resolves.toBeNull(); + }); +}); + +describe('reuseOrOpenDirectHistoryAnchorGate', () => { + it('同一个项目复用同一道闸门:订阅侧要 settle 首屏已经开好的那道', () => { + const opened = openDirectHistoryAnchorGate(projectA); + + expect(reuseOrOpenDirectHistoryAnchorGate(opened, projectA)).toBe(opened); + }); + + it('换项目开一道新闸门,不复用旧项目的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + const next = reuseOrOpenDirectHistoryAnchorGate(opened, projectB); + + expect(next).not.toBe(opened); + expect(next.projectPath).toBe(projectB); + expect(next.consumed).toBe(false); + }); + + it('还没有闸门时新开一道', () => { + expect(reuseOrOpenDirectHistoryAnchorGate(null, projectA).projectPath).toBe( + projectA, + ); + }); +}); + +describe('directHistoryAnchorGateToWaitFor', () => { + it('没有闸门(首屏比订阅 effect 先跑)时开一道给调用方登记', async () => { + const gate = directHistoryAnchorGateToWaitFor(null, projectA); + + expect(gate).not.toBeNull(); + expect(gate?.projectPath).toBe(projectA); + subscribeReceipt(gate!); + await expect(gate!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目未消费的闸门直接复用:首屏等到订阅回执', async () => { + const opened = openDirectHistoryAnchorGate(projectA); + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectA); + + expect(toWaitFor).toBe(opened); + subscribeReceipt(opened); + await expect(toWaitFor!.anchor).resolves.toBe('turn-2-user'); + }); + + it('同项目已消费返回 null:重开项目没有新回执可等,退回取文件尾', () => { + const opened = openDirectHistoryAnchorGate(projectA); + opened.consumed = true; + + expect(directHistoryAnchorGateToWaitFor(opened, projectA)).toBeNull(); + }); + + it('换项目不复用旧闸门:新项目的首屏要等新订阅的回执', () => { + const opened = openDirectHistoryAnchorGate(projectA); + subscribeReceipt(opened); + opened.consumed = true; + + const toWaitFor = directHistoryAnchorGateToWaitFor(opened, projectB); + + expect(toWaitFor).not.toBeNull(); + expect(toWaitFor).not.toBe(opened); + expect(toWaitFor?.projectPath).toBe(projectB); + }); +}); 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 8c0f00a19..000000000 --- a/apps/ai-game-creator-shell/tests/directHistoryPagination.test.tsx +++ /dev/null @@ -1,269 +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('加载旧页后重读历史回到最新页,再翻页仍按原顺序且不重复', async () => { - const invoke = install((args) => - args.beforeItemId - ? page(messages.slice(0, 6), false) - : page(messages.slice(6), true), - ); - mount(); - await screen.findByText('历史正文 25'); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - await setComposerText(screen.getByLabelText('陶泥儿对话内容'), '/history'); - fireEvent.click(screen.getByRole('button', { name: '发送' })); - await act(async () => { - await Promise.resolve(); - }); - expect(screen.queryByText('历史正文 0')).toBeNull(); - expect(screen.getByText('历史正文 25')).not.toBeNull(); - fireEvent.click(earlier()); - await screen.findByText('历史正文 0'); - for (let index = 0; index < messages.length; index += 1) { - expect(screen.getAllByText(`历史正文 ${index}`)).toHaveLength(1); - if (index > 0) { - expect( - screen - .getByText(`历史正文 ${index - 1}`) - .compareDocumentPosition(screen.getByText(`历史正文 ${index}`)) & - Node.DOCUMENT_POSITION_FOLLOWING, - ).not.toBe(0); - } - } - expect( - invoke.mock.calls.filter( - ([command]) => command === 'read_direct_project_history_slice', - ), - ).toHaveLength(4); - }); - - 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/directHistoryPaging.test.ts b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts new file mode 100644 index 000000000..6d127375d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { DIRECT_HISTORY_MAX_PAGES_PER_ACTION } from '../src/app/constants'; +import { readDirectHistoryPages } from '../src/features/project-workspace/directHistoryPaging'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { + DirectThreadHistorySlice, + DirectThreadItem, +} from '../src/features/project-workspace/directThreadEvents'; + +function userItem(itemId: string): DirectThreadItem { + return { itemType: 'message', itemId, role: 'user', text: itemId, at: 1000 }; +} + +function assistantItem(itemId: string): DirectThreadItem { + return { + itemType: 'message', + itemId, + role: 'assistant', + text: `答复 ${itemId}`, + at: 2000, + }; +} + +function toolItem(itemId: string): DirectThreadItem { + return { + itemType: 'function_call', + itemId, + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1500, + }; +} + +function reasoningItem(itemId: string): DirectThreadItem { + return { itemType: 'reasoning', itemId, text: '想想', at: 1400 }; +} + +function slice( + items: DirectThreadItem[], + hasMore: boolean, + firstItemId: string | null, +): DirectThreadHistorySlice { + return { items, hasMore, firstItemId }; +} + +/** 用生产投影把条目拉成聊天条目:判据必须和视图看到的是同一份。 */ +function entriesOf(items: DirectThreadItem[]): DirectChatEntry[] { + return selectDirectChatEntries( + mergeDirectHistoryItems(emptyDirectThreadChatState(), items), + ); +} + +function readerOf(pages: DirectThreadHistorySlice[]) { + const remaining = [...pages]; + return vi.fn(async (_beforeItemId: string | null) => { + const next = remaining.shift(); + if (!next) throw new Error('测试桩:不该再取页'); + return next; + }); +} + +describe('DirectProject 历史分页连拉', () => { + it('整页工具与思考条目落进已渲染回合时继续取页,直到出现新的用户气泡', async () => { + // 已渲染的回合是「turn-2-user」,后面跟着它的折叠执行过程。 + const existing = entriesOf([userItem('turn-2-user'), toolItem('call-2')]); + const readSlice = readerOf([ + slice([toolItem('call-3'), reasoningItem('reason-3')], true, 'reason-3'), + slice( + [userItem('turn-1-user'), assistantItem('turn-1-assistant')], + false, + 'turn-1-user', + ), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + // 第二页才带来新回合:锚点按上一页最老一条推进,条目按文件顺序拼回去。 + expect(readSlice.mock.calls).toEqual([['turn-2-user'], ['reason-3']]); + expect(result.items.map((item) => item.itemId)).toEqual([ + 'turn-1-user', + 'turn-1-assistant', + 'call-3', + 'reason-3', + ]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-1-user'); + expect(result.error).toBeNull(); + const merged = selectDirectChatEntries( + mergeDirectHistoryItems( + { turnRunning: false, history: existing, live: [] }, + result.items, + ), + ); + expect(merged.some((entry) => entry.itemId === 'turn-1-user')).toBe(true); + }); + + it('第一页就出现新的用户气泡时只取一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([userItem('turn-1-user')], true, 'turn-1-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + // 后端说还有更早的:按钮继续留在界面上,用户可以接着往前翻。 + expect(result.hasMore).toBe(true); + expect(result.firstItemId).toBe('turn-1-user'); + }); + + it('首屏没有已渲染回合时,第一页本身就构成可见反馈', async () => { + const readSlice = readerOf([slice([toolItem('call-1')], true, 'call-1')]); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice.mock.calls).toEqual([[null]]); + expect(result.items.map((item) => item.itemId)).toEqual(['call-1']); + }); + + it('hasMore=false 时即使没有新回合也立即停止', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([toolItem('call-3')], false, 'call-3')]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('一直没有新回合时最多连拉上限页数就停手', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf( + Array.from( + { length: DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2 }, + (_, index) => + slice([toolItem(`call-${index + 3}`)], true, `call-${index + 3}`), + ), + ); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes( + DIRECT_HISTORY_MAX_PAGES_PER_ACTION, + ); + // 用满上限但文件里还有更早的历史:按钮留在界面上,用户可再点一次。 + expect(result.hasMore).toBe(true); + expect(result.items).toHaveLength(DIRECT_HISTORY_MAX_PAGES_PER_ACTION); + expect(result.firstItemId).toBe( + `call-${DIRECT_HISTORY_MAX_PAGES_PER_ACTION + 2}`, + ); + }); + + it('锚点不前进时立即停止,不空转也不留按钮', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([slice([], true, null)]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.firstItemId).toBe('turn-2-user'); + }); + + it('锚点原地打转时立即停止,不重复取同一页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const readSlice = readerOf([ + slice([toolItem('call-3')], true, 'turn-2-user'), + ]); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.hasMore).toBe(false); + }); + + it('某一页读取失败时保留已经取到的页', async () => { + const existing = entriesOf([userItem('turn-2-user')]); + const failure = new Error('读取失败'); + const readSlice = vi.fn(async (beforeItemId: string | null) => { + if (beforeItemId === 'turn-2-user') { + return slice([toolItem('call-3')], true, 'call-3'); + } + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: existing, + beforeItemId: 'turn-2-user', + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(2); + expect(result.items.map((item) => item.itemId)).toEqual(['call-3']); + // 失败的页没有推进锚点:下一次点击会重取这一页。 + expect(result.firstItemId).toBe('call-3'); + expect(result.hasMore).toBe(true); + expect(result.error).toBe(failure); + }); + + it('首页读取失败时保留首屏锚点并返回错误', async () => { + const failure = new Error('首页读取失败'); + const readSlice = vi.fn(async (_beforeItemId: string | null) => { + throw failure; + }); + + const result = await readDirectHistoryPages({ + existingEntries: [], + beforeItemId: null, + readSlice, + }); + + expect(readSlice).toHaveBeenCalledTimes(1); + expect(result.items).toEqual([]); + expect(result.firstItemId).toBeNull(); + expect(result.hasMore).toBe(false); + expect(result.error).toBe(failure); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..d9bb2b82f --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from 'vitest'; + +import { + emptyDirectThreadChatState, + mergeDirectHistoryItems, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; + +function event( + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; +} + +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { + return { + itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { + return { + itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + output: 'assets\ngame', + at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + ]); + expect(started.turnRunning).toBe(true); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(completed.turnRunning).toBe(false); + }); + + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); + // 订阅身份与首屏锚点由订阅循环自己持有,不写进聊天 reducer 状态。 + expect(bootstrapped).not.toHaveProperty('subscriptionId'); + expect(bootstrapped).not.toHaveProperty('lastCompletedItemId'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); + }); + + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '你', + }), + event({ + type: 'item.delta', + itemId: 'msg-1', + kind: 'message', + delta: '好', + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + item: messageItem({ text: '你好,我是陶泥儿。' }), + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + itemId: 'reason-1', + kind: 'reasoning', + delta: '先看目录', + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); + }); + + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + item: messageItem({ + itemId: 'sys-1', + role: 'system', + text: '内部指令', + }), + }), + event({ + type: 'item.completed', + item: { + itemType: 'other', + itemId: 'plan-1', + rawType: 'plan', + at: 0, + } satisfies DirectThreadItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ type: 'item.completed', item: toolOutput() }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts deleted file mode 100644 index ff7849dfc..000000000 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -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', - 'failed', - 'interrupted', - null, - undefined, - ]) { - expect(isDirectTurnInProgress(status)).toBe(false); - } - for (const status of ['accepted', 'running', 'streaming', 'finalizing']) { - expect(isDirectTurnInProgress(status)).toBe(true); - } - }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { - const items = [ - { - type: 'message', - role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], - }, - ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); - expect(items[0]).not.toHaveProperty('recordedAt'); - }); -}); diff --git a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts index f47f263a2..809041ff9 100644 --- a/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts +++ b/apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts @@ -1,236 +1,241 @@ import { describe, expect, it } from 'vitest'; -import type { - ChatMessage, - GameCreatorDirectToolCall, - TurnStreamItem, -} from '../src/app/types'; +import type { ChatMessage } from '../src/app/types'; +import type { DirectChatEntry } from '../src/features/project-workspace/directThreadChat'; import { - buildDirectTurnPresentations, + buildDirectChatTurns, + directChatEntriesToMessages, directMessageTimestamp, + mergeDirectChatMessages, normalizeDirectTimestamp, - splitDirectTurnContent, } from '../src/features/project-workspace/directTurnPresentation'; -const user = (turn: string): ChatMessage => ({ +const userEntry = ( + itemId: string, + at = 1_800_000_000_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'user', - text: '只读检查', - messageId: `direct-codex:${turn}:user`, + text: `问题 ${itemId}`, + at, }); -const assistant = (id: string, text = '完整回复'): ChatMessage => ({ + +const assistantEntry = ( + itemId: string, + text: string, + at = 1_800_000_001_000, +): DirectChatEntry => ({ + itemId, + kind: 'message', role: 'assistant', text, - messageId: id, + at, }); -const text = (turnId: string, id: string, seq = 1): TurnStreamItem => ({ - schemaVersion: 'agc-turn-stream.v1', - kind: 'text', - turnId, - id: `text:${turnId}:${id}`, - text: '前缀', - seq, - at: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const tool = (turnId: string): GameCreatorDirectToolCall => ({ - schemaVersion: 'agc-tool-call.v1', - id: 'call', - turnId, - kind: 'mcp_tool', - title: '调用工具', - summary: '读取', - status: 'completed', - detail: { command: '{"path":"file"}', output: '内容', changes: [] }, - startedAt: 1_800_000_000_000, - updatedAt: 1_800_000_000_001, -}); -const build = ( - messages: ChatMessage[], - items: TurnStreamItem[], - options: Partial[0]> = {}, -) => - buildDirectTurnPresentations({ - messages, - visibleMessages: messages, - items, - calls: [], - transientReply: '', - ...options, - }); -describe('DirectProject 回合唯一呈现', () => { +const toolEntry = ( + itemId: string, + at = 1_800_000_000_500, +): DirectChatEntry => ({ + itemId, + kind: 'tool', + role: null, + text: null, + at, + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: itemId, + kind: 'command', + title: '执行命令', + summary: 'npm run build', + status: 'completed', + detail: { command: 'npm run build' }, + startedAt: at, + updatedAt: at, + }, +}); + +const reasoningEntry = ( + itemId: string, + text = '先看目录', + at = 1_800_000_000_200, +): DirectChatEntry => ({ + itemId, + kind: 'reasoning', + role: null, + text, + at, +}); + +const localUser = (text: string, messageId?: string): ChatMessage => ({ + role: 'user', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_000, +}); + +const localNotice = (text: string, messageId?: string): ChatMessage => ({ + role: 'assistant', + text, + ...(messageId ? { messageId } : {}), + updatedAt: 1_800_000_002_500, +}); + +describe('DirectProject 聊天分区', () => { it('把 Unix 秒时间戳归一化为毫秒,已有毫秒值保持不变', () => { expect(normalizeDirectTimestamp(1_800_000_000)).toBe(1_800_000_000_000); expect(normalizeDirectTimestamp(1_800_000_000_000)).toBe(1_800_000_000_000); - expect(directMessageTimestamp(1_800_000_000)).toBe(1_800_000_000_000); - }); - - it('历史仍有未加载切片时,不把那些回合的工具流追加到当前页末尾', () => { - const rows = build( - [user('one')], - [text('old', 'raw-old'), text('one', 'raw')], - { - hasUnloadedHistory: true, - }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - const active = build([], [text('live', 'raw')], { - hasUnloadedHistory: true, - activeTurnId: 'live', - }); - expect(active.map((row) => row.turnId)).toEqual(['live']); - }); - it('尚未落盘用户的实时回合也只产生一个 owner,不另建 live 与 unmapped 出口', () => { - const rows = build([], [text('one', 'raw')], { - activeTurnId: 'one', - transientReply: '同一份累计回复', - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].transientReply).toBe(''); - expect(rows[0].calls).toHaveLength(1); - }); - it('同一身份用户重复快照不增加回合,不丢用户正文', () => { - const rows = build([user('one'), user('one')], [text('one', 'raw')]); - expect(rows).toHaveLength(1); - expect(rows[0].messages).toHaveLength(1); - expect(rows[0].messages[0].role).toBe('user'); - }); - it('用原始 item 身份补齐旧前缀,最终合成消息不另占正文出口', () => { - const rows = build( - [user('one'), assistant('raw'), assistant('direct-codex:one:assistant')], - [text('one', 'raw')], - ); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('stream'); - expect(rows[0].items).toHaveLength(1); - expect(rows[0].items[0].text).toBe('完整回复'); - }); - it('先关联完整历史再分页,首条可见 assistant 不会失去用户归属', () => { - const messages = [ - user('old'), - assistant('old-raw'), - user('one'), - assistant('raw'), - ]; - const rows = build(messages, [text('old', 'old-raw'), text('one', 'raw')], { - visibleMessages: messages.slice(3), - }); - expect(rows.map((row) => row.turnId)).toEqual(['one']); - expect(rows[0].messages[0].messageId).toBe('direct-codex:one:user'); - }); - it('纯文本旧回合不挤占之后有工具的回合身份', () => { - const rows = build( - [user('old'), assistant('plain'), user('one'), assistant('raw')], - [text('one', 'raw')], - { calls: [tool('one')] }, - ); - expect(rows.map((row) => row.turnId)).toEqual(['old', 'one']); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls).toEqual([]); - expect(rows[1].source).toBe('stream'); - }); - it('没有流的原始 assistant 与工具仍归属一个回合,输入输出保留', () => { - const rows = build([user('one'), assistant('raw')], [], { - calls: [tool('one')], - }); - expect(rows).toHaveLength(1); - expect(rows[0].source).toBe('messages'); - expect(rows[0].calls[0].detail.output).toBe('内容'); - }); - it('无法证明流覆盖历史时整轮回退,不能混画部分流与全文', () => { - const rows = build( - [user('one'), assistant('raw-a'), assistant('raw-b')], - [text('one', 'raw-b')], - ); - expect(rows[0].source).toBe('messages'); - expect( - rows[0].messages.filter((message) => message.role === 'assistant'), - ).toHaveLength(2); - }); - it('不同回合的相同文字不是重复消息,不做文本去重', () => { - const rows = build( - [user('one'), assistant('raw-one'), user('two'), assistant('raw-two')], - [text('one', 'raw-one'), text('two', 'raw-two')], - ); - expect(rows).toHaveLength(2); - expect(rows.every((row) => row.source === 'stream')).toBe(true); - }); - it('乱序重复快照按 seq 排列且不增加 item', () => { - const first = text('one', 'a'); - const last = text('one', 'b', 3); - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const rows = build([user('one')], [last, marker, first, first]); - expect(rows[0].items.map((item) => item.seq)).toEqual([1, 2, 3]); - }); - it('完成后中间文本和工具进入过程,最终回复独立且分区不重复', () => { - const marker: TurnStreamItem = { - ...text('one', 'unused', 2), - kind: 'tool', - id: 'tool:one:call', - callId: 'call', - }; - const row = build( - [user('one')], - [text('one', 'start'), marker, text('one', 'final', 3)], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:start', - 'tool:one:call', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual(['text:one:final']); - const active = splitDirectTurnContent({ ...row, active: true }); - expect(active.processItems).toHaveLength(3); - expect(active.finalItems).toEqual([]); - }); - it('失败回合保留失败提示,不把末尾过程文本提升为最终回复', () => { - const row = build( - [user('one'), assistant('direct-codex:one:failure', '失败')], - [text('one', 'progress'), { ...text('one', 'failure', 2), text: '失败' }], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processItems.map((item) => item.id)).toEqual([ - 'text:one:progress', - ]); - expect(parts.finalItems.map((item) => item.id)).toEqual([ - 'text:one:failure', - ]); - }); - it('无流历史只保留最后一条回复,发送时间不从工具推断', () => { - const row = build( - [user('one'), assistant('progress'), assistant('final')], - [], - )[0]; - const parts = splitDirectTurnContent(row); - expect(parts.processMessages.map((message) => message.messageId)).toEqual([ - 'progress', - ]); - expect(parts.finalMessages.map((message) => message.messageId)).toEqual([ - 'final', - ]); expect(directMessageTimestamp(undefined)).toBe(0); expect(directMessageTimestamp(Number.MAX_VALUE)).toBe(0); expect(directMessageTimestamp(1_800_000_000_001)).toBe(1_800_000_000_001); }); - it('无流活动回合的累计文本只属于该回合,持久 assistant 到达即接管', () => { + + it('每个用户条目开一个新回合,顺序就是条目顺序', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a1', '第一轮答复'), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '第二轮答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'u2']); + expect(turns[0]?.users[0]).toMatchObject({ text: '问题 u1' }); + expect(turns[0]?.finals[0]).toMatchObject({ text: '第一轮答复' }); + expect(turns[1]?.finals[0]).toMatchObject({ text: '第二轮答复' }); + expect(turns[0]?.startedAt).toBe(1_800_000_000_000); + expect(turns[0]?.endedAt).toBe(1_800_000_001_000); + }); + + it('已结束的回合只把最后一条助手正文当最终回复,中间正文与工具进过程', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + reasoningEntry('r1'), + assistantEntry('a-mid', '我先看一下项目'), + toolEntry('t1'), + assistantEntry('a-final', '改好了', 1_800_000_002_000), + ], + }); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u1:r1', + 'u1:a-mid', + 'u1:t1', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u1:a-final']); + }); + + it('连续工具合成一块,夹了正文就另起一块', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + toolEntry('t1'), + toolEntry('t2', 1_800_000_000_600), + assistantEntry('a-mid', '继续', 1_800_000_000_700), + toolEntry('t3', 1_800_000_000_800), + ], + turnRunning: true, + }); + const process = turns[0]?.process ?? []; + expect(process.map((block) => block.kind)).toEqual([ + 'tools', + 'assistant', + 'tools', + ]); + expect(process[0]).toMatchObject({ + kind: 'tools', + calls: [{ id: 't1' }, { id: 't2' }], + }); + expect(process[2]).toMatchObject({ kind: 'tools', calls: [{ id: 't3' }] }); + }); + + it('运行中的回合:过程不折叠,最后的助手正文仍在过程里流式显示', () => { + const turns = buildDirectChatTurns({ + entries: [ + userEntry('u1'), + assistantEntry('a-live', '正在写'), + toolEntry('t1', 1_800_000_001_100), + ], + turnRunning: true, + }); + expect(turns[0]?.active).toBe(true); + expect(turns[0]?.finals).toEqual([]); + expect(turns[0]?.process.map((block) => block.kind)).toEqual([ + 'assistant', + 'tools', + ]); + }); + + it('运行期失败说明挂到当前回合末尾,不当成最终回复', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '正文')], + localMessages: [localNotice('后台任务失败:端口被占用')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.finals.map((block) => block.kind)).toEqual([ + 'assistant', + 'assistant', + ]); + expect(turns[0]?.finals[1]).toMatchObject({ + notice: true, + text: '后台任务失败:端口被占用', + }); + }); + + it('乐观用户气泡自成回合,已落盘的同一身份不重复渲染', () => { + const turns = buildDirectChatTurns({ + entries: [userEntry('u1'), assistantEntry('a1', '答复')], + localMessages: [localUser('问题 u1', 'u1'), localUser('第二条')], + turnRunning: true, + }); + expect(turns.map((turn) => turn.key)).toEqual(['u1', 'local:1']); + expect(turns[1]?.users).toHaveLength(1); + expect(turns[1]?.active).toBe(true); + }); + + it('历史无用户条目时也保留一个回合承载正文', () => { + const turns = buildDirectChatTurns({ + entries: [assistantEntry('a1', '只有回复')], + }); + expect(turns).toHaveLength(1); + expect(turns[0]?.users).toEqual([]); + expect(turns[0]?.finals[0]).toMatchObject({ text: '只有回复' }); + }); + + it('分页切片开头落在半截回合里时并进后面那个回合,不生成没有用户气泡的孤儿回合', () => { + // 更早的一屏把上一条用户条目切在外面:这三条前导条目属于上一轮,但不能自成回合。 + const turns = buildDirectChatTurns({ + entries: [ + reasoningEntry('r-prev'), + toolEntry('t-prev'), + assistantEntry('a-prev', '上一轮的答复', 1_800_000_000_900), + userEntry('u2', 1_800_000_010_000), + assistantEntry('a2', '这一轮的答复', 1_800_000_011_000), + ], + }); + expect(turns.map((turn) => turn.key)).toEqual(['u2']); + expect(turns[0]?.users.map((block) => block.key)).toEqual(['u2:u2']); + expect(turns[0]?.process.map((block) => block.key)).toEqual([ + 'u2:r-prev', + 'u2:t-prev', + 'u2:a-prev', + ]); + expect(turns[0]?.finals.map((block) => block.key)).toEqual(['u2:a2']); + }); + + it('条目转消息只保留用户 / 助手正文,并按身份去重', () => { + const messages = directChatEntriesToMessages([ + userEntry('u1'), + reasoningEntry('r1'), + toolEntry('t1'), + assistantEntry('a1', '答复'), + ]); + expect(messages.map((message) => message.messageId)).toEqual(['u1', 'a1']); expect( - build([user('one')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe('回复'); - expect( - build([user('one'), assistant('direct-codex:one:assistant')], [], { - activeTurnId: 'one', - transientReply: '回复', - })[0].transientReply, - ).toBe(''); + mergeDirectChatMessages(messages, [ + { role: 'assistant', text: '答复', messageId: 'a1' }, + localNotice('只在运行期的失败说明', 'local-1'), + ]).map((message) => message.messageId), + ).toEqual(['u1', 'a1', 'local-1']); }); }); diff --git a/docs/README.md b/docs/README.md index 3de2b212c..01f489d07 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 +- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md new file mode 100644 index 000000000..0642ca205 --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -0,0 +1,55 @@ +# 【ADR】DirectProject对话历史单一事实源-2026-09-16 + +状态:已接受 + +## 背景 + +AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 + +`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 + +## 决策 + +- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 +- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 +- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 +- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 两侧的过滤口径必须完全一致,包含「哪些条目根本不是本项目的聊天条目」:Codex app-server 回显的用户消息(`userMessage` / 非 AGC 的 `role=user`)在落盘侧被过滤,在运行态事件侧也必须被过滤(`direct_thread_visible_item`)。少一侧就会出现「实时比历史多出两条同文本用户条目、各自开出一个耗时 0 秒的假回合,重进页面又正常」这类只有其中一侧的事实源缺陷。 +- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 +- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 +- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不需要为了取这批事件再补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。唯一例外是回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,前端却要等回执才知道自己的 `subscriptionId`,这段窗口内的通知只能记成欠账,回执到达后立刻补一次 `consume` 取回,否则该回合的尾部事件会卡在队列里等一个可能永不出现的下一次通知。 +- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 +- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 +- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。锚点是切片**新端(较新一侧)的边界且含该条**(命令参数 `throughItemId`):比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠;向后翻页仍用切片返回的 `firstItemId` 作为 `beforeItemId`(不含锚点)。订阅回执到达之前不读首屏,也不退化成"取文件尾"(那会把回执之后才完成的条目也拉进历史)。 +- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。`list_game_creator_direct_active_turns` 有意保留:它服务首页跨页面的「运行中的项目」列表,不是聊天框读路径。 +- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 +- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 +- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 +- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 + +## 备选方案与取舍 + +1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 +3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 + +## 影响 + +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 +- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 +- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 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 db90eebeb..000000000 --- a/docs/project-memory/plans/【实施计划】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,26 +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 草稿并本地提交。 -5. 在临时消息投影中标记历史来源,保留 Runtime 所有权语义;刷新合并时只保留非历史来源的待回读消息。补齐「先加载旧页,再 /history,再翻页」的顺序与去重回归,保持尚未落盘用户输入的保留逻辑。 - -## 边界与停止条件 - -不调整 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/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..933fca8a6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,43 @@ +# 【实施计划】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` | +| Status | implemented | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`agent/direct_thread_wire.rs`、`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、`src/features/project-workspace/generated/`(ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 +- 保持 `.env` 未提交修改,不触碰个人配置。 + +## 实现顺序 + +1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 +2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 +4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要为了拿这批事件再补一次 `consume`);此后只由 notify 唤醒 `consume`。唯一例外是回执竞态:Rust 注册完 subscriber 就开始通知,而前端要等回执才知道 `subscriptionId`,这段时间到达的通知只能记欠账,回执到达后立刻补一次 `consume`(否则整轮最后一个事件之后可能再无通知,事件会卡死在队列里)。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 +6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;切片的新端边界由这个锚点给出(含该条,命令参数 `throughItemId`),首屏读取等订阅回执里的锚点,回执到达前不发请求、也不退化成「取文件尾」;之后锚点按原始 item id 推进(`beforeItemId`,不含锚点)。一次翻页操作在前端连拉,直到合并后聊天投影出现新回合(新的用户气泡)或 `hasMore=false`,每个操作上限 5 页;锚点未推进(`items` 为空 / `firstItemId` 为 null / 与请求锚点相同)时立即停止。可见性口径只在 `features/project-workspace/directHistoryPaging.ts` 实现一份,首屏与「显示更早」共用。 +7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`),DirectRuntime 自己的写入保留。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。 +9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`),随后用 `prettier --write` 格式化生成目录,避免未格式化的 ts-rs 输出混进提交 +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts` +4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:第 2 步的归一必须在 Rust 出口完成;前端不得再拿到两个 id。 +- 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 +- 订阅过期:以重新 `subscribe` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 回执竞态:`subscribe` 回执到达前产生的 `notify` 拿不到订阅身份,必须记欠账并在回执到达后补一次 `consume`;已有专门用例 `drains a notify that lands before the subscribe receipt` 钉住,改坏会让整轮事件卡死。 +- 合并规则退化为"先到定形"后,若某类条目只有输出没有调用条目,该输出不显示;这是有意取舍,先观察再决定是否补规则。 +- 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 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 4b8e244c4..000000000 --- a/docs/project-memory/plans/【里程碑】AGC对话历史分页恢复-2026-09-16.md +++ /dev/null @@ -1,20 +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. 原始切片默认模式回归通过,用户日志只读重放可以取回全部现存消息;不把真实日志或对话正文提交到仓库。 -5. 加载更早消息后重新读取历史,旧页不出现在最新回复之后;再次翻页保持顺序且不重复。未落盘的实时用户输入不因历史刷新被丢弃。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..0fae7ebb0 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,75 @@ +# 【里程碑】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | implemented(自动化验收通过,运行时验收待补) | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | + +## 目标 + +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 + +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 + +## 范围 + +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 +- 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`。 +- 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;首屏切片的新端边界就是这个锚点(含该条,命令参数 `throughItemId`),比锚点更新的条目只从运行态事件来;向后翻页用切片返回的 `firstItemId`(`beforeItemId`,不含锚点)。后端切片只按原始条目切片,可见性判断留在前端聊天投影;一次翻页操作在前端连拉取页,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed` 把该回合运行态条目并入历史再清空。 +- 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 + +## 不在范围内 + +- 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 +- 跨进程回合账本、按回合统计与持久 turn ledger。 +- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 +- 旧项目磁盘上既有投影文件的清理、迁移或回填。 +- 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 + +## 已确认的决策 + +- 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 +- 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 +- 条目 id 只有一套:归一到 `itemId`;前端与 Thread Manager 都不再出现第二个 id。 +- 事件不带回合身份:`turn.started` 无载荷、`turn.completed{status}`;Thread Manager 的生命周期锚点、条目、增量与请求都不带 turn id。 +- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 +- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 +- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 +- 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 +- 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,`/history` 手动重读保持按当前文件尾取尾屏的恢复语义。 + +## 依赖与前置条件 + +- Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 +- 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同)。真实 app-server 会话核对仍作为运行时验收项。 + +## 验收标准 + +- [x] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。【自动化:`project-development.suite.ts` 空对话首轮 + 历史切片工具卡片用例】 +- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。【待真实 app-server 运行时验收】 +- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。【待真实 app-server 运行时验收】 +- [x] 历史切片从文件尾反向回扫,锚点始终是文件里的原始 item id;切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。【自动化:`direct_project_history` 尾部回扫与分页锚点用例】 +- [x] 首屏切片的新端边界是 `subscribe` 回执里的 `lastCompletedItemId`(含该条):比它更新的条目只从运行态事件来;锚点缺失时按文件尾取尾屏,订阅回执到达前不发首屏请求。【自动化:`cargo test agent::direct_project_history` 22 条(含 `through_item_id` 锚点用例)+ `directHistoryAnchorGate.test.ts` 10 条 + `project-development.suite.ts` 的 `anchors the first history page at the subscribe receipt instead of the file tail`(变异验证见下)】 +- [x] 前端一次翻页操作自动连拉,直到合并后聊天投影出现新回合或 `hasMore=false`,每个操作上限 5 页;锚点不前进时立即停止,不空转。【自动化:`directHistoryPaging.test.ts` 8 条 + `project-development.suite.ts` 跨页同回合用例(变异验证见下)】 +- [x] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream` 与 `read_direct_tool_calls` 命令已删除。`list_game_creator_direct_active_turns` **有意保留**:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不属于聊天框读路径。 +- [x] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。【自动化:reducer「先到定形、后到补空」合并单测 + 工具卡片渲染用例】 +- [x] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。【自动化:`directThreadChat` / `directTurnPresentation` 单测 + `cargo test export_bindings` 生成绑定无差异】 +- [x] 思考正文在回合进行中即可见,且不进入活动状态文本。【自动化:`directThreadChat` 的 `item.delta{kind:"reasoning"}` 单测】 +- [x] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。【自动化:reducer 过期重订阅单测】 +- [x] 通知先于 `subscribe` 回执到达时不丢事件:前端记欠账,回执到达后立刻补一次 `consume`。【自动化:`drains a notify that lands before the subscribe receipt` 用例】 + +## 证据要求 + +- 自动化(已跑):`cargo test direct_thread`(25 条)、`cargo test direct_project_history`(20 条)、`cargo test export_bindings`(生成绑定与工作区无差异)、`NODE_OPTIONS=--localstorage-file=… npx vitest run apps/ai-game-creator-shell/tests`(125 files / 1676 passed / 17 skipped)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint 与 `prettier`。 +- 自动化(分页连拉,已跑):`npx vitest run apps/ai-game-creator-shell/tests/directHistoryPaging.test.ts`(8 passed)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(470 tests / 453 passed / 17 skipped,含新增的 `keeps pulling earlier pages while the page only deepens the rendered turn`)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)。变异验证:把停止判据退化成「这一页有可渲染条目就停」后,跨页用例以 `Unable to find an element with the text: 更早的用户提问` 变红,即用户报的「点了没变化」。 +- 自动化(首屏锚点,已跑):`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml agent::direct_project_history`(22 passed)、`npx vitest run apps/ai-game-creator-shell/tests/directHistoryAnchorGate.test.ts`(10 passed)、`npx vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(475 tests / 457 passed / 17 skipped,含新增的 `anchors the first history page at the subscribe receipt instead of the file tail`)、`npx tsc -p apps/ai-game-creator-shell/tsconfig.json --noEmit`(exit 0)、ESLint / prettier / `npm run check:encoding` / `npm run check:doc-index` / `git diff --check`。同一次全量里唯一失败是既有的 `edits the published runtime config without leaking API keys into chat`(`appSurface/runtime-settings.suite.ts`),与本次改动无关:把本次前端改动 stash 掉后它同样变红。变异验证:① 锚点闸门忽略「已消费」后,`directHistoryAnchorGate.test.ts` 的「同项目已消费返回 null」变红;② 首屏不等闸门后,`anchors the first history page at the subscribe receipt instead of the file tail` 以 `throughItemId` 缺失变红。 +- 运行时(待补):真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 +- 边界:订阅过期、回执竞态、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 +- 环境注意:`rehype-highlight` 已装齐后 `ChatMarkdownMessage` / `AgentMessageContent` 转绿;Node 26 下 vitest 的 jsdom 用例需要 `--localstorage-file` 才能拿到 `window.localStorage`(`clientApi.test.ts` / `chatPromptPolish.test.tsx`),已记入 `docs/project-memory/shared-memory/pitfalls.md`。 diff --git a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md index 191a6f606..c7e01f50a 100644 --- a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md @@ -1,29 +1,32 @@ # 对话回合唯一投影 -- Version: 2 -- Status: implemented-awaiting-runtime-acceptance +- Version: 3 +- Status: superseded - Date: 2026-09-16 +- Superseded by: `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` - Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md -## 范围与评审 +## 结论 -单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。 +本里程碑原先把「回合唯一投影」落在 Direct 回合事件 + `turn-stream.jsonl` + `tool-calls.jsonl` + 活动回合快照这条读路径上,方向已被推翻:聊天视图的输入只剩**项目对话历史**与**运行态事件**两项,Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照都不再是聊天视图的输入。后续实现与验收一律以 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 为准。 -## 验收 +## 仍然有效的部分 -1. 一个 turn 只有一个呈现入口,用户消息不丢失。 -2. item 增量、完成、持久化和回读保持相同身份与固定顺序。 -3. 工具输入输出保留,重复快照不重复渲染。 -4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。 -5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。 -6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。 +1. 一个回合只有一个呈现入口,用户消息不丢失;历史里带不了身份(没有 id)的旧记录不得做位置猜配。 +2. 同一 item 的增量、完成、持久化与回读使用同一个身份、同一个顺序;重复快照不重复渲染。 +3. 工具调用的输入与输出都保留,展开后仍显示「输入 / 输出」。 +4. 已结束回合重进不显示提交中;真实运行回合可恢复。 +5. 用户消息时间只来自条目自己的时间戳,旧无时间记录不造值;完成后中间正文与工具统一折叠,最终回复与失败提示保持可见。 +6. 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 -依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。 +## 已作废的部分 + +- 由活动回合快照接管运行中回合:改为由 `subscribe` bootstrap 事件判定(事件序列里 `turn.started` 之后没有 `turn.completed` 即运行中)。 +- 按 `turnId` 归并回合并把工具卡片挂在回合上:事件与条目都不再带回合身份,前端只有一个 `turnRunning` 布尔,卡片形状去掉 `turnId`。 +- 用 `turn-stream.jsonl` 的 `seq` 决定文本与工具的交替顺序:改为按运行态事件顺序 + 历史文件顺序投影。 +- 前端订阅 `game-creator-direct-turn-update`:改为 `subscribe` + `notify` 唤醒 `consume`(bootstrap 的事件直接 reduce,不再补一次 `consume`)。 ## 当前证据 -- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查、`git diff --check` 通过。 -- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。 -- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。 -- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。 -- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。 +- 里程碑版本的静态检查结论(TypeScript、Cargo、编码、文档索引、`git diff --check`)仍然成立;该增量按当时授权未运行测试。 +- 新路径的证据要求见 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 的「验收标准」与「证据要求」,其中包含解锁此前跳过的工具卡片渲染用例。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ccd014d82..ec473d2c0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -18,6 +18,16 @@ - 验证:客户端定向 Rust 测试、格式、编码和 diff 检查通过;未修改主站路由或 BgFilter。 ## 2026-09-17 图集切分模式改为显式声明 +## 2026-09-17 DirectProject 首屏历史锚点只认订阅回执的 lastCompletedItemId + +- 背景:ADR「首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片」只落了一半。`DirectThreadManager` 是搬运层,内存里没有「已完成条目」的锚点,`subscribe` 一律返回 `last_completed_item_id: None`;`commands.rs` 的 `subscribe_direct_project_thread` 在为空时用 `read_direct_project_last_item_id_at` 从磁盘回填,所以线上回执里的值是真的(订阅那一刻文件里最后一条可显示条目的原始 item id)。前端侧:首屏一直在 `loadProjectConversation` 里用 `beforeItemId: null` 直接取文件尾一屏,`lastCompletedItemId` 自 `1b40f030e` 起不再被任何代码读取。 +- 决策(锚点语义):首屏切片的新端(较新一侧)边界就是这个锚点,**含锚点条目本身**;切片命令新增 `throughItemId` 参数表达「取到这条为止」。比锚点更新的条目只从运行态事件来,历史切片与实时流因此不重叠(原来的文件尾读取会把订阅回执之后才完成的条目也拉进历史,与运行态事件同 id 重叠,只靠前端合并兜住)。 +- 决策(读取时机):订阅回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏。`/history` 手动重读保持「按当前文件尾取尾屏」的恢复语义,不锚定。 +- 决策(翻页不变):向后翻页仍用切片返回的 `firstItemId` 作 `beforeItemId`(不含锚点),`hasMore` 与连拉口径不变。 +- 影响范围:`agent/direct_project_history.rs`(切片锚点 + `through_item_id` 参数)、`commands.rs`(`read_direct_project_history_slice` 命令参数)、AGC 前端首屏读取接线与测试骨架。**未改**:DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入与进度事件、`list_game_creator_direct_active_turns`、SpacetimeDB 与 HTTP 契约。 +- 验证方式(已跑):Rust 侧 `cargo test agent::direct_project_history`(22 passed,含「窗口取到锚点那条、排除比锚点更新的条目、`beforeItemId` 与 `throughItemId` 互斥报错」三类用例);前端 `npx vitest run .../directHistoryAnchorGate.test.ts`(10 passed)与 appSurface 的 `anchors the first history page at the subscribe receipt instead of the file tail`(全量 475 tests / 457 passed / 17 skipped;唯一失败 `edits the published runtime config without leaking API keys into chat` 与本次改动无关,stash 掉本次前端改动后同样变红);`tsc` / ESLint / prettier / `check:encoding` / `check:doc-index` / `git diff --check` 全绿。变异验证:闸门忽略「已消费」、首屏不等闸门两处改动各自让对应用例变红。 +- 关联文档:[ADR](../../adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md)、[里程碑](../plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md)、[实施计划](../plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md)。 + - 决策:`sliceMode` 在图标图集生成入口成为必填字段且不保留任何默认值。省略、`null` 或空字符串必须在引用解析、定价、入队和 provider / OSS 副作用之前返回 `400`(`field=sliceMode`);`grid` 必须同时提供 `gridX`/`gridY`,`connected-components` 不得携带网格尺寸,二者矛盾同样在副作用前失败关闭。 - 决策要求:只有用户或需求明确要求等分网格、固定槽位或指定行列数时才使用 `grid`,且行列数必须来自该需求;自由排布、数量不定或只要求一张图集时显式传 `connected-components`,需要约束素材张数时用 `sliceCount`,不得用网格参数表达张数,也不得用固定 `2×2` 表达“四类素材”。 - 影响面:平台两个图集生成入口(`/api/editor/...` 与 `/api/external/v1/editor/...`)、OpenAPI、画板 Agent 工具、画板前端提交计划、AGC 客户端 MCP 工具说明与桥接校验、AGC 原生工具 schema 与观察器、AGC Skill 与外部编辑器 Skill。 @@ -87,6 +97,16 @@ - 验证方式:`three_dimensional_game_request_frees_the_engine_choice`、`explicit_flat_presentation_requests_do_not_trigger_three_dimensional_selection`、`named_engine_requests_keep_the_existing_engineering_rule`、`three_dimensional_contract_reports_the_current_project_engine`、`home_three_dimensional_note_keeps_project_creation_available`、`system_prompt_is_bounded_and_declares_direct_runtime`,以及 `agent::direct_tools_mcp` 17 passed;`cargo fmt --check` 通过。本机临时目录 owner ACL 与进程用户不一致,涉及 `init_local_game_project_at` 的既有用例(含未改动模块)在本机无法执行,全量分片与真机 smoke 未在本轮取得。 - 关联文档:[里程碑](../plans/【里程碑】Direct三维请求自选技术栈-2026-09-16.md)、[实施计划](../plans/【实施计划】Direct三维请求自选技术栈-2026-09-16.md)。 +## 2026-09-17 DirectProject 历史分页的「可显示」口径定为前端回合反馈,一次翻页连拉上限 5 页 + +- 背景:ADR 与里程碑要求「一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页」,但实现只落地了后端锚点与文件尾回扫,前端仍是单发一页。历史切片的 `limit` 按原始条目计,一整页全是工具卡片 / 思考文本且落进同一个已渲染回合的折叠「执行过程」时,用户点「显示更早的对话」看不到任何变化。 +- 决策(可显示 = 出现新回合):一次翻页操作连续取页,停止判据是**合并后聊天投影的回合数增加**(出现新的用户气泡)。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但它们可能整页落进已渲染回合的折叠过程区,不构成用户可见反馈。 +- 决策(粒度和上限):每个用户操作最多 5 次请求,首屏那次算第 1 页、之后每次点击重新计数;每页仍是 `limit = CONVERSATION_VISIBLE_STEP`,上限只约束请求次数,不改页大小契约。 +- 决策(硬性终止):`items` 为空、`firstItemId` 为 null 或与请求锚点相同 → 立即停止,不靠 5 页上限兜底。 +- 决策(落点):口径与循环只在 `features/project-workspace/directHistoryPaging.ts` 一份实现里,首屏与「显示更早」共用;`DIRECT_HISTORY_MAX_PAGES_PER_ACTION` 放 `app/constants.ts`。 +- 边界:循环内只累积、结束后一次性并入聊天 state;某页失败时保留已成功页并沿用现有报错文案;切换项目时丢弃整批;不新增 loading / 禁用态与新文案,不做滚动锚定补偿;运行中回合允许连拉历史。 +- 验证:`directHistoryPaging` 单测 8 条覆盖口径、上限、`hasMore=false` 早停、锚点不前进、单页失败;`project-development.suite.ts` 新增「跨页同回合」集成用例作为真实回归网(变异验证:把判据退化成「有可渲染条目就停」后该用例变红);`tsc` 与 `appSurface.test.ts`(470 tests / 17 skipped)全绿。 + ## 2026-09-16 图标图集自动拆图上限提高到 256 - 背景:AGC 图标图集自动连通域识别在一次生成中识别出 86 个区域,原有 64 片上限在后处理阶段阻断了请求;该上限同时影响 api-server 自动 / 手动切片、SpacetimeDB 批量落库和统一生成结果 item 数量。 @@ -8336,6 +8356,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - `.agent/conversations/project.jsonl` 中的 DirectProject 用户消息由 AGC 在 `turn/start` 前以 `direct-codex:{clientTurnId}:user` 幂等追加;写入失败时禁止发起 Codex turn,失败或中断也保留该 user item。 - Codex app-server 回显的 `userMessage` / `role=user` item 不是第二个历史来源。AGC 只处理其观察和关联,不再把该 echo 追加到项目历史;Codex 的 assistant、tool 和其它有效 response item 仍按现有 append-only 规则落盘。 +- 2026-09-17 追加:回显过滤必须同时覆盖**运行态事件侧**(`direct_thread_visible_item`,`item/started` 与 `rawResponseItem/completed` 两条路径),不能只过滤落盘。只过滤落盘时实时聊天会多出两条没有历史对应的孤儿用户气泡,各自开出一个「耗时 0 秒」的假回合,重进页面读同一份已过滤的 `project.jsonl` 又恢复正常;判据是 `direct_project_turn_does_not_forward_codex_user_echo_as_chat_items`(回合事件里只能有一条 `direct-codex:{clientTurnId}:user` 用户条目)。 - 本地 AGC user-item 写入必须使用允许 user item 的内部入口,Codex raw item 写入使用过滤入口,避免“过滤回显”反过来阻断预写。相同 `clientTurnId` 只能复用相同规范化 prompt,内容冲突必须失败关闭。 ## 2026-08-31 AGC 错误报告与诊断上传 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 2a440a3b8..f6651d3b1 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -27,10 +27,6 @@ Vite 默认监听应用根下的 Rust `src-tauri/target`,构建产物较多时 - **原因**:这个 shell 以管理员身份运行,`New-Item` / `tempfile` 新建目录的 owner 是 `BUILTIN\Administrators`,而 AGC 的校验要求 owner 等于当前用户 SID(`KDLETTERS\`)。`Get-Acl | Select Owner` 与 `whoami` 一比就能定性;同一台机器上由客户端自己创建的目录 owner 正确,所以「客户端自己建的项目能用、手建的不能用」。 - **处理**:手工夹具先 `icacls /setowner "\" /T`;Rust 用例改用工程自带的 `crate::tests::canonical_test_tempdir(prefix)`(它会 canonicalize 并重置目录 owner),不要直接用 `tempfile::tempdir()`。判「用例失败与本改动无关」时,先确认失败信息是不是这一条。 -## 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 资产门禁;缓存与项目切换须保留现有身份隔离。 @@ -5695,10 +5691,17 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/` - **现象**:在 `apps/ai-game-creator-shell/src-tauri` 跑过 `cargo test` 之后,`npm run ai-game-creator-shell:typecheck` 报 10 条类型错(`src/features/project-workspace/resourceReferences.ts:106-112` 的 `string | undefined` 不能赋给 `string | null`;同文件 134 行的对象字面量带 `type: 'message'`,而 `DirectCodexUserMessageItem` 里没有该字段),`npm run ai-game-creator-shell:build` 也死在 `beforeBuildCommand` 的同一条 typecheck 上。 - **原因**:crate 里的 ts-rs 导出会按**本机依赖版本**重写 `src/features/project-workspace/generated/DirectCodexUser*.ts`:注释头变成 "This file was generated…"、字符串改双引号、`DirectCodexUserMessageItem` 丢掉 `type: 'message'` 判别字段、并多出一个 `DirectCodexUserMessageEnvelope.ts`。仓库里提交的那份是前端真正依赖的形状(前端按带 `type` 的判别联合写),重写后两边就对不上——错在生成器版本漂移,不在前端。 -- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后先 `git checkout -- apps/ai-game-creator-shell/src/features/project-workspace/generated`,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`,然后才做 typecheck / 打包;绑定与前端形状冲突时以**已提交的绑定 + 前端**为基准排查。 -- **验证**:恢复提交版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的未跟踪文件,属同类生成产物。 +- **处理(现行口径)**:不要把重写结果当改动提交。跑过 `cargo test` 或构建后只恢复 `apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUser*.ts` 的仓库版本,再删掉多出来的 `DirectCodexUserMessageEnvelope.ts`;不要恢复整个 `generated/` 目录,以免误删 DirectThread 的现役绑定。绑定与前端形状冲突时以**已提交的 DirectCodexUser 绑定 + 前端**为基准排查。 +- **验证**:恢复仓库版本后 `npm run ai-game-creator-shell:typecheck` exit 0(`[skill-pack] OK`);保留重写结果时同一条命令 exit 2。release 构建本身还会在 `src/features/ui-editor/types/` 落下 `BindingChange.ts` / `BindingDTO.ts` 两个无人引用的生成产物;它们不属于前端契约,发现后直接删除,不提交。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/`(ts-rs 导出源)、`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts`、`apps/ai-game-creator-shell/scripts/build-release.mjs`(`beforeBuildCommand`)。 +## 2026-09-16 Node 26 下 vitest 的 jsdom 用例拿不到 window.localStorage + +- **现象**:只声明 `@vitest-environment jsdom` 的用例里 `window.localStorage` 是 `undefined`(典型报错 `Cannot read properties of undefined (reading 'clear')`);而 `appSurface.test.ts` 里同样访问 `window.localStorage` 却一切正常。 +- **原因(已核对,不是推断)**:Node 26 在 `globalThis` 上定义了实验性 `localStorage` 访问器,不传 `--localstorage-file` 时它返回 `undefined`。vitest 0.34 的 jsdom 环境把 jsdom window 的描述符复制到全局时,不会覆盖 `globalThis` 上已存在的键,于是 jsdom 真正的 `Storage` 被 Node 的 `undefined` 顶掉。`appSurface` 之所以不受影响,是因为 `apps/ai-game-creator-shell/tests/appSurface/harness.ts` 自己用 `Object.defineProperty(window, 'localStorage', …)` 挂了内存实现。 +- **处理**:跑这类用例时给 Node 加 `--localstorage-file`,例如 `NODE_OPTIONS="--localstorage-file=/tmp/vitest-node-localstorage" npx vitest run `;`apps/ai-game-creator-shell/tests/clientApi.test.ts` 与 `chatPromptPolish.test.tsx` 加这个开关后 26 项全绿。不要改业务代码或往测试里塞假 storage 来绕过。 +- **关联**:`vitest.config.ts`(`environment: 'node'` + 用例内 docblock 切 jsdom)、`apps/ai-game-creator-shell/tests/appSurface/harness.ts` 的内存 localStorage 垫片。 + ## 2026-09-17 AGC 壳首页无限 setState:effect 依赖了每次渲染都换身份的普通函数 - **现象**:dev 客户端停在首页、不点任何东西也会持续刷 `WEBVIEW error webview: Maximum update depth exceeded …`(5 秒涨 ~8.5 KB 日志),对应 WebView2 renderer 工作集涨到 **4.2 GB**、CPU 持续累计(约 0.7–1.5 核);表现上很像"模板库卡片太多/滚动卡",实际与页面内容无关。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 3c864437b..40b9aa9b4 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -48,10 +48,6 @@ ## 2026-09-16 DirectProject 回合展示唯一归属 -- 聊天历史使用 `read_direct_project_history_slice` 的 `messagesOnly: true` 模式,按有正文的 user/assistant 消息分页,默认 20 条;工具/推理原始记录不占聊天页名额、不进入聊天分页响应,也不从磁盘删除。接口省略该选项时维持原始 item 切片语义。响应给出明确的 `oldestItemId` 游标;消息投影不能重新发明分页位置。 -- 消息模式读取逐行过滤原始记录,不在内存中积累整份工具输出;正文、原始 ID 和信封时间原样保留。旧的无 ID 消息不能凭空生成身份,必要时向前扩展到已有消息 ID 边界;没有更早消息时结束分页。 -- 首屏和加载更早消息共用分页解析;重复点击只发一个请求,重叠消息按原始 ID 去重并保留当前显示版本。切项目、同项目重新加载及 A→B→A 的迟到响应不得覆盖当前消息、游标或加载状态;失败保留已有消息与分页位置并允许重试。 -- 历史读取来源与 Runtime 所有权分开记录;从磁盘分页读出的消息不能当作未落盘实时消息保留到新页末尾。重新读取历史时回到最新页,旧页仍可从原生游标再次向前加载;真正尚未回读到的实时消息继续保留,不改原始日志、时间或内容。 - 交付合同:实时消息、历史回读、工具详情与最终回复先归一为按 `clientTurnId` 唯一的回合,再渲染一次。用户消息始终保留;同一回合的正文、工具和耗时不能从消息、实时尾部、未归属尾部等多个出口重复展示。 - 归属来自 `direct-codex:{clientTurnId}:{role}`、文本流中保留的原始 item ID,以及项目历史内明确用户记录之后的 assistant 记录。先在已加载的完整消息集合中关联,再做可见分页;持久历史继续通过 canonical item 切片懒加载,`hasMore` 为真时,未加载回合的工具流不得漂到当前页尾部。禁止将第 N 个有工具回合配给第 N 条用户消息,禁止按文本长度、标点或时间窗猜测归属。缺身份的旧记录保留,不猜造其与其它回合的关联。 - 有回合流时正文与工具位置仅来自 item 边界与 `seq`,工具详情按该回合的 `callId` 关联;没有流时同一个回合容器显示历史消息与工具。整轮累计文本仅在活动回合尚无流和持久 assistant 时作兜底,不另建实时消息出口。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index a2b266ecb..29e7165c6 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -1,6 +1,6 @@ # DirectProject Codex 原始历史与异常恢复 -更新时间:`2026-09-15` +更新时间:`2026-09-16` ## 目标 @@ -18,7 +18,7 @@ DirectProject 只使用 `.agent/conversations/project.jsonl` 作为对话历史 `project.jsonl` 的 `payload` 必须是未经改写的 Responses item。AGC 前端 user input 先以 canonical user message item 形式写入;发送给 app-server 前由 Rust 投影为 Codex 可接受的 `message` item,AGC 私有 content part 不会穿透到 wire。Codex 返回的 `rawResponseItem/completed.params.item` 原样追加。native 工具、MCP 工具、reasoning、调用参数和调用结果都保留完整内容,不截断、不摘要、不保存运行态 delta/started 事件。 -Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影,再只发送 item 类型、item ID、delta 文本和 turn 终态等必要字段;不得把完整 item、工具参数或调用结果转发到前端。完整 item 仍只通过上述 JSONL 历史读取。 +Thread Manager 的运行态事件是另一份内存协议:app-server 通知先经过安全投影(挑字段、脱敏、截断、路径归一),再按与历史切片同形的脱敏原始条目(`itemType`、唯一 `itemId`、正文或工具明细)加 delta 文本与 turn 终态下发;搬运层不生成卡片形状,也不把未经脱敏的完整 item 转发到前端。注入 Codex 用的完整 item 仍只从上述 JSONL 历史读取。 DirectProject 自己的写侧只写新格式:格式切换(#282)时仍会写旧行的路径已收口——显式 Codex 返回只落在自己的 journal `.agent/conversations/codex-responses.jsonl`,不再投影进 `project.jsonl`。 @@ -96,26 +96,54 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `consume` 不接收或返回 cursor。每个 subscriber 在 Rust 内部持有自己的 cursor,并在加锁的临界区内完成过期判断、读取和 cursor 前进。前端只持有 `subscriptionId` 与 reducer state。并发 `consume` 不重复返回同一批事件。 -`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 +`notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证——但前提是前端确实唤醒了 `consume`,见下条的回执竞态。 + +`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"先补一次 `consume` 才能拿到已暂存事件"的步骤。第一个例外只有回执竞态:Rust 侧一注册完 subscriber 就开始 `notify`,而前端要等回执到达才知道自己的 `subscriptionId`,这段窗口内的通知拿不到订阅身份。前端因此必须记一笔欠账,回执到达后立刻补一次 `consume` 取回那批事件;否则事件会卡在队列里等下一次通知,而一次回合的最后一个事件之后可能再也没有通知。除此之外不轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 + +首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 ### 事件和顺序 -Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: +Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thread Manager 的内部游标事实,不下发**:同一个 subscriber 的 `consume` 按队列顺序返回事件数组,数组顺序就是前端要处理的顺序,前端因此不需要 item 级 cursor 或第二套 reducer。 + +线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),前端消费 `src/features/project-workspace/generated/` 里的生成绑定,改 Rust 模型后跑 `cargo test export_bindings` 重新生成;毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 + +事件按 `type` 区分,条目按 `itemType` 区分: ```ts -{ - seq: number, - type: string, - turnId: string, - itemId?: string, - payload: unknown, -} +type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } + | { type: 'request'; kind: 'approval.requested' | 'ask.requested' | 'request.resolved'; requestId: string | null }; ``` -进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。 +进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 + +**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 +前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 + +生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 + +`item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + +条目形状的职责边界固定为三条: + +1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 +2. **只有一个条目身份**。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,同一调用的调用与输出共用前者;codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),所以在进队列前归一成一个 `itemId`。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 + +前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复;失败与中止说明只在运行期显示,不写进 `project.jsonl`。 + +历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 + +思考正文以 `item.delta{kind:"reasoning"}` 流式下发(来源是 app-server 的 `item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见文本范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本;plan 文本与命令输出仍然只降级为活动状态,不下发正文。 + ### 队列、subscriber 和回收 每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 11ce76401..886772b12 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -1,5 +1,14 @@ # 【技术方案】GameAgent 对话工具调用卡片(Codex 风格)-2026-09-14 +## 2026-09-16 修订(当前状态) + +本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: + +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`;`read_direct_tool_calls` 命令与 Rust 侧 `read_direct_tool_calls_at` 回读函数已删除,该文件现在只有 DirectRuntime 的写入。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。 +- 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit`:聊天卡片不再有回合身份。 +- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl` 的 `seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 + ## 一句话交付 把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。 @@ -47,15 +56,15 @@ toolCalls?: DirectTurnToolCall[] | null; - 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。 - `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。 -### 3. 回读命令 +### 3. 落盘契约(写侧) -新增 Tauri 命令 `read_direct_tool_calls(projectPath)`,返回按时间正序的 `DirectTurnToolCall[]`。 +DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。 - **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 - 历史文件缺失 → 返回空数组,不报错。 - 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 -### 4. 前端合并与渲染(回合唯一归属,连续工具成块) +### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。