diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index ef4772aaa..72116eadc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1786,6 +1786,9 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st { return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; } + if direct_project_history_shape_failure(error) { + return "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求"; + } match stage { DirectCodexFailureStage::ArtPreparation => { "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" @@ -1819,6 +1822,9 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool { if direct_codex_error_is_mud_points_insufficient(error) { return false; } + if direct_project_history_shape_failure(error) { + return false; + } ![ "private-external-editor-credential-storage-preparation-failed", "private-external-editor-credential-persistence-failed", @@ -1832,6 +1838,20 @@ fn direct_codex_failure_is_retryable(error: &str) -> bool { .any(|marker| error.contains(marker)) } +/// DirectProject 历史文件里与“行形状”有关的失败:同一份文件每次读都会得到同一结果, +/// 重试不会改变结论。IO 类失败(打开/读取目录)不在其中,那些仍按可重试处理。 +const DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS: &[&str] = &[ + "DirectProject 历史记录类型无效", + "DirectProject 历史记录缺少 payload", + "解析 DirectProject 历史失败", +]; + +fn direct_project_history_shape_failure(error: &str) -> bool { + DIRECT_PROJECT_HISTORY_SHAPE_FAILURE_MARKERS + .iter() + .any(|marker| error.contains(marker)) +} + fn direct_codex_error_is_mud_points_insufficient(error: &str) -> bool { let normalized = error.to_ascii_lowercase(); error.contains("泥点余额不足") @@ -4549,6 +4569,22 @@ mod tests { assert!(!direct_codex_failure_is_retryable(error)); } + #[test] + fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { + let error = "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl"; + assert!(direct_project_history_shape_failure(error)); + assert_eq!( + direct_codex_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, error), + "项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求" + ); + assert!(!direct_codex_failure_is_retryable(error)); + + // 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。 + let io_error = "打开 DirectProject 历史失败:拒绝访问"; + assert!(!direct_project_history_shape_failure(io_error)); + assert!(direct_codex_failure_is_retryable(io_error)); + } + #[test] fn client_turn_id_is_strictly_normalized_and_bounded() { assert_eq!( @@ -6523,6 +6559,50 @@ mod tests { assert!(!diagnostic.contains("provider.example")); } + #[test] + fn direct_failure_diagnostic_marks_project_history_shape_failure_as_not_retryable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let history_path = root + .path() + .join(".agent/conversations/project.jsonl") + .display() + .to_string(); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::CodeGeneration, + format!("DirectProject 历史记录类型无效:{history_path}"), + ), + ); + + assert!( + error.starts_with( + "direct-codex-failure:v1 stage=code-generation retryable=false summary=" + ), + "{error}" + ); + assert!( + error.contains( + ";建议:项目对话历史存在本版本无法识别的记录,旧格式已兼容读取,请检查项目诊断后修复该历史文件再发送需求;" + ), + "{error}" + ); + assert!(error.ends_with("已保存脱敏项目诊断"), "{error}"); + + let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let entries = std::fs::read_dir(&diagnostics) + .expect("diagnostic directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let diagnostic = std::fs::read_to_string(entries[0].path().join("failure.json")) + .expect("diagnostic sidecar"); + assert!(diagnostic.contains("\"retryable\": false"), "{diagnostic}"); + assert!(diagnostic.contains("旧格式已兼容读取"), "{diagnostic}"); + } + #[test] fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { let root = tempfile::tempdir().expect("temp dir");