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 bf380a888..f3ca892e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,7 +14,6 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; -mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -22,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_raw_item; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -49,7 +49,6 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; -pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; @@ -57,6 +56,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_raw_item::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index a5db418f6..8b789f2e8 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 @@ -744,16 +744,18 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 -fn direct_chat_entry_payload( +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。 +/// +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 +fn direct_thread_raw_item_payload( root: &std::path::Path, item: &serde_json::Value, turn_id: Option<&str>, completed: bool, now_ms: u64, ) -> serde_json::Value { - direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) - .and_then(|entry| serde_json::to_value(entry).ok()) + direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms) + .and_then(|raw| serde_json::to_value(raw).ok()) .unwrap_or_else(|| serde_json::json!({})) } @@ -3062,7 +3064,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_payload = direct_chat_entry_payload( + let entry_payload = direct_thread_raw_item_payload( history_root, &item, Some(turn_id.as_str()), @@ -3232,7 +3234,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_chat_entry_payload( + payload: direct_thread_raw_item_payload( history_root, item, Some(turn_id.as_str()), @@ -4549,8 +4551,8 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 - let payload = direct_chat_entry_payload( + // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 + let payload = direct_thread_raw_item_payload( std::path::Path::new("."), &item, Some("turn-1"), @@ -4558,8 +4560,8 @@ mod tests { 1000, ); assert_eq!( - payload.get("kind").and_then(serde_json::Value::as_str), - Some("tool") + payload.get("itemType").and_then(serde_json::Value::as_str), + Some("mcpToolCall") ); assert_eq!( payload.get("itemId").and_then(serde_json::Value::as_str), @@ -4569,7 +4571,17 @@ mod tests { payload.get("turnId").and_then(serde_json::Value::as_str), Some("turn-1") ); - assert!(payload.get("toolCall").is_some(), "{payload}"); + // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 + assert!(payload.get("toolCall").is_none(), "{payload}"); + assert!(payload.get("title").is_none(), "{payload}"); + assert!(payload.get("summary").is_none(), "{payload}"); + assert!(payload.get("kind").is_none(), "{payload}"); + // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 + let arguments = payload + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!(!arguments.contains("\"secret\""), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs deleted file mode 100644 index a348cddbd..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! DirectProject 聊天条目投影。 -//! -//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 -//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 -//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 -//! -//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 -//! 排列属于前端聊天投影。 - -use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; -use crate::DirectToolCall; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; - -pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; - -/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 -const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; -/// 工具输入/输出上限:与卡片明细同口径。 -const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; - -/// 前端唯一消费的聊天条目。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectChatEntry { - /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 - pub(crate) item_id: String, - /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) turn_id: Option, - /// `message` | `reasoning` | `tool` - pub(crate) kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tool_call: Option, - /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 - pub(crate) at: u64, -} - -impl DirectChatEntry { - /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 - pub(crate) fn identity(&self) -> &str { - self.call_id.as_deref().unwrap_or(&self.item_id) - } -} - -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -fn item_text(item: &Value) -> Option { - if let Some(text) = item.get("text").and_then(Value::as_str) { - if !text.trim().is_empty() { - return Some(text.to_string()); - } - } - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None -} - -fn item_turn_id(item: &Value) -> Option { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if from_metadata.is_some() { - return from_metadata; - } - // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 - let rest = item - .get("id") - .and_then(Value::as_str)? - .trim() - .strip_prefix("direct-codex:")?; - let turn = rest.rsplit_once(':')?.0.trim(); - (!turn.is_empty()).then(|| turn.to_string()) -} - -fn item_at_ms(item: &Value) -> u64 { - item.get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default() -} - -fn item_identity(item: &Value) -> Option<(String, Option)> { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match (id, call_id) { - (Some(id), call_id) => Some((id, call_id)), - (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), - (None, None) => None, - } -} - -fn raw_tool_kind(name: &str) -> &'static str { - match name { - "exec_command" | "shell" | "exec" => "command", - "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", - "web_search" | "web_search_preview" => "web_search", - _ => "mcp_tool", - } -} - -fn raw_tool_title(kind: &str) -> &'static str { - match kind { - "command" => "执行命令", - "file_change" => "编辑文件", - "web_search" => "联网检索", - "mcp_tool" => "调用工具", - _ => "工具调用", - } -} - -fn stringified(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), - other => serde_json::to_string(other).ok(), - } -} - -fn first_line(value: &str, max_chars: usize) -> String { - bounded(value.lines().next().unwrap_or_default().trim(), max_chars) -} - -/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 -/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 -fn raw_tool_entry( - root: &Path, - item: &Value, - item_id: String, - call_id: Option, - turn_id: Option, - at: u64, - completed: bool, - now_ms: u64, -) -> Option { - let item_type = item.get("type").and_then(Value::as_str)?; - let call_id = call_id?; - let (kind, title, summary, detail) = match item_type { - "function_call" => { - let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); - let arguments = item - .get("arguments") - .and_then(Value::as_str) - .unwrap_or_default(); - let kind = raw_tool_kind(name); - let command = bounded( - &sanitize_detail_text(root, arguments), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ); - let summary = first_line(&command, 120); - ( - kind, - raw_tool_title(kind).to_string(), - summary, - crate::DirectToolCallDetail { - command: Some(command), - output: None, - changes: Vec::new(), - }, - ) - } - "function_call_output" => { - let output = item.get("output").and_then(stringified).map(|output| { - bounded( - &sanitize_detail_text(root, &output), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ) - }); - ( - "other", - String::new(), - String::new(), - crate::DirectToolCallDetail { - command: None, - output, - changes: Vec::new(), - }, - ) - } - _ => return None, - }; - let status = if completed { "completed" } else { "running" }; - let started_at = if at > 0 { at } else { now_ms }; - Some(DirectChatEntry { - item_id, - call_id: Some(call_id.clone()), - turn_id: turn_id.clone(), - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(DirectToolCall { - schema_version: "agc-tool-call.v1".to_string(), - id: call_id, - turn_id: turn_id.unwrap_or_default(), - kind: kind.to_string(), - title, - summary, - status: status.to_string(), - detail, - started_at, - updated_at: if at > 0 { at } else { now_ms }, - }), - at, - }) -} - -/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 -pub(crate) fn direct_chat_entry_from_item( - root: &Path, - item: &Value, - turn_id: Option<&str>, - completed: bool, - now_ms: u64, -) -> Option { - let (item_id, call_id) = item_identity(item)?; - let turn_id = turn_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| item_turn_id(item)); - let at = item_at_ms(item); - - if let Some(entry) = raw_tool_entry( - root, - item, - item_id.clone(), - call_id.clone(), - turn_id.clone(), - at, - completed, - now_ms, - ) { - return Some(entry); - } - - if let Some(tool_call) = direct_tool_call_from_item( - root, - item, - turn_id.as_deref().unwrap_or_default(), - completed, - now_ms, - ) { - let call_id = call_id.or_else(|| Some(tool_call.id.clone())); - return Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(tool_call), - at, - }); - } - - let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); - let (kind, role) = if item_type == "reasoning" { - (DIRECT_CHAT_ENTRY_KIND_REASONING, None) - } else { - let role = item.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant") { - return None; - } - (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) - }; - let text = item_text(item)?; - let text = bounded( - &sanitize_detail_text(root, &text), - DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, - ); - Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: kind.to_string(), - role, - text: Some(text), - tool_call: None, - at, - }) -} - -/// 历史切片投影:文件顺序就是条目顺序。 -/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 -pub(crate) fn direct_chat_entries_from_history( - root: &Path, - items: &[Value], -) -> Vec { - let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); - let mut entries: Vec = Vec::new(); - for item in items { - let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { - continue; - }; - match entries - .iter_mut() - .find(|existing| existing.identity() == entry.identity()) - { - Some(existing) => merge_chat_entry(existing, entry), - None => entries.push(entry), - } - } - entries -} - -/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 -fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { - if let (Some(existing_call), Some(incoming_call)) = - (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) - { - existing_call.detail.command = existing_call - .detail - .command - .take() - .or_else(|| incoming_call.detail.command.clone()); - existing_call.detail.output = existing_call - .detail - .output - .take() - .or_else(|| incoming_call.detail.output.clone()); - if existing_call.detail.changes.is_empty() { - existing_call.detail.changes = incoming_call.detail.changes.clone(); - } - if existing_call.title.trim().is_empty() { - existing_call.title = incoming_call.title.clone(); - } - if existing_call.summary.trim().is_empty() { - existing_call.summary = incoming_call.summary.clone(); - } - if existing_call.kind == "other" && incoming_call.kind != "other" { - existing_call.kind = incoming_call.kind.clone(); - } - existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); - existing_call.status = incoming_call.status.clone(); - } - if existing.text.is_none() { - existing.text = incoming.text; - } - if existing.role.is_none() { - existing.role = incoming.role; - } - if existing.turn_id.is_none() { - existing.turn_id = incoming.turn_id; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_projects_role_and_text() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - None, - true, - 0, - ) - .expect("user entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); - assert_eq!(entry.role.as_deref(), Some("user")); - assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); - assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn tool_item_carries_call_id_and_card() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - None, - true, - 1000, - ) - .expect("tool entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); - assert_eq!( - entry.call_id.as_deref(), - Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") - ); - assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); - assert!(entry.tool_call.is_some()); - } - - #[test] - fn reasoning_item_projects_text_without_role() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "reason-1", - "type": "reasoning", - "content": [{"type": "output_text", "text": "先看目录"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("reasoning entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); - assert_eq!(entry.role, None); - assert_eq!(entry.text.as_deref(), Some("先看目录")); - } - - #[test] - fn non_chat_items_and_secrets_are_not_leaked() { - assert!(direct_chat_entry_from_item( - root(), - &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), - Some("turn-1"), - true, - 0, - ) - .is_none()); - - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("assistant entry"); - let text = entry.text.unwrap_or_default(); - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - } - - #[test] - fn history_merges_call_input_and_output_into_one_card() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let entries = direct_chat_entries_from_history(root(), &items); - assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); - let card = entries[0].tool_call.as_ref().expect("card"); - assert_eq!(card.kind, "command"); - assert_eq!(card.title, "执行命令"); - assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); - assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 44a2b67ce..20c668a11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; -use crate::agent::DirectChatEntry; +use crate::agent::DirectThreadRawItem; const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -70,11 +70,9 @@ pub(crate) struct DirectThreadConsumeResult { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectThreadHistorySlice { - pub(crate) items: Vec, + /// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。 + pub(crate) items: Vec, pub(crate) has_more: bool, - pub(crate) item_timestamps: std::collections::BTreeMap, - /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 - pub(crate) entries: Vec, /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 pub(crate) first_item_id: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs new file mode 100644 index 000000000..2dc96f3fe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs @@ -0,0 +1,581 @@ +//! DirectProject 「原始条目」搬运层。 +//! +//! Thread Manager 只做搬运:这里把 Codex 原始 response item 与 app-server thread item +//! 收敛成同一形状的**脱敏原始条目**,只做三件事——挑字段、脱敏、截断。 +//! +//! 这里不生成工具卡片,不推导标题 / 折叠摘要 / 可见性 / 排序:`title`、`summary`、 +//! `kind` 和"哪些条目要显示"全部是前端投影的职责。运行态事件与历史切片共用本形状, +//! 前端因此只需要一套投影与一套合并规则。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_RAW_TEXT_MAX_CHARS: usize = 8000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_RAW_DETAIL_MAX_CHARS: usize = 4000; +/// 单条变更路径上限。 +const DIRECT_THREAD_RAW_PATH_MAX_CHARS: usize = 300; + +/// 原始条目里的一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawChange { + pub(crate) path: String, + /// 原始 kind:`add` | `update` | `delete`(缺失时为 `update`)。 + pub(crate) kind: String, +} + +/// 前端唯一消费的条目形状:脱敏、截断后的 Codex 原始条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawItem { + /// `project.jsonl` 里的 response item id;app-server `item/started` 时是 app-server item id。 + pub(crate) item_id: String, + /// 工具条目的调用 id:两个 id 空间唯一对齐点(原始 item 的 `call_id`)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// Codex 原始 `type`:`message` / `reasoning` / `function_call` / `function_call_output` / + /// `commandExecution` / `fileChange` / `mcpToolCall` / `webSearch` / …(原样透传,不做映射)。 + pub(crate) item_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) arguments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) item_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) success: Option, + /// 协议事实:来自 `item/completed` / `rawResponseItem/completed` 为 true。 + pub(crate) completed: bool, + /// 毫秒时间;条目自带时间与记录时间都拿不到时为 0。 + pub(crate) at: u64, +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_RAW_DETAIL_MAX_CHARS, + ) +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_RAW_TEXT_MAX_CHARS, + )) +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectThreadRawChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_RAW_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// 把一条 Codex 条目搬运成脱敏原始条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只作为条目自带时间缺失时的兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_raw_item_from_value( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let (item_id, call_id) = item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item, observed_at_ms); + + let text = match item_type.as_str() { + "message" | "agentMessage" | "userMessage" | "reasoning" => item_text(root, item), + _ => None, + }; + let arguments = item + .get("arguments") + .and_then(value_text) + .map(|value| detail_text(root, &value)); + let output = ["output", "aggregatedOutput", "result", "error"] + .iter() + .find_map(|key| item.get(key).and_then(value_text)) + .map(|value| detail_text(root, &value)); + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)); + + Some(DirectThreadRawItem { + item_id, + call_id, + turn_id, + item_type, + role: item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string), + text, + name: item + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + arguments, + output, + command, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)), + changes: item_changes(root, item), + item_status: item + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|status| !status.is_empty()) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + success: item.get("success").and_then(Value::as_bool), + completed, + at, + }) +} + +/// 历史切片搬运:保持文件顺序,不做任何合并。 +/// +/// 同一调用的 `function_call` 与 `function_call_output` 是两条原始条目,合并属于前端。 +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_raw_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| { + direct_thread_raw_item_from_value(root, item, None, true, timestamp_of(item)) + }) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user item"); + assert_eq!(item.item_type, "message"); + assert_eq!(item.role.as_deref(), Some("user")); + assert_eq!(item.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_keeps_call_id_and_raw_command() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool item"); + assert_eq!(item.item_type, "function_call"); + assert_eq!( + item.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(item.name.as_deref(), Some("exec_command")); + assert_eq!(item.arguments.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn reasoning_item_keeps_text_without_role() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning item"); + assert_eq!(item.item_type, "reasoning"); + assert_eq!(item.role, None); + assert_eq!(item.text.as_deref(), Some("先看目录")); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant item"); + let text = item.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_raw_items() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_raw_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert_eq!(projected[0].item_type, "function_call"); + assert_eq!(projected[1].item_type, "function_call_output"); + assert_eq!( + projected[0].call_id.as_deref(), + projected[1].call_id.as_deref() + ); + } + + #[test] + fn system_role_is_passed_through_for_frontend_to_filter() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .expect("system item"); + assert_eq!(item.role.as_deref(), Some("system")); + assert_eq!(item.item_type, "message"); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs index 82e9989e8..545b1fa11 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -8,11 +8,9 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; +use super::direct_thread_raw_item::sanitize_detail_text; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; use crate::project::{enforce_project_permission_policy, project_append_lock_for}; -use crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -86,134 +84,6 @@ fn tool_calls_path(root: &Path) -> PathBuf { root.join(".agent/conversations/tool-calls.jsonl") } -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -/// -/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` -/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + -/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 -/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; -/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed -/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 -/// -/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - /// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 fn bounded_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index d941f1c14..8dbdc185b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,17 +5383,20 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; - let entries = direct_chat_entries_from_history(root, &items); let first_item_id = items .first() .and_then(|item| item.get("id")) .and_then(serde_json::Value::as_str) .map(str::to_string); + let items = direct_thread_raw_items_from_history(root, &items, |item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .and_then(|id| item_timestamps.get(id).copied()) + .unwrap_or_default() + }); Ok(DirectThreadHistorySlice { items, has_more, - item_timestamps, - entries, first_item_id, }) }) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee075cfa5..5fc8d07e0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4101,6 +4101,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryFirstItemId: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4111,13 +4112,11 @@ export function App({ }, ).then((slice) => { loadedDirectHistoryHasMore = slice.hasMore; + loadedDirectHistoryFirstItemId = slice.firstItemId; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: directThreadHistoryItemsToMessages(slice.items), } satisfies LocalConversationResult; }); })() @@ -4214,9 +4213,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -12493,25 +12490,23 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ).map((message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - })); + const older = directThreadHistoryItemsToMessages(slice.items).map( + (message) => ({ + role: + message.role === 'user' + ? ('user' as const) + : ('assistant' as const), + text: message.content, + runtimeOwned: true, + messageId: message.messageId, + updatedAt: message.updatedAt, + }), + ); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + slice.firstItemId ?? directHistoryOldestItemIdRef.current; } catch (error) { setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index e2764e51e..ab5de6c00 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -10,6 +10,11 @@ import type { DirectThreadRawEvent, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; +import { + type DirectThreadRawItem, + isDirectThreadRawItem, + projectDirectThreadRawItem, +} from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -123,17 +128,6 @@ function upsertLiveEntry( return { ...state, live }; } -/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ -export function directChatEntryFromEventPayload( - payload: unknown, -): DirectChatEntry | null { - if (!payload || typeof payload !== 'object') return null; - const entry = payload as DirectChatEntry; - if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; - if (!entry.kind) return null; - return entry; -} - function reduceDirectThreadEvent( state: DirectThreadChatState, event: DirectThreadRawEvent, @@ -168,7 +162,9 @@ function reduceDirectThreadEvent( } case 'item.started': case 'item.completed': { - const entry = directChatEntryFromEventPayload(event.payload); + const entry = isDirectThreadRawItem(event.payload) + ? projectDirectThreadRawItem(event.payload) + : null; if (!entry) return state; return upsertLiveEntry(state, { ...entry, @@ -228,6 +224,24 @@ export function mergeDirectHistoryEntries( }; } +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` + * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadRawItem[], +): DirectThreadChatState { + return mergeDirectHistoryEntries( + state, + items + .map((item) => projectDirectThreadRawItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + ); +} + /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..78b8af25d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,5 @@ import type { LocalConversationMessageRecord } from '../../app/types'; +import type { DirectThreadRawItem } from './directThreadItemProjection'; export type DirectThreadRawEvent = { seq: number; @@ -19,33 +20,36 @@ export type DirectThreadConsumeResult = { }; export type DirectThreadHistorySlice = { - items: unknown[]; + /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ + items: DirectThreadRawItem[]; hasMore: boolean; - itemTimestamps?: Record; + /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ + firstItemId: string | null; }; +/** + * 历史条目转聊天消息。 + * + * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 + * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + */ export function directThreadHistoryItemsToMessages( - items: unknown[], - itemTimestamps: Readonly> = {}, + items: readonly DirectThreadRawItem[], ): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; + return items.flatMap((item) => { + if ( + item.itemType !== 'message' && + item.itemType !== 'agentMessage' && + item.itemType !== 'userMessage' + ) { + return []; + } const role = item.role; if (role !== 'user' && role !== 'assistant') return []; const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; + const content = typeof item.text === 'string' ? item.text : ''; if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; + const messageId = item.itemId; return [ { schemaVersion: 'agc-direct-project-context.v1', @@ -53,7 +57,7 @@ export function directThreadHistoryItemsToMessages( content, agentId: null, messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, + updatedAt: item.at ?? 0, }, ]; }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts new file mode 100644 index 000000000..5ff47d33c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,281 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 + * 走同一个函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCall, + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry } from './directThreadChat'; + +/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ +export type DirectThreadRawChange = { + path: string; + kind: string; +}; + +/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ +export type DirectThreadRawItem = { + itemId: string; + callId?: string | null; + turnId?: string | null; + itemType: string; + role?: string | null; + text?: string | null; + name?: string | null; + arguments?: string | null; + output?: string | null; + command?: string | null; + tool?: string | null; + changes?: DirectThreadRawChange[] | null; + itemStatus?: string | null; + exitCode?: number | null; + success?: boolean | null; + completed: boolean; + at?: number | null; +}; + +const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); +const REASONING_ITEM_TYPES = new Set(['reasoning']); +const TOOL_ITEM_TYPES = new Set([ + 'function_call', + 'function_call_output', + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'webSearch', + 'contextCompaction', +]); + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { + switch (raw.itemType) { + case 'function_call': + return toolKindFromFunctionName(text(raw.name)); + // 输出条目本身不携带工具语义:它只负责补上 `output`。 + case 'function_call_output': + return 'other'; + case 'commandExecution': + return 'command'; + case 'fileChange': + return 'file_change'; + case 'mcpToolCall': + return 'mcp_tool'; + case 'webSearch': + return 'web_search'; + case 'contextCompaction': + return 'context_compaction'; + default: + return 'other'; + } +} + +function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { + const itemStatus = text(raw.itemStatus); + if (itemStatus === 'completed') return 'completed'; + if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; + // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 + if (typeof raw.exitCode === 'number') { + return raw.exitCode === 0 ? 'completed' : 'failed'; + } + if (typeof raw.success === 'boolean') { + return raw.success ? 'completed' : 'failed'; + } + return raw.completed ? 'completed' : 'running'; +} + +function toolChanges( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCallChange[] { + return (raw.changes ?? []) + .filter((change) => text(change?.path).trim().length > 0) + .map((change) => ({ + path: text(change.path), + kind: text(change.kind) || 'update', + })); +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +/** + * 工具卡片投影。 + * + * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 + * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 + */ +function projectToolCard( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCall | null { + const kind = toolKind(raw); + const changes = toolChanges(raw); + const command = text(raw.command) || text(raw.arguments); + const tool = text(raw.tool); + const output = text(raw.output); + const isOutputOnly = raw.itemType === 'function_call_output'; + const detail: GameCreatorDirectToolCall['detail'] = {}; + if (command && !isOutputOnly) detail.command = command; + if (output) detail.output = output; + if (changes.length > 0) detail.changes = changes; + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (kind === 'mcp_tool' ? tool : '') || + detail.command || + changes[0]?.path || + tool || + ''; + const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; + return { + schemaVersion: 'agc-tool-call.v1', + id: text(raw.callId) || raw.itemId, + turnId: text(raw.turnId), + kind, + title: isOutputOnly ? '' : toolTitle(kind, changes), + summary: isOutputOnly ? '' : firstLine(summarySource), + status: toolStatus(raw), + detail, + startedAt: at, + updatedAt: at, + }; +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 + * 都不进聊天视图。 + */ +export function projectDirectThreadRawItem( + raw: DirectThreadRawItem | null | undefined, +): DirectChatEntry | null { + if (!raw) return null; + const itemId = text(raw.itemId).trim(); + const itemType = text(raw.itemType).trim(); + if (!itemId || !itemType) return null; + + if (REASONING_ITEM_TYPES.has(itemType)) { + const body = text(raw.text); + if (!body.trim()) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'reasoning', + role: null, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (MESSAGE_ITEM_TYPES.has(itemType)) { + const role = text(raw.role); + if (role !== 'user' && role !== 'assistant') return null; + const body = text(raw.text); + if (!body) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'message', + role, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (TOOL_ITEM_TYPES.has(itemType)) { + const toolCall = projectToolCard(raw); + if (!toolCall) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'tool', + role: null, + text: null, + toolCall, + at: raw.at ?? 0, + }; + } + + return null; +} + +/** 事件 / 历史切片载荷是否像一条原始条目。 */ +export function isDirectThreadRawItem( + payload: unknown, +): payload is DirectThreadRawItem { + if (!payload || typeof payload !== 'object') return false; + const candidate = payload as Partial; + return ( + typeof candidate.itemId === 'string' && + typeof candidate.itemType === 'string' + ); +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 912e645c7..feeffe84c 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - type DirectChatEntry, directChatEntryIdentity, emptyDirectThreadChatState, - mergeDirectHistoryEntries, + mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( partial: Partial & @@ -18,26 +18,34 @@ function event( return { seq: 1, payload: {}, ...partial }; } -function toolEntry( - overrides: Partial = {}, -): DirectChatEntry { +/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ +function rawToolStarted( + overrides: Partial = {}, +): DirectThreadRawItem { return { - itemId: 'call-1', - callId: 'call-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', turnId: 'turn-1', - kind: 'tool', - toolCall: { - schemaVersion: 'agc-tool-call.v1', - id: 'call-1', - turnId: 'turn-1', - kind: 'command', - title: '执行命令', - summary: 'ls', - status: 'running', - detail: { command: 'ls', output: undefined, changes: [] }, - startedAt: 1000, - updatedAt: 1000, - }, + itemType: 'function_call', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + completed: false, + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ +function rawToolOutput( + overrides: Partial = {}, +): DirectThreadRawItem { + return { + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + turnId: 'turn-1', + itemType: 'function_call_output', + output: 'assets\ngame', + completed: true, + at: 2000, ...overrides, }; } @@ -95,10 +103,12 @@ describe('DirectProject 聊天 reducer', () => { payload: { itemId: 'msg-1', turnId: 'turn-1', - kind: 'message', + itemType: 'message', role: 'assistant', text: '你好,我是陶泥儿。', - }, + completed: true, + at: 3000, + } satisfies DirectThreadRawItem, }), ]); const entries = selectDirectChatEntries(done); @@ -106,65 +116,124 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); }); - it('工具条目 started/completed 归并成一张卡片', () => { + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ - type: 'item.started', + type: 'item.delta', turnId: 'turn-1', - itemId: 'call-1', - payload: toolEntry(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets\ngame', changes: [] }, - updatedAt: 2000, - }, - }, + itemId: 'reason-1', + payload: { delta: '先看目录', kind: 'reasoning' }, }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); - expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); }); - it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { - const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ - toolEntry(), + it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + payload: rawToolStarted(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + rawToolOutput(), { itemId: 'msg-user', - kind: 'message', + itemType: 'message', role: 'user', text: '做一个拼图游戏', + completed: true, + at: 0, + }, + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'sys-1', + payload: { + itemId: 'sys-1', + itemType: 'message', + role: 'system', + text: '内部指令', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'plan-1', + payload: { + itemId: 'plan-1', + itemType: 'plan', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + { + itemId: 'msg-user', + itemType: 'message', + role: 'user', + text: '做一个拼图游戏', + completed: true, + at: 0, }, ]); const live = reduceDirectThreadEvents(historical, [ event({ type: 'item.completed', turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - itemId: 'call-1', - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets', changes: [] }, - }, - }, + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ccaebe645 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -20,20 +20,29 @@ describe('Direct 回合状态与历史时间', () => { expect(isDirectTurnInProgress(status)).toBe(true); } }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { + it('条目时间来自搬运后的 at,前端不自己补造时间', () => { const items = [ { - type: 'message', + itemId: 'direct-codex:turn:user', + itemType: 'message', role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], + text: '帮我修改游戏', + completed: true, + at: 1_800_000_000_001, + }, + { + itemId: 'direct-codex:turn:assistant', + itemType: 'message', + role: 'assistant', + text: '好的', + completed: true, }, ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); + expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( + 1_800_000_000_001, + ); + // Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。 + expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0); expect(items[0]).not.toHaveProperty('recordedAt'); }); });