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 06569e388..9be967bcd 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 @@ -1092,15 +1092,29 @@ fn direct_codex_reasoning_delta_event( }) } +/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。 +/// +/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外 +/// 再做一遍——两份实现分叉过一次,结果是生产路径从不产生 `ReasoningDelta`。 +/// +/// Preparing notifications may carry private plan/reasoning text; expose only the safe +/// activity category. Other categories may retain their bounded, redacted intermediate text. fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, + turn_id: &str, ) -> Option { if let Some(event) = direct_codex_reasoning_delta_event(method, params) { return Some(event); } + if let Some(kind) = direct_codex_resolution_event_type(method) { + return Some(CodexTurnEvent::Request { + kind, + params: params.clone(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -1115,14 +1129,19 @@ fn direct_codex_notification_event( "item/agentMessage/delta" => params .get("delta") .and_then(serde_json::Value::as_str) - .filter(|value| !value.trim().is_empty()) + .filter(|value| !value.is_empty()) .map(|delta| { let item_id = params .get("itemId") .and_then(serde_json::Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()); + .unwrap_or_else(|| { + eprintln!( + "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" + ); + format!("direct-missing-item:{turn_id}") + }); CodexTurnEvent::AgentMessageDelta { item_id, delta: delta.to_string(), @@ -1138,6 +1157,13 @@ fn direct_codex_notification_event( .cloned() .unwrap_or(serde_json::Value::Null), )), + method if direct_codex_request_event_type(method).is_some() => { + Some(CodexTurnEvent::Request { + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), + params: params.clone(), + }) + } _ => Some(CodexTurnEvent::Terminal(params.clone())), } } @@ -3999,8 +4025,8 @@ async fn read_game_creator_codex_app_server_stdout( // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 - let reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms); - if reasoning_delta.is_none() { + let is_reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms).is_some(); + if !is_reasoning_delta { if let Some(activity) = safe_activity { let last_activity = last_direct_activity_by_turn .entry(turn_id.clone()) @@ -4018,68 +4044,14 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event) = reasoning_delta { - event - } else if let Some(kind) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { kind, params } - } else if let Some(activity) = safe_activity { - // Preparing notifications may carry private plan/reasoning text; - // expose only the safe activity category. Other categories may - // retain their bounded, redacted intermediate text below. - if activity == "preparing" { - CodexTurnEvent::Activity(activity) - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - CodexTurnEvent::Activity(activity) - } - } else if let Some(text) = intermediate_text { - CodexTurnEvent::IntermediateText(text) - } else { - match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - let item_id = params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| { - eprintln!( - "agent.direct_codex.protocol_warning event=item/agentMessage/delta missing_item_id" - ); - format!("direct-missing-item:{turn_id}") - }); - CodexTurnEvent::AgentMessageDelta { - item_id, - delta: delta.to_string(), - } - } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - "rawResponseItem/completed" => CodexTurnEvent::RawItem( - params - .get("item") - .cloned() - .unwrap_or(serde_json::Value::Null), - ), - method if direct_codex_request_event_type(method).is_some() => { - CodexTurnEvent::Request { - kind: direct_codex_request_event_type(method) - .expect("request kind checked above"), - params, - } - } - _ => CodexTurnEvent::Terminal(params), - } + let Some(event) = direct_codex_notification_event( + method, + ¶ms, + intermediate_text, + safe_activity, + &turn_id, + ) else { + continue; }; let sender = if method == "turn/completed" { last_direct_activity_by_turn.remove(&turn_id); @@ -4824,6 +4796,7 @@ mod tests { &reasoning, Some("思考正文".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::ReasoningDelta { .. }) ), @@ -4850,6 +4823,7 @@ mod tests { &plan, Some("private plan text must not leak".to_string()), Some("preparing"), + "turn-1", ), Some(CodexTurnEvent::Activity("preparing")) )); @@ -4861,11 +4835,88 @@ mod tests { &command_output, None, Some("command-exec"), + "turn-1", ), Some(CodexTurnEvent::Activity("command-exec")) )); } + /// 判据:读取器与单测共用同一个分类函数,这些分支的行为被钉在这里。 + /// + /// 参数:method / params / 正文候选 / 安全活动类别 / turnId。 + #[test] + fn direct_notification_classification_covers_the_reader_branches() { + let empty = serde_json::json!({}); + let resolved = serde_json::json!({ "requestId": "request-1" }); + assert!(matches!( + direct_codex_notification_event( + "serverRequest/resolved", + &resolved, + None, + None, + "turn-1" + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::RequestResolved, + .. + }) + )); + let approval = serde_json::json!({ "requestId": "request-2" }); + assert!(matches!( + direct_codex_notification_event( + "item/fileChange/requestApproval", + &approval, + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::Request { + kind: DirectThreadRequestKind::ApprovalRequested, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event("item/started", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Item { + completed: false, + .. + }) + )); + assert!(matches!( + direct_codex_notification_event( + "rawResponseItem/completed", + &serde_json::json!({ "item": { "type": "reasoning" } }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::RawItem(_)) + )); + // 空正文的 assistant 增量既不产事件,也不产 Terminal 兜底。 + assert!(direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "" }), + None, + None, + "turn-1", + ) + .is_none()); + assert!(matches!( + direct_codex_notification_event( + "item/agentMessage/delta", + &serde_json::json!({ "delta": "正文" }), + None, + None, + "turn-1", + ), + Some(CodexTurnEvent::AgentMessageDelta { delta, .. }) if delta == "正文" + )); + assert!(matches!( + direct_codex_notification_event("turn/completed", &empty, None, None, "turn-1"), + Some(CodexTurnEvent::Terminal(_)) + )); + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(),