思考正文改走生产路径的 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:
@@ -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, ¶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() {
|
||||
|
||||
Reference in New Issue
Block a user