补齐 AGC 直连活动状态与泥点错误映射
Codex app-server 将文件、命令、验证、搜索等 item/started 事件映射为具体活动标题和安全短详情。 泥点不足错误映射为稳定 409 上游错误并在 DirectProject 显示明确充值引导,不再落到 other。 官方平台会话保持 Provider Proxy 链路,测试专用 API Key/AuthBridge 逻辑仅保留在 cfg(test)。Codex 子进程清理代理环境,确保 loopback SSE 不被系统代理劫持。 补充活动投影、错误映射、流式配置和敏感内容脱敏回归测试。
This commit is contained in:
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 {
|
||||
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) {
|
||||
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> {
|
||||
if direct_codex_error_is_mud_points_insufficient(error) {
|
||||
return Some("泥点余额不足");
|
||||
}
|
||||
if private_external_editor_credentials_storage_preparation_failed(error) {
|
||||
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 {
|
||||
if direct_codex_error_is_mud_points_insufficient(error) {
|
||||
return false;
|
||||
}
|
||||
![
|
||||
"private-external-editor-credential-storage-preparation-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))
|
||||
}
|
||||
|
||||
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 {
|
||||
let summary = direct_codex_failure_public_summary(&failure.error)
|
||||
.map(str::to_string)
|
||||
@@ -1907,8 +1925,7 @@ fn direct_taonier_art_asset_identity(
|
||||
return None;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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
|
||||
/// 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
|
||||
/// must never be emitted as an intermediate chat message or persisted as the
|
||||
/// user-visible assistant turn.
|
||||
@@ -3877,7 +3894,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
) -> Result<String, DirectCodexTurnFailure> {
|
||||
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
|
||||
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()
|
||||
.map(|config| config.llm.stream)
|
||||
@@ -3889,8 +3906,10 @@ async fn run_direct_game_creator_turn_inner(
|
||||
.map_err(|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 emitter = emitter.clone();
|
||||
let live_streamed_text_for_observer = std::sync::Arc::clone(&live_streamed_text);
|
||||
let mut has_streamed = false;
|
||||
let mut latest_accumulated_text = None;
|
||||
let mut observer = move |observation: DirectCodexTurnObservation| match observation {
|
||||
@@ -3901,9 +3920,22 @@ async fn run_direct_game_creator_turn_inner(
|
||||
return;
|
||||
}
|
||||
has_streamed = true;
|
||||
live_streamed_text_for_observer.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
latest_accumulated_text = visible_text.clone();
|
||||
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) => {
|
||||
emitter.emit(
|
||||
if has_streamed { "streaming" } else { "running" },
|
||||
@@ -3938,6 +3970,31 @@ async fn run_direct_game_creator_turn_inner(
|
||||
)
|
||||
})?;
|
||||
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(
|
||||
"finalizing",
|
||||
Some("response-finalization"),
|
||||
@@ -3953,7 +4010,7 @@ async fn run_direct_game_creator_turn_inner(
|
||||
if let Some(emitter) = turn_emitter {
|
||||
emitter.emit(
|
||||
"finalizing",
|
||||
Some("file-change"),
|
||||
Some("file-write"),
|
||||
Some(visible_reply.clone()),
|
||||
);
|
||||
}
|
||||
@@ -4331,6 +4388,21 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex(
|
||||
mod tests {
|
||||
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]
|
||||
fn client_turn_id_is_strictly_normalized_and_bounded() {
|
||||
assert_eq!(
|
||||
|
||||
Reference in New Issue
Block a user