思考正文改走生产路径的 ReasoningDelta 通道

- 新增 direct_codex_reasoning_delta_event,两条 reasoning 增量通知只在这一处分类
- 生产 stdout 读取器改用该函数,并跳过「preparing 活动」的降级与活动节流(活动节流会按类别吃掉逐段正文)
- 旧分类函数 direct_codex_notification_event 同步改用它,避免两份实现再次分叉
- 单测 direct_reasoning_deltas_stream_text_while_plan_and_command_output_stay_activity 取代原先断言 Activity 的旧用例
- 新增 E2E codex_app_server_streams_reasoning_deltas_without_activity_fallback:把读取器的路由关掉即转红(已做变异验证)
This commit is contained in:
2026-09-17 13:22:36 +08:00
parent da2ad83c3b
commit e640219243
@@ -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<CodexTurnEvent> {
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<String>,
safe_activity: Option<&'static str>,
) -> Option<CodexTurnEvent> {
// 思考正文走独立通道,交给 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, &params);
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() {