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 3cb7bc305..83c7de9f9 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 @@ -1060,38 +1060,46 @@ fn should_emit_direct_codex_activity( true } +/// 思考正文增量事件:`item/reasoning/summaryTextDelta`(reasoning item 的 `summary`)与 +/// `item/reasoning/textDelta`(它的 `content`)都下发正文,不降级成"preparing 活动文本"。 +/// +/// 这里只是把"完成时才看到"提前为"边生成边看到":两段文本本来就随 `item/completed` +/// 落进 `project.jsonl` 并展示给用户,可见范围没有放宽;未识别的 plan 文本与命令输出 +/// 仍然只降级为活动状态。运行态读取器和 `direct_codex_notification_event` 共用这一处, +/// 避免两份实现再次分叉(分叉时就出现过"生产路径从不产生 ReasoningDelta")。 +fn direct_codex_reasoning_delta_event( + method: &str, + params: &serde_json::Value, +) -> Option { + if !matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return None; + } + params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }) +} + fn direct_codex_notification_event( method: &str, params: &serde_json::Value, intermediate_text: Option, safe_activity: Option<&'static str>, ) -> Option { - // 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为 - // "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。 - // - // 两条通知都下发正文,不下发活动文本: - // - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`; - // - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`, - // 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。 - // 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围; - // 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。 - if matches!( - method, - "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" - ) { - return params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(|delta| CodexTurnEvent::ReasoningDelta { - item_id: params - .get("itemId") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| "direct-missing-item".to_string()), - delta: delta.to_string(), - }); + if let Some(event) = direct_codex_reasoning_delta_event(method, params) { + return Some(event); } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), @@ -3988,12 +3996,18 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - if let Some(activity) = safe_activity { - let last_activity = last_direct_activity_by_turn - .entry(turn_id.clone()) - .or_default(); - if !should_emit_direct_codex_activity(last_activity, activity) { - continue; + // 思考正文走正文通道,不参与 `preparing` 活动的降级与节流:活动节流按类别抑制 + // 连续的 preparing,逐段思考正文会被整段吃掉——这正是生产路径此前从不产生 + // `ReasoningDelta` 的原因。正文仍沿用下面的正文节流,避免重复 chunk 反复入队。 + let reasoning_delta = direct_codex_reasoning_delta_event(method, ¶ms); + if reasoning_delta.is_none() { + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } } } if let Some(text) = intermediate_text.as_deref() { @@ -4004,7 +4018,9 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(kind) = direct_codex_resolution_event_type(method) { + 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; @@ -4783,18 +4799,49 @@ mod tests { ); } + /// 判据:思考正文走 `ReasoningDelta` 正文通道(与 ADR 一致),不再被 `preparing` + /// 活动降级吃掉;plan 文本与命令输出仍然只降级成活动类别,不带正文。 #[test] - fn direct_preparing_notifications_emit_thinking_activity_without_raw_text() { - let reasoning = serde_json::json!({ "delta": "hidden reasoning must not leak" }); - assert!(matches!( - direct_codex_notification_event( + fn direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity() { + let reasoning = serde_json::json!({ "itemId": "reasoning-1", "delta": "思考正文" }); + for method in [ + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + ] { + assert!( + matches!( + direct_codex_reasoning_delta_event(method, &reasoning), + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) + if item_id == "reasoning-1" && delta == "思考正文" + ), + "{method} 必须下发明文思考增量" + ); + // 运行态读取器用的就是这一个分类函数,不能再走「preparing 活动」降级。 + assert!( + matches!( + direct_codex_notification_event( + method, + &reasoning, + Some("思考正文".to_string()), + Some("preparing"), + ), + Some(CodexTurnEvent::ReasoningDelta { .. }) + ), + "{method} 在通知分类里不能降级成活动" + ); + } + assert!( + direct_codex_reasoning_delta_event( "item/reasoning/textDelta", - &reasoning, - Some("hidden reasoning must not leak".to_string()), - Some("preparing"), - ), - Some(CodexTurnEvent::Activity("preparing")) - )); + &serde_json::json!({ "delta": "" }), + ) + .is_none(), + "空增量不产生正文事件" + ); + assert!( + direct_codex_reasoning_delta_event("turn/plan/updated", &reasoning).is_none(), + "非 reasoning 通知不进正文通道" + ); let plan = serde_json::json!({ "explanation": "private plan text must not leak" }); assert!(matches!( @@ -6288,6 +6335,79 @@ while IFS= read -r line; do :; done assert_eq!(connection.inner.threads.lock().await.len(), 1); } + /// 判据:生产读取器不再把思考正文降级成 `preparing` 活动。 + /// + /// fixture 刻意不发 `turn/started` 等先导活动,所以"活动节流"这条退路不存在: + /// 一旦读取器把 `item/reasoning/*Delta` 归回活动通道,observations 里就会出现 + /// `Activity("preparing")`。同时明文思考只走 DirectProject 正文通道, + /// 不得漏进旧的运行态 observation。 + #[cfg(unix)] + #[tokio::test] + async fn codex_app_server_streams_reasoning_deltas_without_activity_fallback() { + use std::os::unix::fs::PermissionsExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let executable = temp.path().join("fake-codex-app-server-reasoning-delta"); + std::fs::write( + &executable, + r#"#!/bin/sh +IFS= read -r initialize +case "$initialize" in *'"method":"initialize"'*) ;; *) exit 51 ;; esac +printf '%s\n' '{"id":1,"result":{"codexHome":"/tmp","platformFamily":"unix","platformOs":"linux","userAgent":"fixture"}}' +IFS= read -r initialized +case "$initialized" in *'"method":"initialized"'*) ;; *) exit 52 ;; esac +IFS= read -r thread_start +case "$thread_start" in *'"method":"thread/start"'*) ;; *) exit 53 ;; esac +printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' +IFS= read -r turn_start +case "$turn_start" in *'"method":"turn/start"'*) ;; *) exit 54 ;; esac +printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/reasoning/textDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"SECRET_REASONING_TEXT"}}' +printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":[]}"}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[]}"}}}' +printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' +while IFS= read -r line; do :; done +"#, + ) + .expect("write fake app-server"); + let mut permissions = std::fs::metadata(&executable) + .expect("fake metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&executable, permissions).expect("chmod fake app-server"); + + let llm = test_llm(); + let connection = + CodexAppServerConnection::spawn_with_executable(&llm, executable.as_os_str()) + .await + .expect("spawn fake app-server"); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); + connection + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + None, + Some(&mut observer), + None, + ) + .await + .expect("run fake app-server turn"); + drop(observer); + assert!( + !observations.iter().any(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity("preparing") + )), + "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" + ); + assert!( + !format!("{observations:?}").contains("SECRET_REASONING_TEXT"), + "明文思考不得漏进运行态 observation" + ); + } + #[cfg(unix)] #[tokio::test] async fn direct_home_app_server_uses_read_only_protocol_and_rejects_file_change_items() {