通知分类收敛成生产读取器与单测共用的唯一实现

- direct_codex_notification_event 收编 resolution / request / item / rawItem / Terminal 全部分支,并接收 turnId
- 读取器删掉自己那份内联分类,只保留「必须有 turnId 才处理」与活动/正文节流
- agentMessage 缺 itemId 的告警与 direct-missing-item:{turnId} 兜底身份原样保留,避免行为变化
- 新增 direct_notification_classification_covers_the_reader_branches 钉住各分支;E2E 假 app-server 用例继续覆盖读取器
This commit is contained in:
2026-09-17 13:47:32 +08:00
parent d348cf9064
commit 9993b2feb5
@@ -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<String>,
safe_activity: Option<&'static str>,
turn_id: &str,
) -> Option<CodexTurnEvent> {
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, &params);
if reasoning_delta.is_none() {
let is_reasoning_delta = direct_codex_reasoning_delta_event(method, &params).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,
&params,
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(),