diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 3b9786de8..be81367bf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2156,11 +2156,14 @@ fn direct_codex_error_feedback_prompt(error: &str, attempt: usize) -> String { ) } -/// 把 typed 回合失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 +/// 把 typed 失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮的收口说明,事件载荷、横幅与 -/// 项目历史共用同一份,命令边界也只序列化它一次。 -fn record_direct_codex_turn_failure( +/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝** +/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 +/// +/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, +/// 事件载荷、横幅与项目历史共用同一份,命令边界也只序列化它一次。 +pub(crate) fn record_direct_codex_failure( root: &Path, failure: &DirectTurnError, client_turn_id: Option<&str>, @@ -2241,6 +2244,23 @@ fn record_direct_codex_turn_failure( ) } +/// 命令边界的错误文本:可留痕的调用级拒绝在这里补一份运行错误诊断(返回串因此带 `详情:` 引用), +/// 其余只输出 [`DirectTurnError`] 的 `Display`。 +/// +/// 分层改成 typed 之前,这几条"宿主 / 环境事实"是在回合失败通道里被写进诊断的;分层之后它们不再 +/// 进那条通道,留痕与界面的 `详情:` 展开都在这里补回来。GUI 命令与 CLI 边界共用这一份,禁止在各自 +/// 边界再写一套判据;回合级失败已在上游写过诊断,这里直接放行。 +pub(crate) fn direct_turn_error_boundary_text( + root: &Path, + client_turn_id: Option<&str>, + failure: DirectTurnError, +) -> String { + if !failure.is_reportable() { + return failure.to_string(); + } + record_direct_codex_failure(root, &failure, client_turn_id) +} + fn persist_direct_codex_failure_context( root: &Path, client_turn_id: &str, @@ -4624,7 +4644,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( Err(failure) if !failure.is_turn_failure() => Err(failure), Err(failure) => { let stage = failure.turn_failure_stage(); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( root, &failure, turn_emitter.map(|emitter| emitter.turn_id()), @@ -7916,12 +7936,76 @@ mod tests { ); } + /// 可留痕的调用级拒绝(宿主 / 环境事实)在命令边界补一份运行错误诊断,返回串带 `详情:` 引用: + /// 这是分层改 typed 之前的行为,前端横幅的展开逻辑按这个引用工作。 + #[test] + fn reportable_call_rejection_writes_a_diagnostic_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let text = direct_turn_error_boundary_text( + &root, + Some("direct-codex:turn-1:user"), + DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败:找不到可执行文件".into(), + }, + ); + + assert!(text.contains("详情:.agent/runtime/errors/"), "{text}"); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let sidecar = std::fs::read_to_string(entries[0].path()).expect("runtime error sidecar"); + assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}"); + } + + /// 用户的正常操作结果不留痕:空内容只给一句话,不写诊断。 + #[test] + fn user_shaped_call_rejection_is_not_recorded() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let text = direct_turn_error_boundary_text(&root, None, DirectTurnError::ContentEmpty); + + assert_eq!(text, "聊天内容不能为空"); + assert!(!root.join(".agent/runtime/errors").exists()); + } + + /// 回合失败已经在上游写过诊断,边界不得再写第二份。 + #[test] + fn turn_failure_text_is_not_recorded_twice_at_the_boundary() { + let parent = tempfile::tempdir().expect("temp dir"); + let root = parent.path().join("project"); + init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); + + let failure = + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); + let recorded = record_direct_codex_failure(&root, &failure, None); + // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 + let text = direct_turn_error_boundary_text( + &root, + None, + DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), + ); + + assert_eq!(text, recorded); + let entries = std::fs::read_dir(root.join(".agent/runtime/errors")) + .expect("runtime error directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1, "边界不得为同一条失败再写一份诊断"); + } + #[test] fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, @@ -7964,7 +8048,7 @@ mod tests { .join(".agent/conversations/project.jsonl") .display() .to_string(); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::CodeGeneration, @@ -8004,7 +8088,7 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, @@ -8025,7 +8109,7 @@ mod tests { let parent = tempfile::tempdir().expect("temp dir"); let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let error = record_direct_codex_turn_failure( + let error = record_direct_codex_failure( &root, &DirectTurnError::turn_failed( DirectCodexFailureStage::ArtPreparation, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index d2c3ec105..65c44bb2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -30,8 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id( /// DirectProject 聊天命令:对外仍然是 `Result`。 /// -/// 字符串只在这里、由 [`DirectTurnError`] 的 `Display` 生成一次;前端拿到的仍是"一句给用户看的话", -/// 而 Rust 侧从命令入口到宿主出口全程只传 typed 错误。 +/// 字符串只在这里生成一次;前端拿到的仍是"一句给用户看的话",而 Rust 侧从命令入口到宿主出口全程 +/// 只传 typed 错误。可留痕的调用级拒绝(宿主 / 环境事实)在这里补一份运行错误诊断,返回串因此带上 +/// `详情:` 引用——界面横幅的展开逻辑按这个引用工作。 /// // TODO(Direct 命令接单化,未实施):现在这个命令 await 整轮,于是"命令边界"承担了不属于它的角色—— // 回合失败的文案要靠这条 Err 回到界面,认证失败重试也只能挂在它上面。目标形状(草案见 @@ -54,27 +55,28 @@ pub(crate) async fn chat_with_game_creator_direct_codex( client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { + let root = Path::new(project_path.trim()); + let boundary_turn_id = client_turn_id.clone(); chat_with_game_creator_direct_codex_typed( - project_path, + root, user_item, creation_type, client_turn_id, analytics_attempt_id, ) .await - .map_err(String::from) + .map_err(|failure| direct_turn_error_boundary_text(root, boundary_turn_id.as_deref(), failure)) } -/// 命令主体:全程 typed,边界只在上面那层 `map_err(String::from)`。 +/// 命令主体:全程 typed,边界只在上面的 `map_err` 里做一次文本与留痕投影。 async fn chat_with_game_creator_direct_codex_typed( - project_path: String, + root: &Path, user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, analytics_attempt_id: Option, ) -> Result { let capture = crate::analytics::gui::capture_writer_context(); - let root = Path::new(project_path.trim()); let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs index bf7e0f5a0..8c41338e5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs @@ -405,6 +405,37 @@ impl DirectTurnError { } } + /// 命令边界要不要为这条**调用级拒绝**补一份运行错误诊断。 + /// + /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; + /// 空内容、`clientTurnId` 形状、另一轮在跑、权限策略、目录不是绝对路径都是用户的正常操作结果, + /// 留痕只会变成噪声。判据按变体分,不看文案。 + /// + /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 + /// 就是同一件事留两份。 + pub(crate) fn is_reportable(&self) -> bool { + match self { + // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 + Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, + // 项目目录锚不定是文件系统事实(符号链接 / 权限 / 目录被删),不是用户输入。 + Self::ProjectRootUnanchored { .. } => true, + Self::ClientTurnIdMissing + | Self::ClientTurnIdMalformed { .. } + | Self::TurnAlreadyRunning { .. } + | Self::ProjectRootUnusable + | Self::PermissionRejected { .. } + | Self::InputRejected { .. } + | Self::ContentEmpty + | Self::ModelCallFailed { .. } + | Self::TransportClosed { .. } + | Self::TimedOut { .. } + | Self::TurnInterrupted { .. } + | Self::ReviewRequired { .. } + | Self::TurnFailed { .. } + | Self::TurnFailedUnclassified { .. } => false, + } + } + /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 pub(crate) fn wire_kind(&self) -> Option<&'static str> { match self { @@ -909,6 +940,40 @@ fn direct_code_failure_recovery_hint( mod tests { use super::*; + /// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。 + #[test] + fn only_host_and_environment_rejections_are_reportable() { + assert!(DirectTurnError::EnvironmentNotReady { + detail: "Codex app-server 启动失败".into(), + } + .is_reportable()); + assert!(DirectTurnError::HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".into(), + } + .is_reportable()); + assert!(DirectTurnError::ProjectRootUnanchored { + cause: "拒绝访问".into(), + } + .is_reportable()); + assert!(!DirectTurnError::ContentEmpty.is_reportable()); + assert!(!DirectTurnError::ProjectRootUnusable.is_reportable()); + assert!(!DirectTurnError::ClientTurnIdMissing.is_reportable()); + assert!(!DirectTurnError::PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + } + .is_reportable()); + assert!(!DirectTurnError::TurnAlreadyRunning { + existing_invocation_id: "turn-1".into(), + incoming_invocation_id: "turn-2".into(), + } + .is_reportable()); + // 回合级失败在上游已经写过诊断。 + assert!( + !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") + .is_reportable() + ); + } + #[test] fn only_turn_failures_report_as_turn_failures() { assert!(!DirectTurnError::ContentEmpty.is_turn_failure()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 4497b61c8..c53810ca1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -975,8 +975,11 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { let reply_result = runtime.block_on(async { run_direct_game_creator_turn_at(&project_path, &prompt) .await - // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本。 - .map_err(String::from) + // CLI 也是命令边界:typed 错误在这里序列化成一行给终端看的文本;可留痕的 + // 调用级拒绝(宿主 / 环境事实)与 GUI 走同一份投影,带上诊断与 `详情:` 引用。 + .map_err(|failure| { + direct_turn_error_boundary_text(&project_path, None, failure) + }) }); let shutdown_result = shutdown_game_creator_codex_app_servers(); let reply = reply_result?;