补齐 AGC 直连活动状态与泥点错误映射

Codex app-server 将文件、命令、验证、搜索等 item/started 事件映射为具体活动标题和安全短详情。

泥点不足错误映射为稳定 409 上游错误并在 DirectProject 显示明确充值引导,不再落到 other。

官方平台会话保持 Provider Proxy 链路,测试专用 API Key/AuthBridge 逻辑仅保留在 cfg(test)。Codex 子进程清理代理环境,确保 loopback SSE 不被系统代理劫持。

补充活动投影、错误映射、流式配置和敏感内容脱敏回归测试。
This commit is contained in:
2026-09-02 14:26:00 +08:00
parent 69c11c3285
commit 2e15289264
2 changed files with 435 additions and 30 deletions
File diff suppressed because it is too large Load Diff
@@ -1741,6 +1741,9 @@ impl DirectCodexTurnFailure {
fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str {
let normalized = error.to_ascii_lowercase(); let normalized = error.to_ascii_lowercase();
if direct_codex_error_is_mud_points_insufficient(error) {
return "泥点余额不足,请充值后发送“继续”";
}
if private_external_editor_credentials_storage_preparation_failed(error) { if private_external_editor_credentials_storage_preparation_failed(error) {
return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试";
} }
@@ -1786,6 +1789,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
} }
fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
if direct_codex_error_is_mud_points_insufficient(error) {
return Some("泥点余额不足");
}
if private_external_editor_credentials_storage_preparation_failed(error) { if private_external_editor_credentials_storage_preparation_failed(error) {
return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据");
} }
@@ -1796,6 +1802,9 @@ fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> {
} }
fn direct_codex_failure_is_retryable(error: &str) -> bool { fn direct_codex_failure_is_retryable(error: &str) -> bool {
if direct_codex_error_is_mud_points_insufficient(error) {
return false;
}
![ ![
"private-external-editor-credential-storage-preparation-failed", "private-external-editor-credential-storage-preparation-failed",
"private-external-editor-credential-persistence-failed", "private-external-editor-credential-persistence-failed",
@@ -1809,6 +1818,15 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool {
.any(|marker| error.contains(marker)) .any(|marker| error.contains(marker))
} }
fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
error.contains("泥点余额不足")
|| error.contains("可消费泥点不足")
|| normalized.contains("kind=mud-points-insufficient")
|| normalized.contains("insufficient_mud_points")
|| normalized.contains("insufficient-mud-points")
}
fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String {
let summary = direct_codex_failure_public_summary(&failure.error) let summary = direct_codex_failure_public_summary(&failure.error)
.map(str::to_string) .map(str::to_string)
@@ -1907,8 +1925,7 @@ fn direct_taonier_art_asset_identity(
return None; return None;
} }
let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?;
if !std::path::Path::new(&asset_path).is_file() if !std::path::Path::new(&asset_path).is_file() {
{
return None; return None;
} }
let bytes = std::fs::read(asset_path).ok()?; let bytes = std::fs::read(asset_path).ok()?;
@@ -3645,7 +3662,7 @@ fn sync_direct_codex_project_outputs_at(
} }
/// Project Codex text into the only form that may cross the DirectProject UI /// Project Codex text into the only form that may cross the DirectProject UI
/// boundary. The app-server stream can contain reasoning blocks, URLs, /// boundary. The app-server stream can contain reasoning blocks, URLs,
/// credentials, or host paths before the final reply is known; those values /// credentials, or host paths before the final reply is known; those values
/// must never be emitted as an intermediate chat message or persisted as the /// must never be emitted as an intermediate chat message or persisted as the
/// user-visible assistant turn. /// user-visible assistant turn.
@@ -3877,7 +3894,7 @@ async fn run_direct_game_creator_turn_inner(
) -> Result<String, DirectCodexTurnFailure> { ) -> Result<String, DirectCodexTurnFailure> {
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter { if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("understanding"), None); emitter.emit("running", Some("preparing"), None);
} }
let stream_enabled = load_game_creator_app_config() let stream_enabled = load_game_creator_app_config()
.map(|config| config.llm.stream) .map(|config| config.llm.stream)
@@ -3889,8 +3906,10 @@ async fn run_direct_game_creator_turn_inner(
.map_err(|error| { .map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?; })?;
let live_streamed_text = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reply = if let Some(emitter) = turn_emitter { let reply = if let Some(emitter) = turn_emitter {
let emitter = emitter.clone(); let emitter = emitter.clone();
let live_streamed_text_for_observer = std::sync::Arc::clone(&live_streamed_text);
let mut has_streamed = false; let mut has_streamed = false;
let mut latest_accumulated_text = None; let mut latest_accumulated_text = None;
let mut observer = move |observation: DirectCodexTurnObservation| match observation { let mut observer = move |observation: DirectCodexTurnObservation| match observation {
@@ -3901,9 +3920,22 @@ async fn run_direct_game_creator_turn_inner(
return; return;
} }
has_streamed = true; has_streamed = true;
live_streamed_text_for_observer.store(true, std::sync::atomic::Ordering::Relaxed);
latest_accumulated_text = visible_text.clone(); latest_accumulated_text = visible_text.clone();
emitter.emit("streaming", None, visible_text); emitter.emit("streaming", None, visible_text);
} }
DirectCodexTurnObservation::IntermediateText(intermediate_text) => {
let visible_text = if stream_enabled {
project_direct_codex_visible_text(root, &intermediate_text)
} else {
None
};
if let Some(visible_text) = visible_text {
has_streamed = true;
latest_accumulated_text = Some(visible_text.clone());
emitter.emit("streaming", None, Some(visible_text));
}
}
DirectCodexTurnObservation::Activity(activity) => { DirectCodexTurnObservation::Activity(activity) => {
emitter.emit( emitter.emit(
if has_streamed { "streaming" } else { "running" }, if has_streamed { "streaming" } else { "running" },
@@ -3938,6 +3970,31 @@ async fn run_direct_game_creator_turn_inner(
) )
})?; })?;
if let Some(emitter) = turn_emitter { if let Some(emitter) = turn_emitter {
// The Router/Responses upstream frequently buffers the whole agent
// reply and only delivers it with the terminal item, so real
// agentMessage deltas never arrive while tools run. When no live
// delta reached the UI, replay the final reply as a bounded typewriter
// stream so the chat shows progressive text instead of one jump from
// activity status to the completed message.
if stream_enabled
&& !live_streamed_text.load(std::sync::atomic::Ordering::Relaxed)
&& !visible_reply.is_empty()
{
const TYPEWRITER_CHUNK_CHARS: usize = 24;
const TYPEWRITER_CHUNK_DELAY_MS: u64 = 40;
let text = visible_reply.as_str();
let mut offset = 0usize;
while offset < text.len() {
let mut end = (offset + TYPEWRITER_CHUNK_CHARS).min(text.len());
while end < text.len() && !text.is_char_boundary(end) {
end += 1;
}
emitter.emit("streaming", None, Some(text[..end].trim_end().to_string()));
offset = end;
tokio::time::sleep(std::time::Duration::from_millis(TYPEWRITER_CHUNK_DELAY_MS))
.await;
}
}
emitter.emit( emitter.emit(
"finalizing", "finalizing",
Some("response-finalization"), Some("response-finalization"),
@@ -3953,7 +4010,7 @@ async fn run_direct_game_creator_turn_inner(
if let Some(emitter) = turn_emitter { if let Some(emitter) = turn_emitter {
emitter.emit( emitter.emit(
"finalizing", "finalizing",
Some("file-change"), Some("file-write"),
Some(visible_reply.clone()), Some(visible_reply.clone()),
); );
} }
@@ -4331,6 +4388,21 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() {
let error = "direct-codex-failure:v1 summary=泥点余额不足";
assert!(direct_codex_error_is_mud_points_insufficient(error));
assert_eq!(
direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error),
"泥点余额不足,请充值后发送“继续”"
);
assert_eq!(
direct_codex_failure_public_summary(error),
Some("泥点余额不足")
);
assert!(!direct_codex_failure_is_retryable(error));
}
#[test] #[test]
fn client_turn_id_is_strictly_normalized_and_bounded() { fn client_turn_id_is_strictly_normalized_and_bounded() {
assert_eq!( assert_eq!(