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 14c1f7917..4f6e8b58c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -30,11 +30,9 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; -mod direct_tool_calls; mod direct_tools_mcp; mod direct_turn_error; mod direct_turn_failure; -mod direct_turn_stream; mod direct_validation; mod generation; mod prompt; @@ -74,11 +72,9 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; -pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; pub(crate) use direct_turn_error::*; pub(crate) use direct_turn_failure::*; -pub(crate) use direct_turn_stream::*; pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use generation::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index 4dee3f105..84fa98c2c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -710,32 +710,21 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( .filter_map(|event| serde_json::to_string(&event).ok()) .collect::>(); stream.push(String::new()); - parse_direct_stream_result( - stream.join("\n").as_bytes(), - client_turn_id.unwrap_or("claude-code-turn"), - observer, - ) + parse_direct_stream_result(stream.join("\n").as_bytes(), observer) } fn parse_direct_stream_result( stdout: &[u8], - turn_id: &str, mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let text = std::str::from_utf8(stdout) .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; let mut result = None; - let mut tool_calls = std::collections::HashMap::::new(); for line in text.lines().filter(|line| !line.trim().is_empty()) { let event: serde_json::Value = serde_json::from_str(line) .map_err(|_| "Claude Agent SDK stream-json 包含无效 JSON".to_string())?; let event_type = event.get("type").and_then(serde_json::Value::as_str); if event_type == Some("assistant") { - let id = event - .get("uuid") - .and_then(serde_json::Value::as_str) - .unwrap_or("claude-code-assistant") - .to_string(); let content = event .pointer("/message/content") .and_then(serde_json::Value::as_array); @@ -754,77 +743,7 @@ fn parse_direct_stream_result( .collect::(); if !visible.is_empty() { if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: id, - accumulated_text: visible, - completed: true, - }); - } - } - for part in content.iter().filter(|part| { - part.get("type").and_then(serde_json::Value::as_str) == Some("tool_use") - }) { - let Some(call_id) = part.get("id").and_then(serde_json::Value::as_str) else { - continue; - }; - let name = part - .get("name") - .and_then(serde_json::Value::as_str) - .unwrap_or("mcp_tool") - .to_string(); - let arguments = part - .get("input") - .cloned() - .unwrap_or(serde_json::Value::Null); - let now = direct_tool_call_now_ms(); - let call = DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: call_id.to_string(), - turn_id: turn_id.to_string(), - kind: "mcp_tool".to_string(), - title: "调用工具".to_string(), - summary: name, - status: "running".to_string(), - detail: DirectToolCallDetail { - command: serde_json::to_string(&arguments).ok(), - output: None, - changes: Vec::new(), - }, - started_at: now, - updated_at: now, - }; - if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall(call.clone())); - } - tool_calls.insert(call_id.to_string(), call); - } - } - } else if event_type == Some("user") { - if let Some(content) = event - .pointer("/message/content") - .and_then(serde_json::Value::as_array) - { - for part in content.iter().filter(|part| { - part.get("type").and_then(serde_json::Value::as_str) == Some("tool_result") - }) { - let Some(call_id) = part.get("tool_use_id").and_then(serde_json::Value::as_str) - else { - continue; - }; - let Some(mut call) = tool_calls.remove(call_id) else { - continue; - }; - call.status = "completed".to_string(); - call.updated_at = direct_tool_call_now_ms(); - call.detail.output = part - .get("content") - .and_then(serde_json::Value::as_str) - .map(str::to_string); - if part.get("is_error").and_then(serde_json::Value::as_bool) == Some(true) { - call.status = "failed".to_string(); - } - if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall(call)); + observer(DirectCodexTurnObservation::AgentMessageSegment(visible)); } } } @@ -894,14 +813,13 @@ mod tests { {"type":"result","is_error":false,"result":"最终回复"} "# .as_bytes(), - "turn-1", Some(&mut |event| observed.push(event)), ) .expect("stream result"); assert_eq!(text, "最终回复"); assert!(matches!( observed.as_slice(), - [DirectCodexTurnObservation::AgentMessageSegment { .. }] + [DirectCodexTurnObservation::AgentMessageSegment(_)] )); } } 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 3f28cb2a9..4050394a5 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 @@ -642,21 +642,12 @@ enum CodexTurnEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), - /// 一个 assistant 文本段的当前累计全文。 - /// - /// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**, - /// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。 - AgentMessageSegment { - item_id: String, - accumulated_text: String, - completed: bool, - }, + /// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。 + AgentMessageSegment(String), IntermediateText(String), /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 Reasoning(String), Activity(&'static str), - /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 - ToolCall(crate::DirectToolCall), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -809,7 +800,7 @@ fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, ) -> Option { - thread_item_from_value(root, item, direct_tool_call_now_ms()) + thread_item_from_value(root, item, direct_now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -3325,9 +3316,9 @@ impl CodexAppServerConnection { ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); - // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), - // 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。 - let direct_tool_call_turn_id: Option = direct_client_turn_id + // 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), + // 不用 Codex app-server 自己的 turnId。 + let direct_turn_id: Option = direct_client_turn_id .map(str::trim) .filter(|turn_id| !turn_id.is_empty()) .map(str::to_string); @@ -3506,7 +3497,7 @@ impl CodexAppServerConnection { } let context = ProjectModelUsageContext { root: history_root.to_path_buf(), - client_turn_id: direct_tool_call_turn_id.clone(), + client_turn_id: direct_turn_id.clone(), thread_id: Some(thread_id.clone()), requested_model: model.to_string(), }; @@ -3527,7 +3518,7 @@ impl CodexAppServerConnection { let turn_start_cancellation = Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); // Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。 - let _active_turn_guard = direct_tool_call_turn_id + let _active_turn_guard = direct_turn_id .as_deref() .filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject) .map(|turn_id| { @@ -3611,7 +3602,7 @@ impl CodexAppServerConnection { // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 - let direct_turn_started_at_ms = direct_tool_call_now_ms(); + let direct_turn_started_at_ms = direct_now_ms(); let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -3711,17 +3702,14 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::AccumulatedText( streamed_text.clone(), )); - // 同一个 assistant item 的当前累计全文:回合流按 item 分段, - // 段内只追加、段间才换行,不能拿"整轮累计"当一段。 + // 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。 let segment_text = direct_project_history .accumulated_text_for(&item_id) .unwrap_or_else(|| delta.clone()); if !segment_text.trim().is_empty() { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.clone(), - accumulated_text: segment_text, - completed: false, - }); + observer(DirectCodexTurnObservation::AgentMessageSegment( + segment_text, + )); } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { @@ -3780,10 +3768,7 @@ impl CodexAppServerConnection { // 通知的钟就是该阶段唯一可证明的时间。 append_thread_event( &direct_thread_id, - ThreadEvent::item_completed( - entry_item, - direct_tool_call_now_ms(), - ), + ThreadEvent::item_completed(entry_item, direct_now_ms()), ); } } @@ -3841,43 +3826,22 @@ impl CodexAppServerConnection { completed, ¶ms, ); - // 工具调用卡片:item/started 与 item/completed 各采一次, - // 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id / - // 非工具类 item)就静默跳过,不影响这一轮的其它投影。 - if let Some(turn_id) = direct_tool_call_turn_id.as_deref() { - if let Some(tool_call) = direct_tool_call_from_item( - history_root, - item, - turn_id, - completed, - direct_tool_call_now_ms(), - ) { - if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::ToolCall( - tool_call, - )); - } - } - } } if item_type == "agentMessage" { // 某些 app-server 实现会在工具开始后停止发送 agentMessage delta, - // 但会在 item/completed 携带完整文本。把这份最终快照补进回合流, - // 让流中的文本段不会停在工具前的短前缀。 + // 但会在 item/completed 携带完整文本;把这份最终快照也交给观察者, + // 运行态条目就不会停在工具前的短前缀。 if completed { - if let (Some(item_id), Some(text)) = ( - item.get("id").and_then(serde_json::Value::as_str), - item.get("text") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()), - ) { + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()) + { if let Some(observer) = direct_observer.as_deref_mut() { observer( - DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.to_string(), - accumulated_text: text.to_string(), - completed: true, - }, + DirectCodexTurnObservation::AgentMessageSegment( + text.to_string(), + ), ); } } @@ -3919,7 +3883,7 @@ impl CodexAppServerConnection { ¶ms, item, false, - direct_tool_call_now_ms(), + direct_now_ms(), ), ), ); @@ -3943,15 +3907,10 @@ impl CodexAppServerConnection { .filter(|text| !text.trim().is_empty()) { final_text = Some(text.to_string()); - if let (Some(item_id), Some(observer)) = ( - item.get("id").and_then(serde_json::Value::as_str), - direct_observer.as_deref_mut(), - ) { - observer(DirectCodexTurnObservation::AgentMessageSegment { - item_id: item_id.to_string(), - accumulated_text: text.to_string(), - completed: true, - }); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::AgentMessageSegment( + text.to_string(), + )); } } } @@ -3968,7 +3927,7 @@ impl CodexAppServerConnection { thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), - direct_tool_call_now_ms(), + direct_now_ms(), ), )); } @@ -4138,12 +4097,12 @@ impl CodexAppServerConnection { .unwrap_or_else(|| fallback_status.to_string()); // 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。 let completed_at = if approval_adapter.is_some() { - direct_tool_call_now_ms() + direct_now_ms() } else { model_terminal .as_ref() .map(|(_, at)| *at) - .unwrap_or_else(direct_tool_call_now_ms) + .unwrap_or_else(direct_now_ms) }; // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 25ac70b95..06b012c94 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1,9 +1,7 @@ use super::*; use base64::Engine as _; -use std::collections::BTreeMap; use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::Mutex; mod user_input; #[cfg(not(test))] @@ -475,10 +473,6 @@ pub(crate) fn active_turn_id_at(root: &Path) -> Result { /// 这期间执行器侧还没有这一轮的登记,但这一轮马上就会进去,提前释放等于放开并发。 const STALE_TURN_RELEASE_MIN_AGE_MS: u64 = 60_000; -fn direct_taonier_active_now_millis() -> u64 { - unix_millis().min(u128::from(u64::MAX)) as u64 -} - /// "终止"拿不到可中断句柄时的分类,决定这一轮能不能被兜底释放。 #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum StaleTurnReleaseReason { @@ -4134,7 +4128,7 @@ fn sync_direct_codex_project_outputs_at( /// Reasoning wrappers are still removed because they are not reply text, but /// the user owns the project and the resulting reply is not redacted here. /// -/// `pub(crate)`:回合流(`direct_turn_stream`)落最终回复前复用同一套可见性投影。 +/// `pub(crate)`:运行态与历史投影共用同一套可见性判断。 pub(crate) fn project_direct_codex_visible_text(value: &str) -> Option { let stripped = strip_incomplete_direct_thinking_marker(&strip_llm_thinking_blocks(value)); if stripped.trim().is_empty() { @@ -4315,7 +4309,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { - emitter.emit("accepted", Some("request-accepted"), None, None); + emitter.emit("accepted", Some("request-accepted")); } match run_direct_game_creator_turn_inner( root, @@ -4343,19 +4337,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt // 用户消息,原因只在当轮界面与宿主诊断里。以后要给"进历史但不喂模型"的条目留一条通道 // (见 `docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md` 的备选方案第 3 条)。 if let Some(emitter) = turn_emitter { - // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, - // 这样"流里已经是完整内容"这一点对失败回合同样成立。 - let failure_item = append_direct_turn_stream_text_at( - root, - emitter.turn_id(), - DIRECT_TURN_STREAM_FAILURE_ITEM_ID, - &error, - ) - .ok() - .flatten() - .into_iter() - .collect::>(); - emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); + emitter.emit("failed", Some("none")); } Err(DirectTurnError::TurnFailed { stage, @@ -4365,163 +4347,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt } } -/// 本回合累积的工具调用条目(观察者写、回合末读)。 -type DirectToolCallCollector = std::sync::Arc>>; - -fn lock_direct_tool_call_collector( - collector: &DirectToolCallCollector, -) -> std::sync::MutexGuard<'_, Vec> { - collector - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -/// 单条工具调用落盘:走阻塞线程池(写文件要拿项目锁,不能在 async 运行时上直接跑)。 -/// 失败只返回错误交给调用方忽略,不打断回合。 -fn spawn_persist_direct_tool_call(root: &Path, call: &DirectToolCall) { - let root = root.to_path_buf(); - let call = call.clone(); - tauri::async_runtime::spawn_blocking(move || persist_direct_tool_call_at(&root, &call)); -} - -/// 回合结束整批落盘;失败时退回逐条 upsert,尽量把能写的写进去。 -fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCollector) { - let calls = { - let collected = lock_direct_tool_call_collector(collector); - collected.clone() - }; - if calls.is_empty() { - return; - } - if persist_direct_tool_calls_at(root, &calls).is_ok() { - return; - } - for call in &calls { - let _ = persist_direct_tool_call_at(root, call); - } -} - -/// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。 -fn spawn_persist_direct_turn_stream_item( - root: &Path, - item: &DirectTurnStreamItem, -) -> tauri::async_runtime::JoinHandle> { - let root = root.to_path_buf(); - let item = item.clone(); - tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item)) -} - -/// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。 -const DIRECT_TURN_STREAM_TEXT_THROTTLE_MS: u128 = 300; - -/// 正在增长的那一段文本。 -struct DirectTurnStreamPendingText { - /// 这一段对应的 Codex assistant item id(段身份)。 - item_id: String, - item: DirectTurnStreamItem, - last_flush: std::time::Instant, -} - -/// 回合流的写入与下发状态(观察者持有)。 -/// -/// `seq_by_id` 是**顺序真相的本体**:条目 id 第一次出现时分配序号,之后所有更新都带同一个 -/// 序号,所以并发落盘的先后不会改变渲染顺序(不会出现"新工具插到旧文本前面")。 -struct DirectTurnStreamWriter { - turn_id: String, - seq_by_id: BTreeMap, - next_seq: u64, - last_updated_at: u64, - pending_text: Option, -} - -impl DirectTurnStreamWriter { - fn new(turn_id: String) -> Self { - Self { - turn_id, - seq_by_id: BTreeMap::new(), - next_seq: 0, - last_updated_at: 0, - pending_text: None, - } - } - - /// 条目 id 对应的固定序号:首次出现时分配,之后永远不变。 - fn seq_for(&mut self, id: &str) -> u64 { - if let Some(seq) = self.seq_by_id.get(id) { - return *seq; - } - self.next_seq += 1; - self.seq_by_id.insert(id.to_string(), self.next_seq); - self.next_seq - } - - /// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照。 - fn push_text( - &mut self, - root: &Path, - item_id: &str, - visible_text: &str, - now_ms: u64, - completed: bool, - ) -> Vec { - let now = std::time::Instant::now(); - let mut snapshots = Vec::new(); - self.last_updated_at = now_ms.max(self.last_updated_at.saturating_add(1)); - if self - .pending_text - .as_ref() - .is_some_and(|pending| pending.item_id != item_id) - { - snapshots.extend(self.take_pending_snapshot()); - } - if let Some(pending) = self.pending_text.as_mut() { - pending.item.text = Some(sanitize_stream_text(root, visible_text)); - pending.item.updated_at = self.last_updated_at; - if completed - || now.duration_since(pending.last_flush).as_millis() - >= DIRECT_TURN_STREAM_TEXT_THROTTLE_MS - { - pending.last_flush = now; - snapshots.push(pending.item.clone()); - } - } else { - let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id)); - let item = direct_turn_stream_text_item( - root, - &self.turn_id, - item_id, - visible_text, - seq, - now_ms, - self.last_updated_at, - ); - snapshots.push(item.clone()); - self.pending_text = Some(DirectTurnStreamPendingText { - item_id: item_id.to_string(), - item, - last_flush: now, - }); - } - snapshots - } - - /// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。 - fn take_pending_snapshot(&mut self) -> Option { - self.pending_text.take().map(|pending| pending.item) - } - - /// 工具条目:只记位置,正文仍来自 `tool-calls.jsonl`。 - fn push_tool(&mut self, call: &DirectToolCall, now_ms: u64) -> DirectTurnStreamItem { - let seq = self.seq_for(&direct_turn_stream_tool_item_id(&self.turn_id, &call.id)); - let at = if call.started_at > 0 { - call.started_at - } else { - now_ms - }; - direct_turn_stream_tool_item(&self.turn_id, call, seq, at) - } -} - async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, @@ -4588,351 +4413,248 @@ async fn run_direct_game_creator_turn_inner( } // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 let result: Result = async { - if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { - return Ok(report); - } - // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID - // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 - let direct_user_item = match direct_user_item { - Some(item) => item, - None => execution_session.snapshot().and_then(|state| { - direct_project_local_message_item( - "user", - prompt, - direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id).as_deref(), - ) - }).map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?, - }; - if execution_session.newly_accepted { - if let Ok(ledger) = execution_session.snapshot() { - crate::analytics::goal::accepted( - capture.clone(), - root, - &ledger.project_id, - crate::analytics::contract::Source::Direct, - ); + if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { + return Ok(report); } - } - emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); - if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("preparing"), None, None); - } - // 本回合累积的工具调用条目:观察者增量采集,回合结束时整批落盘(幂等 upsert)。 - // 实时下发与落盘共用同一份数据,避免两处各采集一次产生口径差。 - let tool_calls: DirectToolCallCollector = Arc::new(Mutex::new(Vec::new())); - let stream_enabled = load_game_creator_app_config() - .map(|config| config.llm.stream) - .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - error) - })?; - let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let base_system_prompt = - build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - error), - )?; - // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, - // 避免被长度上限截断,也不阻断任何工具。 - let engine_contract = direct_engine_three_dimensional_contract(root, prompt); - let system_prompt = match engine_contract.as_deref() { - Some(contract) => format!("{contract}\n{base_system_prompt}") - .chars() - .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) - .collect(), - None => base_system_prompt, - }; - let reply = if let Some(emitter) = turn_emitter { - let client_turn_id = emitter.turn_id().to_string(); - let emitter = emitter.clone(); - let turn_root = root.to_path_buf(); - let turn_tool_calls = Arc::clone(&tool_calls); - // 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。 - let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone()); - let mut stream_writes = Vec::new(); - let mut observer = |observation: DirectCodexTurnObservation| { - let status = direct_codex_observation_status(&observation, stream_enabled); - match observation { - DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { - let visible_text = - project_direct_codex_accumulated_text(stream_enabled, &accumulated_text); - if visible_text.is_none() { - return; - } - emitter.emit(status, None, visible_text, None); - } - DirectCodexTurnObservation::AgentMessageSegment { - item_id, - accumulated_text, - completed, - } => { - // 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。 - let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text) - else { - return; - }; - let items = stream_writer.push_text( - &turn_root, - &item_id, - &visible_text, - direct_tool_call_now_ms(), - completed, - ); - for item in &items { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); - } - if !items.is_empty() { - emitter.emit_with_stream_items(status, None, None, None, items); - } - } - DirectCodexTurnObservation::IntermediateText(intermediate_text) => { - let visible_text = if stream_enabled - || is_direct_codex_item_started_work_detail(&intermediate_text) - { - project_direct_codex_visible_text(&intermediate_text) - } else { - None - }; - if let Some(visible_text) = visible_text { - emitter.emit(status, None, Some(visible_text), None); - } - } - DirectCodexTurnObservation::Activity(activity) => { - emitter.emit(status, Some(activity), None, None); - } - DirectCodexTurnObservation::Reasoning(reasoning) => { - // 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running: - // streaming 已被"用户可见正文"占用。 - emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); - } - DirectCodexTurnObservation::ToolCall(mut tool_call) => { - // 同一工具的开始、完成和详情补全共用一份单调快照。 - { - let collected = lock_direct_tool_call_collector(&turn_tool_calls); - let existing = collected - .iter() - .find(|existing| existing.id == tool_call.id); - if existing.is_some_and(|call| { - call.status != "running" && tool_call.status == "running" - }) { - return; - } - if !super::direct_tool_calls::direct_tool_call_status_changed( - existing, &tool_call, - ) { - return; - } - if let Some(existing) = existing { - tool_call.updated_at = tool_call - .updated_at - .max(existing.updated_at.saturating_add(1)); - tool_call = super::direct_tool_calls::merge_tool_call_snapshot( - existing, &tool_call, - ); - } - } - { - let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); - collected.retain(|existing| existing.id != tool_call.id); - collected.push(tool_call.clone()); - } - // 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。 - let mut items = stream_writer - .take_pending_snapshot() - .into_iter() - .collect::>(); - items.push(stream_writer.push_tool(&tool_call, direct_tool_call_now_ms())); - for item in &items { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); - } - emitter.emit_with_stream_items( - status, - None, - None, - Some(vec![tool_call.clone()]), - items, - ); - // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 - spawn_persist_direct_tool_call(&turn_root, &tool_call); - } - } + // CLI 没有结构化条目;在进入反馈循环前固定原始消息,避免后续反馈以同一 ID + // 写成内容不同的用户消息。GUI 则保留原有的 Skill、资源及附件引用。 + let direct_user_item = match direct_user_item { + Some(item) => item, + None => execution_session + .snapshot() + .and_then(|state| { + direct_project_local_message_item( + "user", + prompt, + direct_codex_user_item_id_for_client_turn_id(&state.client_turn_id) + .as_deref(), + ) + }) + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?, }; - let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; - let mut attempt = 1; - let reply_result = loop { - let result = direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt.clone(), - feedback_prompt.clone(), - turn_kind, - Some(&client_turn_id), - Some(&mut observer), - Some(direct_user_item.clone()), - ) - .await; - turn_kind = DirectCodexTurnKind::HostFeedback; - match result { - Ok(value) => match super::direct_delivery::review_reply(root, &execution_session).await { - Ok(Some(report)) => break Ok(report), - Ok(None) => break Ok(value), - // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; - // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), - // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 - Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, - ) => { - emitter.emit("running",Some("host-review"),Some("宿主复核发现必需证据尚未齐备,正在按冻结范围继续处理".into()),None); - feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); - } - Err(error) => break Err(error), + if execution_session.newly_accepted { + if let Ok(ledger) = execution_session.snapshot() { + crate::analytics::goal::accepted( + capture.clone(), + root, + &ledger.project_id, + crate::analytics::contract::Source::Direct, + ); + } + } + emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); + if let Some(emitter) = turn_emitter { + emitter.emit("running", Some("preparing")); + } + let stream_enabled = load_game_creator_app_config() + .map(|config| config.llm.stream) + .map_err(|error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) + })?; + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let base_system_prompt = + build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( + |error| { + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) }, - // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, - // 第二次拿到 `None` 时还会把空串当成回复返回。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) { - Some(report) => break Ok(report), - None - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - emitter.emit( - "running", - Some("error-feedback"), - Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), - None, + )?; + // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, + // 避免被长度上限截断,也不阻断任何工具。 + let engine_contract = direct_engine_three_dimensional_contract(root, prompt); + let system_prompt = match engine_contract.as_deref() { + Some(contract) => format!("{contract}\n{base_system_prompt}") + .chars() + .take(MAX_DIRECT_SYSTEM_PROMPT_CHARS) + .collect(), + None => base_system_prompt, + }; + let reply = if let Some(emitter) = turn_emitter { + let client_turn_id = emitter.turn_id().to_string(); + let emitter = emitter.clone(); + let mut observer = |observation: DirectCodexTurnObservation| { + let status = direct_codex_observation_status(&observation, stream_enabled); + match observation { + DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + let visible_text = project_direct_codex_accumulated_text( + stream_enabled, + &accumulated_text, ); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); - } - // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 - None => break Err(error), - }, - } - }; - drop(observer); - if let Some(item) = stream_writer.take_pending_snapshot() { - stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item)); - emitter.emit_with_stream_items("streaming", None, None, None, vec![item]); - } - // finalize 必须看见这一轮全部快照,不能与 fire-and-forget 写任务竞争。 - for write in stream_writes { - if !matches!(write.await, Ok(Ok(()))) { - app_log!("[turn-stream] 回合快照持久化失败"); - } - } - reply_result - } else { - let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; - let mut attempt = 1; - let response = loop { - let result = direct_game_creator_codex_chat_at_with_optional_observer( - root, - system_prompt.clone(), - feedback_prompt.clone(), - turn_kind, - None, - None, - Some(direct_user_item.clone()), - ) - .await; - turn_kind = DirectCodexTurnKind::HostFeedback; - match result { - Ok(value) => { - match super::direct_delivery::review_reply(root, &execution_session).await { - Ok(Some(report)) => break Some(report), - Ok(None) => break Some(value), - // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 - // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; - // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 - Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, - ) => { - feedback_prompt = format!(prompt_text!("direct.deliveryFeedback"),detail=detail); + if visible_text.is_none() { + return; } - // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 - Err(error) => return Err(error), + emitter.emit(status, None); + } + DirectCodexTurnObservation::AgentMessageSegment(accumulated_text) => { + if project_direct_codex_visible_text(&accumulated_text).is_none() { + return; + } + emitter.emit(status, None); + } + DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + let is_visible = stream_enabled + || is_direct_codex_item_started_work_detail(&intermediate_text); + if is_visible + && project_direct_codex_visible_text(&intermediate_text).is_some() + { + emitter.emit(status, None); + } + } + DirectCodexTurnObservation::Activity(activity) => { + emitter.emit(status, Some(activity)); + } + DirectCodexTurnObservation::Reasoning(_) => { + // 思考增量只把活动回合标成 running;正文由运行态事件投影下发。 + emitter.emit("running", None); } } - // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` - // 时还会绕过下面那条"未返回结果"的兜底错误)。 - Err(error) => match super::direct_delivery::terminal_report(&execution_session) { - Some(report) => break Some(report), - None - if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS - && error.is_model_repairable() => - { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); - attempt += 1; - feedback_prompt = direct_codex_error_feedback_prompt(&detail); + }; + let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; + let mut attempt = 1; + let reply_result = loop { + let result = direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + turn_kind, + Some(&client_turn_id), + Some(&mut observer), + Some(direct_user_item.clone()), + ) + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { + Ok(value) => { + match super::direct_delivery::review_reply(root, &execution_session).await { + Ok(Some(report)) => break Ok(report), + Ok(None) => break Ok(value), + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), + // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { + emitter.emit("running", Some("host-review")); + feedback_prompt = format!( + prompt_text!("direct.deliveryFeedback"), + detail = detail + ); + } + Err(error) => break Err(error), + } } - None => return Err(error), - }, - } - }; - response.ok_or_else(|| { + // 交付报告的兜底只读一次:guard 与取值各调一次会在两次之间换出不同结果, + // 第二次拿到 `None` 时还会把空串当成回复返回。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) + { + Some(report) => break Ok(report), + None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + emitter.emit("running", Some("error-feedback")); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + None => break Err(error), + }, + } + }; + drop(observer); + reply_result + } else { + let mut feedback_prompt = prompt.to_string(); + let mut turn_kind = DirectCodexTurnKind::User; + let mut attempt = 1; + let response = loop { + let result = direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt.clone(), + feedback_prompt.clone(), + turn_kind, + None, + None, + Some(direct_user_item.clone()), + ) + .await; + turn_kind = DirectCodexTurnKind::HostFeedback; + match result { + Ok(value) => { + match super::direct_delivery::review_reply(root, &execution_session).await { + Ok(Some(report)) => break Some(report), + Ok(None) => break Some(value), + // 返修要求是控制流,不是失败:把要求写回 prompt 再跑一轮。 + // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; + // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 + Err( + DirectTurnError::ReviewRequired { detail } + | DirectTurnError::RepairRequired { detail }, + ) => { + feedback_prompt = format!( + prompt_text!("direct.deliveryFeedback"), + detail = detail + ); + } + // 回合级失败原样带出:typed 分类决定诊断摘要与建议,不再降级成文本。 + Err(error) => return Err(error), + } + } + // 同上:交付报告只读一次,避免两次调用之间换出不同结果(第二次拿到 `None` + // 时还会绕过下面那条"未返回结果"的兜底错误)。 + Err(error) => match super::direct_delivery::terminal_report(&execution_session) + { + Some(report) => break Some(report), + None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS + && error.is_model_repairable() => + { + let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail); + } + None => return Err(error), + }, + } + }; + response.ok_or_else(|| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::CodeGeneration, + "陶泥儿错误反馈回合未返回结果", + ) + }) + }?; + let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, - "陶泥儿错误反馈回合未返回结果", + "陶泥儿未返回可展示的回复".to_string(), ) - }) - }?; - // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 - // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 - persist_collected_direct_tool_calls(root, &tool_calls); - let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "陶泥儿未返回可展示的回复".to_string(), - ) - })?; - if let Some(emitter) = turn_emitter { - // 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。 - let finalized = - finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply) - .ok() - .flatten() - .into_iter() - .collect::>(); - emitter.emit_with_stream_items( - "finalizing", - Some("response-finalization"), - Some(visible_reply.clone()), - None, - finalized, - ); - } - if direct_codex_output_fingerprint(root) != previous_output_fingerprint { - emit_direct_game_creator_progress( - root, - "project.sync", - "检测到游戏文件更新,正在同步客户端资源", - ); + })?; if let Some(emitter) = turn_emitter { - emitter.emit( - "finalizing", - Some("file-write"), - Some(visible_reply.clone()), - None, - ); + emitter.emit("finalizing", Some("response-finalization")); } - sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) - .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error) - })?; + if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + emit_direct_game_creator_progress( + root, + "project.sync", + "检测到游戏文件更新,正在同步客户端资源", + ); + if let Some(emitter) = turn_emitter { + emitter.emit("finalizing", Some("file-write")); + } + sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| { + DirectTurnError::turn_failed( + DirectCodexFailureStage::VersionRegistration, + error, + ) + })?; + } + Ok(visible_reply) } - Ok(visible_reply) - }.await; + .await; if let Ok(ledger) = execution_session.snapshot() { if let (Some(metadata), Some((end_reason, error_code))) = ( &ledger.analytics_run, @@ -5046,38 +4768,6 @@ mod direct_analytics_tests { } } -#[cfg(test)] -mod direct_turn_stream_writer_tests { - use super::*; - - #[test] - fn item_switch_returns_previous_tail_and_next_head() { - let mut writer = DirectTurnStreamWriter::new("turn".into()); - let root = Path::new("."); - writer.push_text(root, "a", "前缀", 1000, false); - writer.push_text(root, "a", "完整正文", 1000, false); - let snapshots = writer.push_text(root, "b", "第二段", 1000, false); - assert_eq!(snapshots.len(), 2); - assert_eq!(snapshots[0].text.as_deref(), Some("完整正文")); - assert_eq!(snapshots[0].seq, 1); - assert_eq!(snapshots[1].seq, 2); - assert!(snapshots[1].updated_at > snapshots[0].updated_at); - } - - #[test] - fn completed_snapshot_bypasses_throttle_and_keeps_item_position() { - let mut writer = DirectTurnStreamWriter::new("turn".into()); - let root = Path::new("."); - let first = writer.push_text(root, "a", "前缀", 1000, false); - let completed = writer.push_text(root, "a", "完整正文", 1000, true); - assert_eq!(completed.len(), 1); - assert_eq!(completed[0].id, first[0].id); - assert_eq!(completed[0].seq, first[0].seq); - assert!(completed[0].updated_at > first[0].updated_at); - assert_eq!(completed[0].text.as_deref(), Some("完整正文")); - } -} - #[allow(dead_code)] async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, @@ -5822,35 +5512,6 @@ mod tests { ); } - #[test] - fn direct_turn_update_payload_is_the_exact_camel_case_contract() { - let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { - project_path: "/project".to_string(), - turn_id: "abcdef-123456".to_string(), - sequence: 7, - status: "streaming".to_string(), - activity: None, - accumulated_text: Some("partial".to_string()), - tool_calls: None, - reasoning_text: None, - stream_items: None, - updated_at: 42, - }) - .expect("serialize direct update"); - assert_eq!( - value, - serde_json::json!({ - "projectPath": "/project", - "turnId": "abcdef-123456", - "sequence": 7, - "status": "streaming", - "activity": null, - "accumulatedText": "partial", - "updatedAt": 42, - }) - ); - } - #[test] fn system_prompt_is_bounded_and_declares_direct_runtime() { let prompt = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 3295ef20d..d907d60b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -103,7 +103,7 @@ async fn enqueue_direct_codex_turn_typed( })?; // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 - let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_tool_call_now_ms()); + let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms()); match enqueue_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { 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 deleted file mode 100644 index 9d83b435d..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ /dev/null @@ -1,1197 +0,0 @@ -//! GameAgent 对话「工具调用卡片」的采集与持久化。 -//! -//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: -//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 -//! 信息,这里把它们投影成结构化的 `DirectToolCall`,落到**独立文件** -//! `/.agent/conversations/tool-calls.jsonl`。 -//! -//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 -//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 - -use super::thread_manager::wire::sanitize_detail_text; -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; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 -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` 取最新)。 -pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; -/// `detail.command` / `detail.output` 的字符上限。 -const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; -/// 折叠态摘要(`summary`)的字符上限。 -const DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS: usize = 120; -/// 单条变更路径的字符上限。 -const DIRECT_TOOL_CALL_PATH_MAX_CHARS: usize = 300; - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCallChange { - pub(crate) path: String, - /// `add` | `update` | `delete` - pub(crate) kind: String, -} - -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCallDetail { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) output: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub(crate) changes: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectToolCall { - pub(crate) schema_version: String, - /// Codex item 的 id。同一 item 的 started/completed 共用它,落盘时按它幂等 upsert。 - pub(crate) id: String, - pub(crate) turn_id: String, - /// `command` | `file_change` | `mcp_tool` | `web_search` | `context_compaction` | `other` - pub(crate) kind: String, - /// 折叠态标题,按 kind 固定(`command` → `执行命令`、`file_change` → `编辑 N 个文件`)。 - pub(crate) title: String, - /// 折叠态标题后面的短摘要。 - pub(crate) summary: String, - /// `running` | `completed` | `failed` - pub(crate) status: String, - pub(crate) detail: DirectToolCallDetail, - pub(crate) started_at: u64, - pub(crate) updated_at: u64, -} - -impl DirectToolCall { - fn timestamp(&self) -> u64 { - if self.updated_at > 0 { - self.updated_at - } else { - self.started_at - } - } -} - -fn tool_calls_path(root: &Path) -> PathBuf { - root.join(".agent/conversations/tool-calls.jsonl") -} - -/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 -fn bounded_chars(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 first_line_bounded(value: &str, max_chars: usize) -> String { - let first_line = value.lines().next().unwrap_or_default().trim(); - bounded_chars(first_line, max_chars) -} - -/// 把 app-server 中可能是字符串或 JSON 对象的工具详情统一转成可读文本。 -fn direct_tool_call_value_text(value: &Value) -> Option { - match value { - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - Value::Null => None, - // 调用方先脱敏再截断,不能在这里截断掉敏感字段的语法边界。 - _ => serde_json::to_string_pretty(value).ok(), - } -} - -fn tool_call_kind(item_type: &str) -> Option<&'static str> { - match item_type { - "commandExecution" => Some("command"), - "fileChange" => Some("file_change"), - "mcpToolCall" => Some("mcp_tool"), - "webSearch" => Some("web_search"), - "contextCompaction" => Some("context_compaction"), - // todoList / reasoning / plan / agentMessage 之类不属于「工具调用」,不落卡片。 - "todoList" | "reasoning" | "plan" | "agentMessage" | "message" | "userMessage" => None, - _ => Some("other"), - } -} - -fn direct_tool_call_changes(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(DirectToolCallChange { - path: bounded_chars(path, DIRECT_TOOL_CALL_PATH_MAX_CHARS), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> String { - match kind { - "command" => "执行命令".to_string(), - "file_change" => { - let mut paths = changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - paths.sort_unstable(); - paths.dedup(); - if paths.is_empty() { - "编辑文件".to_string() - } else { - format!("编辑 {} 个文件", paths.len()) - } - } - "mcp_tool" => "调用工具".to_string(), - "web_search" => "搜索资料".to_string(), - "context_compaction" => "整理上下文".to_string(), - _ => "调用工具".to_string(), - } -} - -fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { - // item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为 - // `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。 - if let Some(status) = item.get("status").and_then(Value::as_str) { - match status { - "completed" => return "completed", - "failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed", - _ => {} - } - } - // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 - if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { - return if exit_code == 0 { - "completed" - } else { - "failed" - }; - } - if let Some(success) = item.get("success").and_then(Value::as_bool) { - return if success { "completed" } else { "failed" }; - } - if completed { - "completed" - } else { - "running" - } -} - -/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新。 -pub(crate) fn direct_tool_call_status_changed( - existing: Option<&DirectToolCall>, - incoming: &DirectToolCall, -) -> bool { - !existing.is_some_and(|current| { - current.status == incoming.status - && current.detail == incoming.detail - && current.title == incoming.title - && current.summary == incoming.summary - }) -} - -/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 -/// -/// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, -/// 两处都没有时才用调用方给的回退值(本机毫秒时间戳)。 -pub(crate) fn direct_tool_call_from_item( - root: &Path, - item: &Value, - turn_id: &str, - completed: bool, - now_ms: u64, -) -> Option { - let item_type = item.get("type").and_then(Value::as_str)?; - let kind = tool_call_kind(item_type)?; - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|id| !id.is_empty())?; - - let item_started_at = item - .get("startedAtMs") - .and_then(Value::as_u64) - .unwrap_or_default(); - let item_completed_at = item - .get("completedAtMs") - .and_then(Value::as_u64) - .unwrap_or_default(); - let started_at = if item_started_at > 0 { - item_started_at - } else if item_completed_at > 0 { - item_completed_at - } else { - now_ms - }; - let updated_at = if item_completed_at > 0 { - item_completed_at - } else { - started_at.max(now_ms) - }; - - let command = item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| sanitize_detail_text(root, command)) - .map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) - .or_else(|| { - item.get("arguments") - .and_then(direct_tool_call_value_text) - .map(|arguments| sanitize_detail_text(root, &arguments)) - .map(|arguments| bounded_chars(&arguments, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) - }); - let output = ["aggregatedOutput", "output", "result", "error"] - .iter() - .find_map(|key| item.get(key).and_then(direct_tool_call_value_text)) - .map(|output| sanitize_detail_text(root, &output)) - .map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); - // `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 ``, - // 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。 - let changes = direct_tool_call_changes(item) - .into_iter() - .map(|change| DirectToolCallChange { - path: sanitize_detail_text(root, &change.path), - kind: change.kind, - }) - .collect::>(); - - let tool = item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)); - // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 - let summary_source = (if kind == "mcp_tool" { - tool.as_deref() - } else { - None - }) - .or(command.as_deref()) - .or_else(|| changes.first().map(|change| change.path.as_str())) - .or(tool.as_deref()) - .unwrap_or_default(); - let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); - - Some(DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: id.to_string(), - turn_id: turn_id.trim().to_string(), - kind: kind.to_string(), - title: direct_tool_call_title(kind, &changes), - summary, - status: direct_tool_call_status(item, completed).to_string(), - detail: DirectToolCallDetail { - command, - output, - changes, - }, - started_at, - updated_at, - }) -} - -fn record_line(call: &DirectToolCall) -> Result { - serde_json::to_string(&serde_json::json!({ - "type": DIRECT_TOOL_CALL_RECORD_TYPE, - "payload": call, - })) - .map_err(|error| format!("序列化工具调用条目失败:{error}")) -} - -/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 -fn tool_call_from_line(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - let parsed: Value = serde_json::from_str(trimmed).ok()?; - if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TOOL_CALL_RECORD_TYPE) { - return None; - } - let payload = parsed.get("payload")?; - let mut call: DirectToolCall = serde_json::from_value(payload.clone()).ok()?; - if call.id.trim().is_empty() { - return None; - } - if call.schema_version.trim().is_empty() { - call.schema_version = DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(); - } - Some(call) -} - -fn read_tool_call_lines(path: &Path) -> Vec { - let Ok(file) = File::open(path) else { - return Vec::new(); - }; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut calls = Vec::new(); - loop { - buffer.clear(); - match reader.read_until(b'\n', &mut buffer) { - Ok(0) => break, - // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行; - // 契约要求「单行损坏跳过该行继续」,不能把后续记录一起丢掉。 - Ok(_) => match std::str::from_utf8(&buffer) { - Ok(line) => { - if let Some(call) = tool_call_from_line(line) { - calls.push(call); - } - } - Err(_) => continue, - }, - // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 - Err(_) => break, - } - } - calls -} - -/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近 -/// `DIRECT_TOOL_CALL_LIMIT` 条。 -fn normalize_tool_calls(calls: Vec) -> Vec { - let mut by_id: BTreeMap = BTreeMap::new(); - for call in calls { - let merged = match by_id.remove(&call.id) { - Some(previous) => merge_tool_call_snapshot(&previous, &call), - None => call, - }; - by_id.insert(merged.id.clone(), merged); - } - let mut normalized = by_id.into_values().collect::>(); - normalized.sort_by(|left, right| { - left.timestamp() - .cmp(&right.timestamp()) - .then_with(|| left.id.cmp(&right.id)) - }); - if normalized.len() > DIRECT_TOOL_CALL_LIMIT { - normalized.drain(..normalized.len() - DIRECT_TOOL_CALL_LIMIT); - } - normalized -} - -/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 -fn status_certainty(status: &str) -> u8 { - match status { - "completed" | "failed" => 1, - _ => 0, - } -} - -/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。 -/// -/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`, -/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 -/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里 -/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 -/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 -/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 -pub(crate) fn merge_tool_call_snapshot( - existing: &DirectToolCall, - incoming: &DirectToolCall, -) -> DirectToolCall { - let take_incoming = incoming.updated_at > existing.updated_at - || (incoming.updated_at == existing.updated_at - && status_certainty(&incoming.status) > status_certainty(&existing.status)); - let mut merged = if take_incoming { - incoming.clone() - } else { - existing.clone() - }; - if merged.detail.command.is_none() { - merged.detail.command = existing - .detail - .command - .clone() - .or(incoming.detail.command.clone()); - } - if merged.detail.output.is_none() { - merged.detail.output = existing - .detail - .output - .clone() - .or(incoming.detail.output.clone()); - } - merged.started_at = [merged.started_at, existing.started_at, incoming.started_at] - .into_iter() - .filter(|started_at| *started_at > 0) - .min() - .unwrap_or_default(); - merged -} - -/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。 -/// -/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 -/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; -/// 文件规模由 200 条上限与 4000 字符截断兜住。 -fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<(), String> { - let path = tool_calls_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("工具调用历史写入")?; - let mut calls = read_tool_call_lines(&path); - let existing = calls - .iter() - .find(|existing| existing.id == call.id) - .cloned(); - let incoming = match existing.as_ref() { - Some(existing) => merge_tool_call_snapshot(existing, call), - None => call.clone(), - }; - calls.retain(|existing| existing.id != incoming.id); - calls.push(incoming); - let normalized = normalize_tool_calls(calls); - let mut body = String::new(); - for existing in &normalized { - body.push_str(&record_line(existing)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") -} - -/// 落盘入口。失败不抛给调用方以外的地方——工具调用是展示数据,不能因为它把整轮判失败。 -pub(crate) fn persist_direct_tool_call_at( - root: &Path, - call: &DirectToolCall, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - upsert_direct_tool_call_once(root, call) -} - -/// 一轮结束时把本回合累积的工具调用整批落盘(一次锁、一次重写)。 -pub(crate) fn persist_direct_tool_calls_at( - root: &Path, - calls: &[DirectToolCall], -) -> Result<(), String> { - if calls.is_empty() { - return Ok(()); - } - enforce_project_permission_policy(root, "conversation.write")?; - let path = tool_calls_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("工具调用历史写入")?; - let mut existing = read_tool_call_lines(&path); - let mut incoming = calls.to_vec(); - for call in incoming.iter_mut() { - let merged = existing - .iter() - .find(|row| row.id == call.id) - .map(|previous| merge_tool_call_snapshot(previous, call)); - if let Some(merged) = merged { - *call = merged; - } - } - let ids = incoming - .iter() - .map(|call| call.id.as_str()) - .collect::>(); - existing.retain(|call| !ids.contains(&call.id.as_str())); - existing.extend(incoming); - let normalized = normalize_tool_calls(existing); - let mut body = String::new(); - for call in &normalized { - body.push_str(&record_line(call)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") -} - -/// 本机毫秒时间戳(item 没带时间时用)。 -pub(crate) fn direct_tool_call_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64 -} - -#[cfg(test)] -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_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 { - serde_json::to_string(&json!({ - "type": "tool_call_item", - "payload": { - "schemaVersion": "agc-tool-call.v1", - "id": id, - "turnId": "turn-1", - "kind": "command", - "title": "执行命令", - "summary": "npm run build", - "status": "completed", - "detail": {"command": "npm run build"}, - "startedAt": started_at, - "updatedAt": updated_at - } - })) - .expect("serialize tool call row") - } - - fn init_tool_call_project(name: &str) -> tempfile::TempDir { - let root = tempfile::tempdir().expect("temp project"); - crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") - .expect("init project"); - root - } - - fn command_item(id: &str, command: &str) -> serde_json::Value { - json!({ - "id": id, - "type": "commandExecution", - "command": command, - "status": "inProgress", - "startedAtMs": 1000, - }) - } - - /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 - fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall { - DirectToolCall { - schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), - id: id.to_string(), - turn_id: "turn-1".to_string(), - kind: "command".to_string(), - title: "执行命令".to_string(), - summary: "npm run build".to_string(), - status: status.to_string(), - detail: DirectToolCallDetail::default(), - started_at: 1, - updated_at, - } - } - - #[test] - fn tool_call_status_change_is_detected_only_on_real_changes() { - let running = sample_tool_call("call-1", "running", 1); - let completed = sample_tool_call("call-1", "completed", 2); - - assert!( - direct_tool_call_status_changed(None, &running), - "首次观察必须被收集" - ); - assert!( - !direct_tool_call_status_changed(Some(&running), &running), - "状态没变时不该重复下发同一份快照" - ); - assert!( - direct_tool_call_status_changed(Some(&running), &completed), - "running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)" - ); - } - - #[test] - fn explicit_declined_or_failed_status_is_not_reported_as_completed() { - for status in ["declined", "failed", "cancelled", "aborted"] { - let item = json!({ - "id": "call-1", - "type": "commandExecution", - "status": status, - }); - assert_eq!( - direct_tool_call_status(&item, true), - "failed", - "item 自带 {status} 时不能因为 completed=true 就被当成 completed" - ); - } - let completed = json!({ - "id": "call-1", - "type": "commandExecution", - "status": "completed", - }); - assert_eq!(direct_tool_call_status(&completed, true), "completed"); - } - - #[test] - fn tool_call_upsert_is_idempotent_per_item_id() { - let root = init_tool_call_project("tool-call-upsert"); - let started = direct_tool_call_from_item( - root.path(), - &command_item("item-1", "npm run build"), - "turn-1", - false, - 1000, - ) - .expect("started tool call"); - persist_direct_tool_call_at(root.path(), &started).expect("persist started"); - - let completed = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-1", - "type": "commandExecution", - "command": "npm run build", - "exitCode": 0, - "completedAtMs": 2000, - }), - "turn-1", - true, - 2000, - ) - .expect("completed tool call"); - persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); - - 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 覆盖"); - assert_eq!(calls[0].updated_at, 2000); - } - - /// 判据:非 0 退出码判 failed。 - #[test] - fn tool_call_marks_failed_on_non_zero_exit_code() { - let root = init_tool_call_project("tool-call-failed"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-failed", - "type": "commandExecution", - "command": "npm test", - "exitCode": 1, - "completedAtMs": 3000, - }), - "turn-1", - true, - 3000, - ) - .expect("failed tool call"); - assert_eq!(call.status, "failed"); - } - - /// 判据:command / output 截断到 4000 字符,summary 截断到 120 字符。 - #[test] - fn tool_call_truncates_command_output_and_summary() { - let root = init_tool_call_project("tool-call-truncate"); - let long_command = "a".repeat(5000); - let long_output = "b".repeat(5000); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-long", - "type": "commandExecution", - "command": long_command, - "aggregatedOutput": long_output, - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("long tool call"); - let command = call.detail.command.expect("bounded command"); - let output = call.detail.output.expect("bounded output"); - assert_eq!(command.chars().count(), 4001, "4000 字符 + 省略号"); - assert_eq!(output.chars().count(), 4001, "4000 字符 + 省略号"); - assert_eq!(call.summary.chars().count(), 121, "120 字符 + 省略号"); - } - - /// 判据:脱敏后不出现 API Key / Token / 绝对用户目录。 - #[test] - fn tool_call_redacts_secrets_and_absolute_paths() { - let root = init_tool_call_project("tool-call-redact"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-secret", - "type": "commandExecution", - "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnop' https://example.com", - "aggregatedOutput": "OPENAI_API_KEY=tnr_sk_abcdefghijklmnop /home/someuser/private/notes.txt", - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("redacted tool call"); - let command = call.detail.command.as_deref().expect("command"); - let output = call.detail.output.as_deref().expect("output"); - assert!( - !command.contains("sk-abcdefghijklmnop"), - "命令里的 API Key 必须脱敏:{command}" - ); - assert!( - !output.contains("tnr_sk_abcdefghijklmnop"), - "输出里的 Token 必须脱敏:{output}" - ); - assert!( - !output.contains("/home/someuser"), - "输出里的绝对用户目录必须脱敏:{output}" - ); - - persist_direct_tool_call_at(root.path(), &call).expect("persist redacted call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - assert!( - !raw.contains("sk-abcdefghijklmnop"), - "落盘文件里不得出现 API Key" - ); - assert!( - !raw.contains("/home/someuser"), - "落盘文件里不得出现绝对用户目录" - ); - } - - /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 - #[test] - fn tool_call_file_change_title_counts_unique_paths() { - let root = init_tool_call_project("tool-call-file-change"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-files", - "type": "fileChange", - "changes": [ - {"path": "game/src/a.ts", "kind": "update"}, - {"path": "game/src/a.ts", "kind": "update"}, - {"path": "game/src/b.ts", "kind": "add"} - ], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("file change tool call"); - assert_eq!(call.kind, "file_change"); - assert_eq!(call.title, "编辑 2 个文件"); - assert_eq!(call.summary, "game/src/a.ts"); - assert_eq!(call.detail.changes.len(), 3); - } - - /// 判据:非工具类 item 不产卡片。 - #[test] - fn tool_call_skips_non_tool_items() { - let root = init_tool_call_project("tool-call-skip"); - for item_type in ["reasoning", "agentMessage", "todoList", "plan"] { - assert!( - direct_tool_call_from_item( - root.path(), - &json!({"id": "item-x", "type": item_type}), - "turn-1", - false, - direct_tool_call_now_ms(), - ) - .is_none(), - "{item_type} 不应产出工具调用卡片" - ); - } - } - /// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。 - const CREDENTIAL_CANARIES: [&str; 5] = [ - "canary-bearer-value", - "canary-cookie-value", - "canary-api-key-value", - "canary-client-secret-value", - "canary-password-value", - ]; - - /// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` / - /// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。 - #[test] - fn tool_call_redacts_extended_credential_shapes() { - let root = init_tool_call_project("tool-call-credential-shapes"); - let command = [ - "curl -H 'Authorization: Bearer canary-bearer-value' https://example.com", - "curl -b 'Cookie: session=canary-cookie-value' https://example.com", - "curl -d api_key=canary-api-key-value https://example.com", - "curl -d client_secret=canary-client-secret-value https://example.com", - "vault login --password canary-password-value --env prod", - ] - .join("\n"); - let output = [ - "Authorization: Bearer canary-output-bearer-value", - "Cookie: session=canary-output-cookie-value", - ] - .join("\n"); - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-credentials", - "type": "commandExecution", - "command": command, - "aggregatedOutput": output, - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("credential tool call"); - - let projected_command = call.detail.command.as_deref().expect("command"); - let projected_output = call.detail.output.as_deref().expect("output"); - for canary in CREDENTIAL_CANARIES { - assert!( - !projected_command.contains(canary), - "命令投影里不得出现原始凭据 {canary}:{projected_command}" - ); - } - for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { - assert!( - !projected_output.contains(canary), - "输出投影里不得出现原始凭据 {canary}:{projected_output}" - ); - } - assert!( - !call.summary.contains("canary-bearer-value"), - "摘要取自命令首行,同样不得带原始凭据:{}", - call.summary - ); - - persist_direct_tool_call_at(root.path(), &call).expect("persist credential call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - for canary in CREDENTIAL_CANARIES { - assert!( - !raw.contains(canary), - "落盘行里不得出现原始凭据 {canary}:{raw}" - ); - } - for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { - assert!( - !raw.contains(canary), - "落盘行里不得出现原始凭据 {canary}:{raw}" - ); - } - } - - /// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。 - #[test] - fn tool_call_redaction_keeps_normal_text_and_is_idempotent() { - let root = init_tool_call_project("tool-call-redaction-idempotent"); - let sanitize = |value: &str| sanitize_detail_text(root.path(), value); - - // 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。 - let plain = "grep -n password game/src/config.ts"; - let once = sanitize(plain); - assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏"); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // 既有前缀脱敏(sk-…)不得回退。 - let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com"; - let once = sanitize(prefixed); - assert!( - !once.contains("sk-canary-prefix-key"), - "既有前缀脱敏不得回退:{once}" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换), - // 原始值随之消失,且再次脱敏结果不变。 - let with_secret = "vault login --password canary-password-value --env prod"; - let once = sanitize(with_secret); - assert!( - !once.contains("canary-password-value"), - "`--password <值>` 不得落盘明文:{once}" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - - // `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志 - // fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。 - let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod"; - let once = sanitize(placeholder); - assert_eq!( - once, "[redacted sensitive context]", - "含敏感 CLI 标志的行按既有约定整行替换" - ); - assert_eq!(sanitize(&once), once, "脱敏必须幂等"); - } - - /// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`, - /// 也不得回退 `updatedAt`;`startedAt` 仍取最早。 - #[test] - fn tool_call_persist_keeps_newest_snapshot_per_item() { - let root = init_tool_call_project("tool-call-monotonic"); - let running = direct_tool_call_from_item( - root.path(), - &command_item("item-1", "npm run build"), - "turn-1", - false, - 1000, - ) - .expect("running tool call"); - assert_eq!(running.status, "running"); - let completed = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-1", - "type": "commandExecution", - "command": "npm run build", - "exitCode": 0, - "completedAtMs": 2000, - }), - "turn-1", - true, - 2000, - ) - .expect("completed tool call"); - assert_eq!(completed.status, "completed"); - assert_eq!(completed.updated_at, 2000); - - 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 = persisted_tool_calls(root.path()); - assert_eq!(calls.len(), 1, "同一 id 只能有一行"); - assert_eq!( - calls[0].status, "completed", - "后到的旧快照不得把 completed 打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt"); - assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早"); - - // 回合末整批落盘那条路径同样不得回退。 - persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) - .expect("persist stale running batch"); - let calls = persisted_tool_calls(root.path()); - assert_eq!( - calls[0].status, "completed", - "整批落盘路径同样不得把 completed 打回 running" - ); - assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); - } - - /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 - #[test] - fn tool_call_paths_become_project_relative() { - let root = init_tool_call_project("tool-call-path-shape"); - let root_display = root.path().to_string_lossy().to_string(); - let inside = root - .path() - .join("game/src/x.ts") - .to_string_lossy() - .to_string(); - let outside = if cfg!(windows) { - r"C:\Windows\Temp\canary-outside.ts".to_string() - } else { - "/opt/canary/outside.ts".to_string() - }; - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-paths", - "type": "fileChange", - "changes": [ - {"path": inside, "kind": "update"}, - {"path": outside, "kind": "add"}, - ], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("path tool call"); - let paths = call - .detail - .changes - .iter() - .map(|change| change.path.as_str()) - .collect::>(); - assert_eq!( - paths[0], "game/src/x.ts", - "项目内绝对路径必须落成项目相对路径(不能是占位符)" - ); - assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); - assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); - - persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); - let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); - assert!( - !raw.contains(&root_display), - "落盘不得残留项目根目录:{raw}" - ); - } - - /// 判据:写前读取时单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回, - /// 否则一次截断写入会把整份工具卡片从后续重写里抹掉。 - #[test] - 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"); - - // 形态一(契约原文):合法行 + 非法字节行 + 合法行 → 读回 2 条。 - let mut bytes = Vec::new(); - bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); - bytes.push(b'\n'); - bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); - 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_tool_call_lines(&path); - assert_eq!( - calls.len(), - 2, - "非法 UTF-8 行只跳过该行,后面的合法记录必须读回" - ); - assert_eq!(calls[0].id, "item-a"); - assert_eq!(calls[1].id, "item-b"); - - // 形态二:损坏行缺换行(写入被截断),与紧随其后的记录黏成一行。 - // 此时被丢掉的只有黏连的那一行,其后的合法记录必须继续读回。 - let mut bytes = Vec::new(); - bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); - bytes.push(b'\n'); - bytes.push(0xff); - bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); - bytes.push(b'\n'); - 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_tool_call_lines(&path); - assert_eq!( - calls.len(), - 2, - "损坏行缺换行时只丢黏连的那一行,其后的合法记录必须继续读回" - ); - 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 条」,更早回合的 - /// 卡片会被静默丢弃(契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 - #[test] - fn tool_call_cap_drops_oldest_turn_cards() { - let root = init_tool_call_project("tool-call-cap-oldest"); - let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) - .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-old", - false, - 1000 + index as u64, - ) - .expect("old turn tool call") - }) - .collect::>(); - persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); - let newest = direct_tool_call_from_item( - root.path(), - &json!({ - "id": "item-newest", - "type": "commandExecution", - "command": "run newest", - "startedAtMs": 90_000, - }), - "turn-new", - false, - 90_000, - ) - .expect("newest tool call"); - persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); - - let read = persisted_tool_calls(root.path()); - assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); - assert_eq!( - read.last().expect("last").id, - "item-newest", - "最新回合的卡片必须在" - ); - assert_eq!( - read.first().expect("first").id, - "item-0001", - "最旧回合的卡片被静默丢弃(老回合卡片会消失)" - ); - assert!( - read.windows(2) - .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), - "落盘顺序必须按时间正序" - ); - } - - /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 - /// (Codex 上报的路径分隔符与盘符大小写不受我们控制)。 - #[test] - fn tool_call_paths_normalize_separators_and_case() { - let root = init_tool_call_project("tool-call-path-variants"); - let native = root.path().to_string_lossy().to_string(); - let variants = [native.replace('\\', "/"), native.to_ascii_uppercase()]; - let mut paths = Vec::new(); - for (index, variant) in variants.into_iter().enumerate() { - let call = direct_tool_call_from_item( - root.path(), - &json!({ - "id": format!("item-path-variant-{index}"), - "type": "fileChange", - "changes": [{"path": format!("{variant}/game/src/y.ts"), "kind": "update"}], - "startedAtMs": 1000, - }), - "turn-1", - false, - 1000, - ) - .expect("variant path tool call"); - paths.push(call.detail.changes[0].path.clone()); - } - assert_eq!(paths[0], "game/src/y.ts", "`/` 写法同样要落成项目相对路径"); - assert_eq!( - paths[1], "game/src/y.ts", - "大小写变体同样要落成项目相对路径" - ); - } -} 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 deleted file mode 100644 index dcdc5829b..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! GameAgent 对话「回合流」的采集与持久化。 -//! -//! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** -//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 -//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。 -//! -//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的 -//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到 -//! 旧文本前面"——渲染顺序只由 `seq` 决定。 -//! -//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 - -use crate::agent::sanitize_detail_text; -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; -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::{Path, PathBuf}; - -/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 -pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item"; -/// 条目 schema 版本。 -pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1"; -/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。 -pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400; -/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。 -const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000; -/// 没有流式分段时,最终回复那一段的固定 item id。 -const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final"; -/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。 -pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure"; - -/// 文本段。 -pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text"; -/// 工具调用的位置标记。 -pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool"; - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectTurnStreamItem { - pub(crate) schema_version: String, - /// 幂等身份:文本段 `text::`、工具 `tool::`。 - pub(crate) id: String, - pub(crate) turn_id: String, - /// `text` | `tool` - pub(crate) kind: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - /// 首次出现的写入序号:**顺序真相**,同刻按它排序。 - pub(crate) seq: u64, - /// 条目首次出现的本机毫秒时刻。 - pub(crate) at: u64, - pub(crate) updated_at: u64, -} - -#[cfg(test)] -mod snapshot_tests { - use super::*; - - fn text( - turn: &str, - id: &str, - seq: u64, - at: u64, - updated: u64, - text: &str, - ) -> DirectTurnStreamItem { - direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated) - } - - #[test] - fn late_older_snapshot_cannot_undo_completed_text_or_position() { - let complete = text("turn", "item", 1, 1000, 1002, "正文"); - let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿"); - let merged = normalize_stream_items(vec![complete, late]); - assert_eq!(merged.len(), 1); - assert_eq!(merged[0].text.as_deref(), Some("正文")); - assert_eq!(merged[0].seq, 1); - assert_eq!(merged[0].at, 1000); - } - - #[test] - fn retention_does_not_treat_new_turn_seq_one_as_oldest() { - let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT) - .map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, "旧")) - .collect::>(); - snapshots.push(text("new", "one", 1, 2000, 2000, "新")); - let merged = normalize_stream_items(snapshots); - assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT); - assert_eq!(merged.last().unwrap().turn_id, "new"); - } -} - -impl DirectTurnStreamItem { - fn order_key(&self) -> (u64, u64, &str) { - (self.seq, self.at, self.id.as_str()) - } -} - -fn turn_stream_path(root: &Path) -> PathBuf { - root.join(".agent/conversations/turn-stream.jsonl") -} - -/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。 -pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String { - format!("text:{}:{}", turn_id.trim(), item_id.trim()) -} - -/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。 -pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String { - format!("tool:{}:{}", turn_id.trim(), call_id.trim()) -} - -/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。 -pub(crate) fn direct_turn_stream_text_item( - root: &Path, - turn_id: &str, - item_id: &str, - text: &str, - seq: u64, - at: u64, - updated_at: u64, -) -> DirectTurnStreamItem { - DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: direct_turn_stream_text_item_id(turn_id, item_id), - turn_id: turn_id.trim().to_string(), - kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), - text: Some(sanitize_stream_text(root, text)), - call_id: None, - seq, - at, - updated_at, - } -} - -/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。 -pub(crate) fn direct_turn_stream_tool_item( - turn_id: &str, - call: &crate::DirectToolCall, - seq: u64, - at: u64, -) -> DirectTurnStreamItem { - DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: direct_turn_stream_tool_item_id(turn_id, &call.id), - turn_id: turn_id.trim().to_string(), - kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(), - text: None, - call_id: Some(call.id.trim().to_string()), - seq, - at, - updated_at: call.updated_at, - } -} - -/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。 -pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String { - let sanitized = sanitize_detail_text(root, text); - if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS { - return sanitized; - } - let mut truncated = sanitized - .chars() - .take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS) - .collect::(); - truncated.push('…'); - truncated -} - -fn record_line(item: &DirectTurnStreamItem) -> Result { - serde_json::to_string(&serde_json::json!({ - "type": DIRECT_TURN_STREAM_RECORD_TYPE, - "payload": item, - })) - .map_err(|error| format!("序列化回合流条目失败:{error}")) -} - -/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 -fn stream_item_from_line(line: &str) -> Option { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - let parsed: Value = serde_json::from_str(trimmed).ok()?; - if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) { - return None; - } - let payload = parsed.get("payload")?; - let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?; - if item.id.trim().is_empty() || item.turn_id.trim().is_empty() { - return None; - } - if !matches!( - item.kind.as_str(), - DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL - ) { - return None; - } - if item.schema_version.trim().is_empty() { - item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(); - } - Some(item) -} - -fn read_stream_lines(path: &Path) -> Vec { - let Ok(file) = File::open(path) else { - return Vec::new(); - }; - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut items = Vec::new(); - loop { - buffer.clear(); - match reader.read_until(b'\n', &mut buffer) { - Ok(0) => break, - // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。 - Ok(_) => match std::str::from_utf8(&buffer) { - Ok(line) => { - if let Some(item) = stream_item_from_line(line) { - items.push(item); - } - } - Err(_) => continue, - }, - // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 - Err(_) => break, - } - } - items -} - -/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零, -/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。 -fn merge_stream_snapshot( - existing: &DirectTurnStreamItem, - incoming: &DirectTurnStreamItem, -) -> DirectTurnStreamItem { - let text_len = |item: &DirectTurnStreamItem| { - item.text - .as_deref() - .map(str::chars) - .map(Iterator::count) - .unwrap_or_default() - }; - // writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。 - let take_incoming = incoming.updated_at > existing.updated_at - || (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing)); - let mut merged = existing.clone(); - if take_incoming { - merged.text = incoming.text.clone(); - } - merged.updated_at = merged.updated_at.max(incoming.updated_at); - if merged.call_id.is_none() { - merged.call_id = incoming.call_id.clone(); - } - merged.seq = merged.seq.min(incoming.seq); - merged.at = [merged.at, incoming.at] - .into_iter() - .filter(|at| *at > 0) - .min() - .unwrap_or_default(); - merged -} - -/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。 -fn normalize_stream_items(items: Vec) -> Vec { - let mut by_id: BTreeMap = BTreeMap::new(); - for item in items { - let merged = match by_id.remove(&item.id) { - Some(previous) => merge_stream_snapshot(&previous, &item), - None => item, - }; - by_id.insert(merged.id.clone(), merged); - } - let mut normalized = by_id.into_values().collect::>(); - let mut turn_starts = BTreeMap::::new(); - for item in &normalized { - turn_starts - .entry(item.turn_id.clone()) - .and_modify(|at| *at = (*at).min(item.at)) - .or_insert(item.at); - } - normalized.sort_by(|left, right| { - (turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&( - turn_starts[&right.turn_id], - &right.turn_id, - right.order_key(), - )) - }); - if normalized.len() > DIRECT_TURN_STREAM_LIMIT { - normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT); - } - normalized -} - -/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。 -/// 文件规模由 400 条上限与 8000 字符截断兜住。 -fn with_locked_stream_items( - root: &Path, - mutate: impl FnOnce(&mut Vec) -> T, -) -> Result { - let path = turn_stream_path(root); - let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "conversation.write", - )?; - let lock = project_append_lock_for(&path)?; - let _append_guard = lock.lock("回合流写入")?; - let mut items = read_stream_lines(&path); - let outcome = mutate(&mut items); - let normalized = normalize_stream_items(items); - let mut body = String::new(); - for item in &normalized { - body.push_str(&record_line(item)?); - body.push('\n'); - } - write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?; - Ok(outcome) -} - -/// 幂等 upsert 一条回合流条目。 -/// -/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置, -/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。 -pub(crate) fn upsert_direct_turn_stream_item_at( - root: &Path, - item: &DirectTurnStreamItem, -) -> Result<(), String> { - enforce_project_permission_policy(root, "conversation.write")?; - with_locked_stream_items(root, |items| { - // normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。 - items.push(item.clone()); - }) -} - -/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 -/// -/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 -/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。 -pub(crate) fn append_direct_turn_stream_text_at( - root: &Path, - turn_id: &str, - item_id: &str, - text: &str, -) -> Result, String> { - let turn_id = turn_id.trim(); - let text = text.trim(); - if turn_id.is_empty() || text.is_empty() { - return Ok(None); - } - let sanitized = sanitize_stream_text(root, text); - let item_id = item_id.trim(); - enforce_project_permission_policy(root, "conversation.write")?; - let now = crate::agent::direct_tool_call_now_ms(); - with_locked_stream_items(root, |items| { - let existing_id = direct_turn_stream_text_item_id(turn_id, item_id); - if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) { - // 位置不动:只替换文本与 updatedAt。 - existing.text = Some(sanitized.clone()); - existing.updated_at = now.max(existing.updated_at); - return Some(existing.clone()); - } - // 首次出现:位置钉在末尾(当前最大 seq + 1)。 - let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1; - let item = DirectTurnStreamItem { - schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), - id: existing_id, - turn_id: turn_id.to_string(), - kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), - text: Some(sanitized), - call_id: None, - seq: next_seq, - at: now, - updated_at: now, - }; - items.push(item.clone()); - Some(item) - }) -} - -/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。 -pub(crate) fn finalize_direct_turn_stream_reply_at( - root: &Path, - turn_id: &str, - visible_reply: &str, -) -> Result, String> { - let turn_id = turn_id.trim(); - if turn_id.is_empty() || visible_reply.trim().is_empty() { - return Ok(None); - } - // 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。 - let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply) - .unwrap_or_else(|| visible_reply.trim().to_string()); - let visible_reply = visible_reply.as_str(); - enforce_project_permission_policy(root, "conversation.write")?; - let now = crate::agent::direct_tool_call_now_ms(); - with_locked_stream_items(root, |items| { - if items - .iter() - .any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT) - { - None - } else { - let next_seq = items - .iter() - .filter(|item| item.turn_id == turn_id) - .map(|item| item.seq) - .max() - .unwrap_or(0) - + 1; - let item = direct_turn_stream_text_item( - root, - turn_id, - DIRECT_TURN_STREAM_FINAL_ITEM_ID, - visible_reply, - next_seq, - now, - now, - ); - items.push(item.clone()); - Some(item) - } - }) -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index c7c523cee..be807ca6c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -61,7 +61,6 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag #[derive(Clone)] pub(crate) struct DirectGameCreatorTurnUpdateEmitter { - project_path: String, /// Thread Manager 的线程身份:进度只回填到"这一轮仍被占用"的那一格上。 thread_id: String, turn_id: String, @@ -71,74 +70,14 @@ pub(crate) struct DirectGameCreatorTurnUpdateEmitter { impl DirectGameCreatorTurnUpdateEmitter { pub(crate) fn new(root: &Path, turn_id: String) -> Self { Self { - project_path: root.to_string_lossy().into_owned(), thread_id: crate::agent::thread_id_for_project(root), turn_id, sequence: Arc::new(AtomicU64::new(0)), } } - pub(crate) fn emit( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - ) { - self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None); - } - - /// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。 - pub(crate) fn emit_with_reasoning( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - reasoning_text: Option, - ) { - self.emit_full( - status, - activity, - accumulated_text, - tool_calls, - reasoning_text, - Vec::new(), - ); - } - - /// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。 - /// - /// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据, - /// 不能在前端各算一套顺序。 - pub(crate) fn emit_with_stream_items( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - stream_items: Vec, - ) { - self.emit_full( - status, - activity, - accumulated_text, - tool_calls, - None, - stream_items, - ); - } - - #[allow(clippy::too_many_arguments)] - fn emit_full( - &self, - status: &'static str, - activity: Option<&'static str>, - accumulated_text: Option, - tool_calls: Option>, - reasoning_text: Option, - stream_items: Vec, - ) { + /// 回填这一轮逻辑回合的进度(状态 / 活动 / 序号);首页「运行中的项目」快照读它。 + pub(crate) fn emit(&self, status: &'static str, activity: Option<&'static str>) { let status_is_allowed = matches!( status, "accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed" @@ -155,6 +94,8 @@ impl DirectGameCreatorTurnUpdateEmitter { | "controlled-tool" | "web-search" | "context-compaction" + | "host-review" + | "error-feedback" | "response-finalization" | "none" ) @@ -164,11 +105,7 @@ impl DirectGameCreatorTurnUpdateEmitter { return; } let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; - let updated_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64; + let updated_at = crate::agent::direct_now_ms(); update_active_turn( &self.thread_id, &self.turn_id, @@ -177,24 +114,6 @@ impl DirectGameCreatorTurnUpdateEmitter { sequence, updated_at, ); - let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { - return; - }; - let _ = app.emit( - "game-creator-direct-turn-update", - GameCreatorDirectTurnUpdateEvent { - project_path: self.project_path.clone(), - turn_id: self.turn_id.clone(), - sequence, - status: status.to_string(), - activity: activity.map(str::to_string), - accumulated_text, - tool_calls, - reasoning_text, - stream_items: (!stream_items.is_empty()).then_some(stream_items), - updated_at, - }, - ); } pub(crate) fn turn_id(&self) -> &str { @@ -1017,3 +936,33 @@ pub(crate) fn read_game_creator_agent_runtimes_at( .map(|agent_id| read_game_creator_agent_runtime_at(root, &agent_id)) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + /// DirectRuntime 实际传过的活动值必须都在 `emit` 白名单里:漏一个会在 debug 构建 panic、 + /// 在 release 构建静默丢掉整次进度回填(首页「运行中的项目」不再随这一轮更新)。 + #[test] + fn emit_accepts_every_activity_the_direct_runtime_passes() { + let root = std::env::temp_dir().join("agc-emit-activity-guard"); + let emitter = DirectGameCreatorTurnUpdateEmitter::new(&root, "turn-guard".to_string()); + for activity in [ + "request-accepted", + "preparing", + "file-read", + "file-write", + "game-verify", + "command-exec", + "controlled-tool", + "web-search", + "context-compaction", + "host-review", + "error-feedback", + "response-finalization", + "none", + ] { + emitter.emit("running", Some(activity)); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index 22e016807..5eabb23b6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_codex_user_item_to_prompt, direct_tool_call_now_ms, redact_agent_runtime_error, + direct_codex_user_item_to_prompt, direct_now_ms, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DirectTurnError, DirectTurnTerminal, DispatchedTurn, }; @@ -55,7 +55,7 @@ impl TurnReservation { complete_turn_if_reserved( &self.thread_id, &self.token, - terminal.event(direct_tool_call_now_ms(), self.user_item_id.as_deref()), + terminal.event(direct_now_ms(), self.user_item_id.as_deref()), ) } @@ -72,7 +72,7 @@ impl TurnReservation { })) .expect("canonical user item"), None, - direct_tool_call_now_ms(), + direct_now_ms(), ); super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); @@ -160,9 +160,9 @@ async fn run_dispatched_direct_turn( ) .await; match outcome { - Ok(reply) => { + Ok(_) => { // 深层的终态出口已经在 `run_turn` 里写出 `turn.completed`;这里只补最后一条回合更新。 - emitter.emit("completed", Some("none"), Some(reply), None); + emitter.emit("completed", Some("none")); } Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 @@ -211,7 +211,7 @@ mod tests { })) .expect("canonical user item"), None, - direct_tool_call_now_ms(), + direct_now_ms(), ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 0055d42e5..b9ee45c96 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -17,9 +17,9 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, direct_tool_call_now_ms, queue_has_room, - ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, - QueueRemovalReason, SubscriptionBootstrap, ThreadEvent, + direct_codex_user_item_id_for_client_turn_id, direct_now_ms, queue_has_room, ConsumeResult, + EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, QueueRemovalReason, + SubscriptionBootstrap, ThreadEvent, }; const DEFAULT_MAX_EVENTS: usize = 8_192; @@ -854,7 +854,7 @@ pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> Queu global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove_pending_turn(thread_id, client_turn_id, direct_tool_call_now_ms()) + .remove_pending_turn(thread_id, client_turn_id, direct_now_ms()) }; if outcome == QueueRemovalOutcome::Removed { notify_subscribers(thread_id); @@ -873,7 +873,7 @@ pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .claim_pending_turn(thread_id, direct_tool_call_now_ms()) + .claim_pending_turn(thread_id, direct_now_ms()) }; if claimed.is_some() { notify_subscribers(thread_id); @@ -954,7 +954,7 @@ pub(crate) fn release_stale_direct_turn( thread_id, expected_client_turn_id, min_age_ms, - direct_tool_call_now_ms(), + direct_now_ms(), ) }; if matches!(outcome, StaleTurnRelease::Released(_)) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs index f5d328482..9bab8b252 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs @@ -21,6 +21,15 @@ use serde_json::Value; use std::path::Path; use ts_rs::TS; +/// 宿主观测时刻:Unix 毫秒。 +pub(crate) fn direct_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + /// 正文(消息 / 思考)上限。 const THREAD_TEXT_MAX_CHARS: usize = 8_000; /// 工具明细(命令 / 参数 / 输出)上限。 @@ -676,7 +685,7 @@ fn json_ms(container: &Value, key: &str) -> Option { /// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 /// /// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, -/// 条目自带时用条目里的同名毫秒字段(`direct_tool_calls` 读的是同一处)。完成事件即使同时 +/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 /// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 /// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 pub(crate) fn thread_item_event_at_ms( 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 df1ffc84e..ea1b8dc56 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -755,29 +755,6 @@ struct GameCreatorAgentProgressEvent { message: String, } -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -struct GameCreatorDirectTurnUpdateEvent { - project_path: String, - turn_id: String, - sequence: u64, - status: String, - activity: Option, - accumulated_text: Option, - /// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。 - /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 - #[serde(skip_serializing_if = "Option::is_none")] - tool_calls: Option>, - /// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。 - #[serde(skip_serializing_if = "Option::is_none")] - reasoning_text: Option, - /// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。 - /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 - #[serde(skip_serializing_if = "Option::is_none")] - stream_items: Option>, - updated_at: u64, -} - #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index f07dd61d6..8781afa25 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -10,7 +10,7 @@ ## 背景 -AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 +AGC 项目开发聊天框当前同时从多处取数据:Direct 回合事件(实时)、另一份按顺序重建的回合投影、以及按读取期另算的工具卡片,重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 `.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 @@ -18,10 +18,10 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 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`。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/thread_manager/wire.rs`),不是"一个大结构体加一堆可空字段":`ThreadItem` 用 `itemType` 区分条目,`ThreadEvent` 用 `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`、标题、折叠摘要都由前端生成。 @@ -43,7 +43,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 思考正文以 `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 的账本没动)。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份。 - 未识别 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 状态投影(含工具卡片)。 @@ -53,13 +53,12 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( ## 备选方案与取舍 -1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +1. **保留一份"读侧已脱敏"的持久缓存**:省一次脱敏与截断,但它会成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 ## 影响 -- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定(失败时同一事件带 `failure` 载荷,不新增事件类型);缺少该事件的残留回合不得被渲染成运行中。 - 两条已知边界,都**不**在本次补路径,且已被 [`【ADR】DirectProject命令接单化-2026-09-23`](./【ADR】DirectProject命令接单化-2026-09-23.md) 取代(§3、§2):① 宿主进程被强杀(`kill -9`)时没有任何 `Drop` 会执行,但队列随进程消失,新进程的订阅 bootstrap 因此不会看到"有开始没结束",界面不会卡在忙碌态;② `turn.started` 之前的失败按发生位置分流——接单**之前**的是拒单,根本不产生回合(不写用户条目、不写失败诊断),接单**之后**的由这一轮的占用对象统一收口成 `turn.completed`,不存在"有回合却没有事件解释"的路径。 @@ -69,5 +68,5 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - **调用级拒绝**(同一 `clientTurnId` 并发复用 / 项目已有另一条回合在跑 / 权限策略拒绝 / 目录锚不定 / 输入校验 / 环境与凭据未就绪)不属于回合失败:这一轮没有开始,只把原因回给命令边界(界面出运行错误横幅),不写失败诊断、不发 `failed` 事件、不进交付报告。此前它们与回合失败混在同一层、共用同一份错误文本,现在分流只认 typed 判据。 - 「活动回合的唯一判据」约束的是**原生回合**:界面上的「本地已发出、原生还没认领」是投影的展示态(`DirectChatTurn.state = 'awaiting-start'`),由本地在途用户条目身份派生,不构成第二套原生生命周期,也不参与 `turnRunning` 的判定。 - 三层数据流、变量归属与一次发送的时序写在代码里:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts` 的模块注释;回合三态的定义与判据真值表在 `apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnPresentation.ts` 的 `DirectChatTurnState`。改判据时同步这两处与对应测试。 -- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `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/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 50a4811ea..a7948a8a4 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,20 +8,20 @@ ## 修改边界 -- 允许修改:`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 行为、审批弹层现有状态来源。 +- 允许修改:`agent/thread_manager/wire.rs`、`agent/thread_manager/mod.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、ts-rs 生成目录、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、Codex durable thread 行为、审批弹层现有状态来源;DirectRuntime 的进度事件与账本本轮不动(该账本随后已随 issue #553 整体删除)。 - 保持 `.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` 生成前端绑定。 +1. Rust 只搬运:`agent/thread_manager/wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`ThreadItem` / `ThreadEvent` / 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`),不是聊天框读路径。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`)。`list_game_creator_direct_active_turns` 是唯一的例外并有意保留:它服务首页跨页面的「运行中的项目」列表(`WorkspaceLauncher` / `directActiveTurns.ts`),不是聊天框读路径。DirectRuntime 自己的账本写入本轮保留,随后已随 issue #553 整体删除。 9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index d75871953..b2c376613 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -11,7 +11,7 @@ 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 概念,也不带任何回合身份。 +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/thread_manager/wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 ## 范围 @@ -27,7 +27,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 - 跨进程回合账本、按回合统计与持久 turn ledger。 -- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 +- DirectRuntime 自己的进度事件与该运行时的账本:本轮只切 DirectProject 聊天框的读路径;该账本(`turn-stream.jsonl` / `tool-calls.jsonl`)随后已随 issue #553 整体删除。 - 旧项目磁盘上既有投影文件的清理、迁移或回填。 - 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 @@ -40,7 +40,7 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 - 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 - 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 -- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 +- 前端聊天卡片的工具形状是 `Omit`。 - 「可显示」的判据取**前端回合反馈**:一次翻页操作连拉到「合并后聊天投影的回合数增加」为止。工具卡片与思考文本虽然能通过 `projectDirectThreadItem`,但可能整页落进已渲染回合的折叠「执行过程」,不构成用户可见反馈;口径只在 `directHistoryPaging.ts` 里实现一份,首屏与「显示更早」共用。 - 首屏切片的**新端边界**只认 `subscribe` 回执里的 `lastCompletedItemId`(含该条):回执到达之前不读首屏,也不退化成「取文件尾」;锚点缺失(订阅不可用 / 失败 / 历史为空)时才按文件尾取尾屏,手动重读保持按当前文件尾取尾屏的恢复语义。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0bf78d647..80ab2745b 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,13 @@ # 决策记录 +## 2026-09-30 退役 DirectProject 工具调用与回合流账本(issue #553) + +- 背景:`tool-calls.jsonl` / `turn-stream.jsonl` 是只写不读的账本——读命令在 2026-09-16 随聊天真相源收敛删除后,唯一"消费方"是 `game-creator-direct-turn-update` 事件,而全仓已无监听方;工具卡片的真实读路径早已是「项目对话历史 + 运行态事件」的前端投影。issue #553 还暴露了同一时期绝对路径脱敏误伤 HTML 结束标签的问题。 +- 决策:整体删除 DirectRuntime 的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入器、`DirectToolCallCollector`、回合流节流器、`DirectCodexTurnObservation::ToolCall` 与 `GameCreatorDirectTurnUpdateEvent`;`update_active_turn` 保留,首页「运行中的项目」快照不变。工具卡片只由读取期在 `agent/thread_manager/wire.rs` 脱敏、截断的历史条目投影。 +- 边界:不在运行时做磁盘清理;旧项目里可能残留的文件不迁移、不读取,历史由 Git 保存。 +- 未修(另开):`redact_absolute_path_tokens` 的无语境绝对路径扫描仍会把 HTML 结束标签、嵌套 JSON 转义里的 `/` 误判成路径,是 issue #553 的根因;本次只删死路径,未改扫描器。 +- 验证:`cargo test --bin genarrative-ai-game-creator-shell agent:: -- --test-threads=1`(929 passed / 0 failed / 5 ignored)、`npm run check:encoding`、`npm run check:doc-index`、`git diff --check`。 + ## 2026-09-30 release 渠道移除产品名与包名后缀 - 决策:`release` 渠道的正式产品名统一为 `陶泥儿`,Windows NSIS、macOS DMG / updater 归档等由 Tauri `productName` 派生的包名不再包含 `Release` 文本;`identifier=world.genarrative.ai-game-creator.release` 与 `release-win` 更新分区保持不变。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e47120989..0602a9974 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -24,7 +24,7 @@ - **处理(本次)**:`sanitize_error_context` 改为 `split_inclusive('\n')` 逐段处理、原样保留行终止符(CRLF 仍归一成 LF,私钥块整行吞掉的旧口径不变)。逐段脱敏因此与整段脱敏同形,Markdown 结构不再依赖 delta 的切法。 - **仍未修**:`/` 开头的 delta 片段被判成绝对路径(上面 ②)。要修必须把「上一个字符」带进边界判定(正文增量可从 `direct_project_history` 的累计文本取,思考增量目前没有累计器),属于跨 delta 状态的取舍,本次未一并改。 - **判据/取证**:`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell direct_thread_delta`——新增 `direct_thread_delta_sanitization_preserves_line_breaks` 钉住「逐段脱敏 == 整段脱敏」,去掉 `split_inclusive` 即红;真实文本的回归用渲染侧夹具复核(修复前 table/li/h2 全 0,修复后与整段脱敏一致:1 个 table / 3 个 th / 4 个 h2)。 -- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/direct_thread_wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 +- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/generation/prompt_context.rs`、`.../agent/thread_manager/wire.rs`、`apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts`、`#384`。 ## 2026-09-29 Game Agent 读工具被项目相对路径规则拦住 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 610b6d5aa..b47f28b1f 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -4,10 +4,10 @@ 本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `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 导出绑定)。 +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`src/view/project-development/chat/conversation/directThreadItemProjection.ts`);工具条目在读取期由 `agent/thread_manager/wire.rs` 统一脱敏与截断,不再有独立的工具调用账本或回读命令。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/thread_manager/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` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 +- DirectRuntime 曾经的 `tool-calls.jsonl` / `turn-stream.jsonl` 账本、写入器与进度事件已整体删除(2026-09-30,issue #553);本方案原先描述这些落盘与事件契约的「工具调用条目」「实时事件」「回读命令」三节一并移除,改为按事件顺序 + 历史文件顺序投影。 ## 一句话交付 @@ -44,60 +44,24 @@ - 同一用户消息从本地发送转为正式条目时必须保留原发送时间,不能因去重丢掉该时间而改用启动应答后的观测时间。回合明确结束时,即使当前 live 集合为空,也应将终态边界关联到已知的本轮用户条目;不得仅按“历史最后一项”猜测或向无关旧回合补时间。 - 生命周期事件可携带已有用户条目的 `userItemId`,用于把时间边界精确关联到同一条历史消息(不产生新的回合 ID 或持久化字段)。恢复时该关联随 Thread Manager 原事件重放;带旧用户身份的终态不能收口另一条新请求。缺少身份的旧事件不据时间猜测归属。 -## 背景与现状(已核实) +## 背景与现状(2026-09-14 立项时点) + +> 以下记录立项时点的现状,用来解释这次改造的动机;其中「工具调用不留痕」「没有结构化工具调用」等结论都已被后续实现与 2026-09-16 修订取代,**当前状态以文首修订与 ADR 为准**。 - 数据来源:Codex app-server 会推 `item/started` / `item/completed`,item 里带完整信息(`commandExecution.command`、`fileChange.changes[].path` 等)。 - 现状投影:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs` 的 `direct_codex_item_intermediate_text()`(约 791 行)把 item **压成一行中文文本**(`正在执行命令:xxx` / `正在写入文件:xxx`),经 `DirectCodexTurnObservation::IntermediateText` 下发。 -- 前端事件:`GameCreatorDirectTurnUpdateEvent`(`src/app/types.ts:1090`)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。 +- 前端事件:`GameCreatorDirectTurnUpdateEvent`(该事件已随 issue #553 删除)只有 `projectPath / turnId / sequence / status / activity / accumulatedText / updatedAt`,**没有结构化工具调用**。 - 历史持久化:`.agent/conversations/project.jsonl` 现在只写 message 条目(实测 61 条全是 message),工具调用不留痕。 - 历史回读:`read_direct_project_chat_history_at()`(`direct_project_history.rs:575`)只把 `role ∈ {user, assistant}` 且有文本的条目投影成 `LocalConversationMessageRecord`,**形状上装不下工具调用**。 - 结论:要做成卡片必须同时改「采集 → 传输 → 持久化 → 回读 → 渲染」五段,纯前端做不出来。 ## 契约(实现必须照此,不得自行改形状) -### 1. 工具调用条目(采集与持久化形状) +工具卡片的两处数据来源是 `.agent/conversations/project.jsonl` 的原始条目与 Thread Manager 的运行态事件;Rust 出口只做挑字段、脱敏、截断,卡片形状、可见性、合并与计时全部由前端投影完成。 -新增独立历史文件:`/.agent/conversations/tool-calls.jsonl`,一行一条,行信封与既有历史一致: +### 前端合并与渲染(回合唯一归属,连续工具成块) -```json -{ "type": "tool_call_item", "payload": { "schemaVersion": "agc-tool-call.v1", "id": "...", "turnId": "...", "kind": "command|file_change|mcp_tool|web_search|context_compaction|other", "title": "执行命令", "summary": "npm run build", "status": "running|completed|failed", "detail": { "command": "...", "output": "...", "changes": [{ "path": "game/src/x.ts", "kind": "add|update|delete" }] }, "startedAt": 0, "updatedAt": 0 } } -``` - -- `id`:Codex item 的 id;同一 item 的 `started` 与 `completed` 必须落成**同一条**(按 id 幂等 upsert,不允许写两行)。 -- **状态单调**:同一 `id` 的每条快照按 `updatedAt` 合并落盘——`updatedAt` 更旧的快照不得覆盖更新的 `status` 与 `updatedAt`。逐条快照落盘与回合末整批落盘两条路径会并发竞争,后到的旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`;`updatedAt` 相同时终态优先;`startedAt` 取最早的非零值(`item/completed` 不一定带 `startedAtMs`)。 -- `title` 是折叠态的一行标题,按 kind 固定:`command` → `执行命令`、`file_change` → `编辑 N 个文件`(N = changes 去重后数量)、其余见 kind 枚举。 -- `summary` 是折叠态标题后面的短摘要:命令取命令首行(截断 120 字符),`file_change` 取首个变更路径。`summary` 的每个来源(命令、变更路径、`tool`)都必须先脱敏再落盘。 -- `detail.command` 读取原生命令或 MCP `arguments`,`detail.output` 读取 `aggregatedOutput` / `output` / `result` / `error`;对象格式化为 JSON,先脱敏再各截断到 4000 字符。展开工具行分别显示“输入”“输出”。MCP 摘要保留工具名,不把 JSON 开头的 `{` 当成摘要。状态相同但详情变化也必须更新;完成快照缺少输入字段时保留开始快照的输入。 -- **路径形状**:`detail.changes[].path` 用**项目相对路径**(如 `game/src/x.ts`,分隔符统一成 `/`);项目外的绝对路径落成 `` 占位。任何情况下都不得写出项目根目录本身、用户家目录或绝对路径的原始值。 -- **必须脱敏**(落盘前统一走 `agent/direct_tool_calls.rs` 的 `sanitize_detail_text`,顺序:项目路径归一化 → `redact_absolute_path_tokens` → `redact_secret_tokens` → `sanitize_error_context`): - - 前缀型密钥沿用 `redact_secret_tokens`(`sk-…`、`tnr_sk_…`、`ghp_…`、`AKIA…`、`eyJ…` 等); - - 键值型凭据沿用 `sanitize_error_context`(= `redact_secret_tokens` + `redact_error_sensitive_assignments` + `redact_error_bearer_values` + `redact_error_config_names` 的既有组合),覆盖 `Authorization: Bearer …`、`Cookie: session=…`、`api_key=…`、`client_secret=…`、`token=…` 等形状; - - 含 `--password` / `--token` / `--secret` / `--api-key` 这类敏感 CLI 标志的行按既有 fail-closed 约定**整行**替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致;即使标志后面只是 `$VAR` 占位符也整行替换,占位符本身不会保留); - - 脱敏必须幂等(同一段文本连跑两次结果一致),且不得把未脱敏文本写进 `detail` / `summary`。 - -### 2. 实时事件(新增字段,不改既有字段语义) - -`GameCreatorDirectTurnUpdateEvent` 增加**可选**字段: - -```ts -toolCalls?: DirectTurnToolCall[] | null; -``` - -- 只有在本回合工具调用集合发生变化时才带(不要每个 heartbeat 都重发全量)。 -- 字段**可选**:老版本事件解析路径必须保持兼容(前端拿到 `undefined` 时行为与现在一致)。 -- `DirectTurnToolCall` 与上面 payload 同形(去掉 `turnId`)。 - -### 3. 落盘契约(写侧) - -DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读命令与 Rust 侧回读函数已随聊天读路径退役删除,下面的语义约束的是**写进文件的行**。 - -- **上限语义**:200 条是「按时间保留最新 200 条」。超出时更早回合的卡片会被**静默丢弃**(老回合卡片会消失),不做分页、不做历史回填;同一 `id` 的多条记录先按 `updatedAt` 合并,再按时间正序裁剪。 -- 历史文件缺失 → 返回空数组,不报错。 -- 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 - -### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - -- 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 +- 加载对话时按历史条目进入 `project.jsonl` 的原始顺序投影:用户消息、文本与工具块保持原序,连续工具合为一块、遇到文本另起一块,不按 `turnId` 重新归并、也不再有 `seq` 交替。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。 - 实时与回读共用同一投影,正文、工具和耗时不另建实时/未归属渲染出口。先在完整历史按消息身份关联,再分页;禁止按第 N 个工具回合匹配第 N 条用户消息。详情通过当前回合 `callId` 关联;同项目回读与实时增量幂等合并,切项目清空旧状态。完整合同见 [AGC 实施计划](./【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md) 的“DirectProject 回合展示唯一归属”。 - 块 DOM 与交互(对齐 Codex): @@ -146,32 +110,28 @@ DirectRuntime 写 `/.agent/conversations/tool-calls.jsonl`;回读 ## 验收判据(每条都要有可复现证据) -1. 一轮真实回合里,`tool-calls.jsonl` 里同 id 只有一行,`completed` 后 `status` 变 `completed`。 +1. 一轮真实回合里,按项目对话历史投影出的工具卡片同 id 只有一张,`completed` 后状态变 `completed`。 2. 刷新页面 / 重开项目后,工具调用卡仍在原位(不重复、不丢)。 3. 实时回合中卡片状态从 `running` 走到 `completed`。 4. 折叠/展开键盘可达(Tab 到头部按钮、Enter/Space 切换、`aria-expanded` 同步)。 -5. `Read` 到的既有对话(无工具调用)行为不变;老事件(无 `toolCalls` 字段)行为不变。 -6. 脱敏检查:`tool-calls.jsonl` 里不出现 API Key / Token / 绝对用户目录。 +5. `Read` 到的既有对话(无工具调用)行为不变。 +6. 脱敏检查:卡片摘要、输入 / 输出与展开详情的落盘内容中不出现 API Key / Token / 绝对用户目录。 ## 不做项(本次明确不做) - 不做工具调用的重试 / 取消 / 编辑按钮。 - 不做 diff 级展开(只在展开态列文件路径与变更类型)。 - 不做工具输出的完整回放、不做卡片内搜索。 -- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径(新数据走独立文件,避免污染模型上下文)。 +- 不改 `.agent/conversations/project.jsonl` 的既有格式与注入 Codex 上下文的路径。 - 不改工具调用之外的聊天渲染(markdown、气泡、滚动)。 - 不做历史工具调用的迁移回填(存量项目没有卡片,属预期)。 ## 涉及文件(实现边界) -- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server.rs`(item → 结构化采集)、`agent/direct_runtime.rs`(随事件下发 + 回合结束持久化)、`agent/direct_project_history.rs` 或新增 `agent/direct_tool_calls.rs`(upsert 与回读)、`commands.rs`(新增命令注册)。 -- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(新增字段与类型)、`App.tsx`(订阅、累加、加载时合并)、`features/agent-runtime/` 或新增 `features/project-workspace/ToolCallCard.tsx`(卡片组件)、`styles.css`(卡片样式,新样式集中在文件末尾中文注释区块)。 -- 测试:Rust 侧单元测试(upsert 幂等、状态按 `updatedAt` 单调合并、截断、脱敏覆盖与幂等性、项目相对路径形状、非法 UTF-8 损坏行跳过、200 条上限保留最新)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并、老事件兼容)。 +- Rust(`apps/ai-game-creator-shell/src-tauri/src/`):`agent/codex_app_server/mod.rs`(item → 结构化采集)、`agent/thread_manager/wire.rs`(读取期脱敏、截断与 ts-rs 导出模型)、`agent/direct_runtime/mod.rs`(运行态事件)。 +- 前端(`apps/ai-game-creator-shell/src/`):`app/types.ts`(卡片类型)、`view/project-development/chat/conversation/directThreadItemProjection.ts`(历史条目 → 卡片投影)、`view/project-development/chat/components/ToolCallGroup/`(卡片组件与文案)、`packages/shared`(共享过程样式)。 +- 测试:Rust 侧单元测试(历史条目投影、脱敏覆盖与幂等性、相对路径形状、截断)、前端 `tests/`(卡片渲染、折叠交互、重开项目后合并)。 -## 实施顺序(每步都要能独立验证) +## 实施状态 -1. Rust 采集 + 独立文件 upsert:先只落盘,单元测试证明幂等与截断。 -2. 事件字段下发 + 前端类型:老路径不受影响(回归现有 appSurface 用例)。 -3. Tauri 回读命令 + 前端加载合并:刷新后卡片存在。 -4. 卡片组件 + 样式 + 无障碍:折叠/展开 + 键盘。 -5. 真实回合联调(本地 Tauri 起一轮),截图留证。 +卡片表现层已按本方案落地;数据来源层在 2026-09-16 改为「运行态事件 + 项目对话历史」前端投影(见文首修订),原先的直连工具账本链路已随 issue #553 清理移除。