From 394b8de9d2ef5ee8e891b12a972f84dc2db20cf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Fri, 2 Oct 2026 18:10:17 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=EF=BC=9ADirectProject=20?= =?UTF-8?q?=E5=9B=9E=E5=90=88=E9=94=99=E8=AF=AF=E5=8E=BB=E5=89=8D=E7=BC=80?= =?UTF-8?q?=E3=80=81=E6=8B=86=E5=88=86=20wire=20=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E3=80=81=E7=BB=88=E6=80=81=E6=94=B9=E5=88=A4=E5=88=AB=E8=81=94?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 三条错误表去冗余前缀:DirectTurnError→TurnError、DirectTurnEnqueueError→EnqueueError、DirectTurnFailure→TurnFailure、DirectTurnDeadline→Deadline、DirectCodexFailureStage→FailureStage、DirectCodexNativeKind→NativeKind、DirectModelCallKind→ModelCallKind - 用户条目去前缀:DirectCodexUserItem→UserItem 等六个类型;agent/direct_turn_error.rs 移到 codex_app_server/turn_error.rs - thread_manager/wire.rs 拆为 wire/{mod,clock,items,turn,failure,tests}.rs;direct_turn_failure.rs 的失败载荷并入 wire/failure.rs - 宿主侧回合终态从 struct TurnTerminal 改为 enum TurnCompletion{Completed,Interrupted,Aborted,Failed},从 wire 移到 thread_manager/turn_completion.rs,status 由变体反推 - 过期回合释放兜底改走 TurnCompletion::Aborted.event - 同步生成绑定、前端调用点与测试;修正终态单测里过时的 TransportClosed 断言 - 对 src-tauri 全量 cargo fmt,满足 pre-commit 的 rustfmt 门禁 --- .../src-tauri/src/agent.rs | 7 +- .../src-tauri/src/agent/claude_code_cli.rs | 12 +- .../src/agent/codex_app_server/execution.rs | 54 +- .../src/agent/codex_app_server/mod.rs | 442 ++--- .../turn_error.rs} | 960 +++++----- .../src/agent/direct_codex_user_item/mod.rs | 2 +- .../src/agent/direct_codex_user_item/model.rs | 42 +- .../direct_codex_user_item/validation.rs | 35 +- .../src/agent/direct_codex_user_item/wire.rs | 91 +- .../src-tauri/src/agent/direct_delivery.rs | 8 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 480 ++--- .../src/agent/direct_runtime/user_input.rs | 71 +- .../src/agent/direct_turn_failure.rs | 261 --- .../src/agent/runtime_driver/entrypoints.rs | 2 +- .../src/agent/thread_manager/dispatch.rs | 79 +- .../src-tauri/src/agent/thread_manager/mod.rs | 32 +- .../src/agent/thread_manager/queue.rs | 10 +- .../agent/thread_manager/turn_completion.rs | 306 ++++ .../src/agent/thread_manager/wire.rs | 1621 ----------------- .../src/agent/thread_manager/wire/clock.rs | 8 + .../src/agent/thread_manager/wire/failure.rs | 48 + .../src/agent/thread_manager/wire/items.rs | 635 +++++++ .../src/agent/thread_manager/wire/mod.rs | 31 + .../src/agent/thread_manager/wire/tests.rs | 576 ++++++ .../src/agent/thread_manager/wire/turn.rs | 359 ++++ .../src-tauri/src/commands/desktop.rs | 2 +- .../src/app/AuthenticatedClient.tsx | 9 +- .../src/app/expectNever.ts | 10 + apps/ai-game-creator-shell/src/app/types.ts | 2 +- .../ResourceReferenceInput.tsx | 31 +- .../project-workspace/chatPromptPolish.ts | 8 +- .../attachmentReferenceProvider.ts | 6 +- .../resourceReferenceProvider.ts | 4 +- .../runtimeRegionReferenceProvider.ts | 4 +- .../skillReferenceProvider.ts | 4 +- .../reference-source/types.ts | 6 +- .../project-workspace/resourceReferences.ts | 52 +- ...ResourceCanvasAssetGenerationPanelView.tsx | 10 +- .../chat/DirectProjectChatView.tsx | 4 +- .../DirectProjectComposer.tsx | 8 +- .../useDirectProjectChatController.ts | 120 +- .../conversation/directCodexConversation.ts | 83 +- .../chat/conversation/directPendingTurns.ts | 4 +- .../chat/conversation/directThreadChat.ts | 15 +- .../conversation/directTurnEnqueueFailure.ts | 124 ++ .../chat/conversation/directTurnFailure.ts | 163 +- .../chat/generated/ClientTurnIdMalformed.ts | 6 + .../{DirectTurnDeadline.ts => Deadline.ts} | 2 +- .../DirectCodexUserMessageEnvelope.ts | 0 .../generated/DirectCodexUserMessageItem.ts | 9 - .../generated/DirectTurnEnqueueFailure.ts | 20 - .../chat/generated/DirectTurnError.ts | 27 - .../chat/generated/DirectTurnFailure.ts | 20 - .../chat/generated/DirectTurnFailureKind.ts | 17 - .../chat/generated/EnqueueError.ts | 25 + .../chat/generated/EnvironmentNotReady.ts | 8 + ...ctCodexFailureStage.ts => FailureStage.ts} | 2 +- .../chat/generated/HostStateUnavailable.ts | 6 + .../chat/generated/InputRejected.ts | 6 + .../chat/generated/ModelCallFailed.ts | 7 + ...irectModelCallKind.ts => ModelCallKind.ts} | 17 +- ...DirectCodexNativeKind.ts => NativeKind.ts} | 2 +- .../chat/generated/PermissionRejected.ts | 6 + .../chat/generated/ProjectRootUnanchored.ts | 6 + .../chat/generated/QueueFull.ts | 6 + .../chat/generated/ThreadEvent.ts | 8 +- .../chat/generated/TimedOut.ts | 7 + .../chat/generated/TransportClosed.ts | 6 + .../chat/generated/TurnFailed.ts | 7 + .../chat/generated/TurnFailedUnclassified.ts | 6 + .../chat/generated/TurnFailure.ts | 32 + .../chat/generated/TurnInterrupted.ts | 6 + ...Part.ts => UserAttachmentReferencePart.ts} | 2 +- ...xUserContentPart.ts => UserContentPart.ts} | 14 +- .../{DirectCodexUserItem.ts => UserItem.ts} | 6 +- .../chat/generated/UserMessageItem.ts | 9 + .../{DirectCodexUserRole.ts => UserRole.ts} | 2 +- ...RegionPart.ts => UserRuntimeRegionPart.ts} | 2 +- .../tests/appSurface/chat-composer.suite.ts | 37 +- .../tests/appSurface/home.suite.ts | 72 +- .../appSurface/project-conversation.suite.ts | 114 +- .../tests/chatPromptPolish.test.tsx | 6 +- .../tests/directThreadChat.test.ts | 91 +- .../tests/referenceSourceProviders.test.ts | 6 +- .../resourceCanvasBottomToolbar.test.tsx | 4 +- ...urceCanvasGenerationFloatingPanel.test.tsx | 4 +- .../tests/resourceReferences.test.ts | 6 +- 87 files changed, 3900 insertions(+), 3580 deletions(-) rename apps/ai-game-creator-shell/src-tauri/src/agent/{direct_turn_error.rs => codex_app_server/turn_error.rs} (54%) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs create mode 100644 apps/ai-game-creator-shell/src/app/expectNever.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectTurnDeadline.ts => Deadline.ts} (80%) delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts delete mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexFailureStage.ts => FailureStage.ts} (92%) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectModelCallKind.ts => ModelCallKind.ts} (51%) rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexNativeKind.ts => NativeKind.ts} (96%) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailed.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailedUnclassified.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexUserAttachmentReferencePart.ts => UserAttachmentReferencePart.ts} (78%) rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexUserContentPart.ts => UserContentPart.ts} (61%) rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexUserItem.ts => UserItem.ts} (52%) create mode 100644 apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexUserRole.ts => UserRole.ts} (72%) rename apps/ai-game-creator-shell/src/view/project-development/chat/generated/{DirectCodexUserRuntimeRegionPart.ts => UserRuntimeRegionPart.ts} (87%) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 4f6e8b58c..cfaf85c3f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -31,8 +31,6 @@ mod direct_project_turn_history; mod direct_runtime; mod direct_tool_bridge; mod direct_tools_mcp; -mod direct_turn_error; -mod direct_turn_failure; mod direct_validation; mod generation; mod prompt; @@ -53,11 +51,12 @@ pub(crate) use claude_code_cli::{ game_creator_claude_code_cli_version_identity, }; pub(crate) use codex_app_server::direct_game_creator_codex_chat_at; +pub(crate) use codex_app_server::turn_error::*; use codex_app_server::*; #[cfg(not(test))] pub(crate) use codex_app_server::{ cancel_direct_codex_turn_at, direct_game_creator_home_codex_chat, thread_id_for_project, - DirectTurnCancelView, + TurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -73,8 +72,6 @@ pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tools_mcp::*; -pub(crate) use direct_turn_error::*; -pub(crate) use direct_turn_failure::*; pub(crate) use direct_validation::DirectValidationConfig; pub(crate) use generation::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs index cb7d67b89..8ef03fc80 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/claude_code_cli.rs @@ -313,7 +313,7 @@ async fn run_sidecar_turn( pub(crate) fn cancel_direct_claude_code_turn_at( root: &Path, client_turn_id: Option<&str>, -) -> Result, String> { +) -> Result, String> { let key = claude_project_key(root); let turns = active_claude_code_turns() .lock() @@ -332,7 +332,7 @@ pub(crate) fn cancel_direct_claude_code_turn_at( active.alive.store(false, Ordering::Release); kill_claude_code_process_tree(active.pid); let client_turn_id = active.client_turn_id.clone(); - Ok(Some(super::codex_app_server::DirectTurnCancelView { + Ok(Some(super::codex_app_server::TurnCancelView { outcome: super::codex_app_server::DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), message: "已向正在运行的 cc 回合发出终止".to_string(), client_turn_id, @@ -764,7 +764,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( system_prompt: String, user_prompt: String, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { direct_turn_trace("claude-executor-enter"); let (mcp_url, mcp_token) = start_external_mcp_loopback(root, llm.web_search_enabled).await?; @@ -829,7 +829,7 @@ pub(crate) async fn direct_game_creator_claude_code_chat_at( fn parse_direct_stream_result( stdout: &[u8], - mut observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + mut observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, ) -> Result { let text = std::str::from_utf8(stdout) .map_err(|_| "Claude Agent SDK stream-json 不是 UTF-8".to_string())?; @@ -857,7 +857,7 @@ fn parse_direct_stream_result( .collect::(); if !visible.is_empty() { if let Some(observer) = observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment(visible)); + observer(TurnObservation::AgentMessageSegment(visible)); } } } @@ -946,7 +946,7 @@ mod tests { assert_eq!(text, "最终回复"); assert!(matches!( observed.as_slice(), - [DirectCodexTurnObservation::AgentMessageSegment(_)] + [TurnObservation::AgentMessageSegment(_)] )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs index 72b02c321..656783c38 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/execution.rs @@ -1,7 +1,7 @@ //! Native / third-party approval adapter. The host execution session owns policy //! and persistence; this module only binds the app-server protocol to its leases. -use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError}; +use super::super::{direct_delivery, direct_execution, direct_validation, TurnError}; use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner}; use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession}; use serde_json::{json, Value}; @@ -152,12 +152,10 @@ pub(super) const HOST_OUTCOME_REPAIR_REQUIRED_DETAIL: &str = impl HostOutcomeText { /// 投影成这一轮的收尾结果:正常报告是文本,返修要求是控制流(走 `Err` 侧自己的变体)。 - pub(super) fn into_run_result(self) -> Result { + pub(super) fn into_run_result(self) -> Result { match self { Self::Report(text) => Ok(text), - Self::RepairRequired { detail } => { - Err(super::DirectTurnRunFailure::RepairRequired { detail }) - } + Self::RepairRequired { detail } => Err(super::RunFailure::RepairRequired { detail }), } } } @@ -246,7 +244,7 @@ pub(super) struct ExecutionAdapter { outcome: watch::Sender>, /// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败, /// 原因与交付报告同一份文本。 - turn_failure: Mutex>, + turn_failure: Mutex>, /// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。 host_stop_requested: AtomicBool, /// 已留痕的拒绝原因(`method\u{1}reason`)。同一回合内同因只记一次,避免模型重试刷屏。 @@ -921,8 +919,8 @@ impl ExecutionAdapter { /// /// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由 /// 不得覆盖它。 - pub(super) async fn fail_turn(&self, failure: DirectTurnError) { - let reason = failure.to_string(); + pub(super) async fn fail_turn(&self, failure: TurnError) { + let reason = failure.diagnostic_detail(); if self.is_closed() { // 宿主自己收尾:连接是我们先关的,紧随其后的 `TransportClosed` 只是收尾的副产物。 // 只把原因留给报告,不改阶段——否则正常的宿主收尾会被改写成 `interrupted` @@ -944,7 +942,7 @@ impl ExecutionAdapter { } /// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。 - pub(super) fn turn_failure(&self) -> Option { + pub(super) fn turn_failure(&self) -> Option { self.turn_failure.lock().ok().and_then(|slot| slot.clone()) } @@ -1041,7 +1039,7 @@ impl ExecutionAdapter { pub(super) fn lifecycle_status(&self, fallback: &str) -> String { // 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由 - // `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 + // `turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的 // `Interrupted` 抹成一次没有原因的"已结束"。 match self.session.snapshot().map(|state| state.phase) { Ok(ExecutionPhase::Completed) => "completed", @@ -1335,7 +1333,7 @@ pub(super) async fn wait_outcome( #[cfg(test)] mod tests { - use super::super::DirectTurnDeadline; + use super::super::{Deadline, TimedOut, TransportClosed}; use super::*; @@ -1391,34 +1389,28 @@ mod tests { assert!(!adapter.host_stop_requested()); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }) + })) .await; // 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。 let failure = adapter.turn_failure().expect("host fact must be recorded"); - assert_eq!( - failure.wire_kind(), - Some(super::super::DirectTurnFailureKind::TransportFailed) - ); - assert!(failure.to_string().contains("SIGKILL")); + assert!(matches!(failure, TurnError::TransportClosed(_))); + assert!(failure.diagnostic_detail().contains("SIGKILL")); // 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。 assert!(adapter.report().contains("SIGKILL")); // 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。 adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) .await; let failure = adapter.turn_failure().expect("first reason is kept"); - assert_eq!( - failure.wire_kind(), - Some(super::super::DirectTurnFailureKind::TransportFailed) - ); - assert!(failure.to_string().contains("SIGKILL")); - assert!(!failure.to_string().contains("超时")); + assert!(matches!(failure, TurnError::TransportClosed(_))); + assert!(failure.diagnostic_detail().contains("SIGKILL")); + assert!(!failure.diagnostic_detail().contains("超时")); } /// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道 @@ -1430,9 +1422,9 @@ mod tests { adapter.closed.store(true, Ordering::Release); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "模型本次执行结束,回收原生后台子树".into(), - }) + })) .await; assert!(adapter.turn_failure().is_none()); @@ -1457,9 +1449,9 @@ mod tests { adapter.request_host_stop(); adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(), - }) + })) .await; assert!(adapter.turn_failure().is_none()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 3abf9ef84..f2bf50f2a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -16,6 +16,7 @@ use process_tree::{OwnedProcessTree, ProcessTreeExitProof}; mod direct_project_identity; mod execution; mod model_catalog; +pub(crate) mod turn_error; pub(crate) use direct_project_identity::thread_id_for_project; use direct_project_identity::*; use execution::{ExecutionAdapter, ResponseDeliveryFailure}; @@ -640,7 +641,7 @@ enum CodexTurnEvent { } #[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) enum DirectCodexTurnObservation { +pub(crate) enum TurnObservation { AccumulatedText(String), /// 一段可见的 assistant 正文(同一 assistant item 的当前累计全文)。 AgentMessageSegment(String), @@ -800,7 +801,7 @@ fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, ) -> Option { - thread_item_from_value(root, item, direct_now_ms()) + thread_item_from_value(root, item, now_ms()) } /// 运行态条目投影:Codex 回显的用户消息整条跳过。 @@ -1639,7 +1640,7 @@ async fn stage_codex_app_server_image( } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum DirectCodexTurnKind { +pub(crate) enum TurnKind { User, HostFeedback, } @@ -1648,14 +1649,14 @@ async fn direct_project_turn_input( request: &LlmRunRequest, prompt: &str, workspace_path: &std::path::Path, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, original_user_item: Option<&serde_json::Value>, skill_roots: &[std::path::PathBuf], ) -> Result { // 原始用户条目一直用于历史和事件关联,但只有首次请求将它作为模型输入。 - if turn_kind == DirectCodexTurnKind::User { + if turn_kind == TurnKind::User { if let Some(item) = original_user_item { - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + let canonical: UserItem = serde_json::from_value(item.clone()) .map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?; return direct_codex_user_item_to_codex_turn_input( workspace_path, @@ -3302,7 +3303,7 @@ impl CodexAppServerConnection { .await // 非 Direct 入口没有"继续返修批次"这条控制流(那是封口复核才有的),遇上只当一次普通的 // 请求被拒;Direct 回合走下面的 `_and_history` 版本,控制流在那里有自己的变体。 - .map_err(DirectTurnRunFailure::into_llm_error) + .map_err(RunFailure::into_llm_error) } async fn run_turn_with_direct_observer( @@ -3311,8 +3312,8 @@ impl CodexAppServerConnection { llm: &GameCreatorLlmConfig, request: LlmRunRequest, on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, - direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + direct_observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) -> Result { self.run_turn_with_direct_observer_and_history( snapshot, llm, @@ -3320,7 +3321,7 @@ impl CodexAppServerConnection { None, None, None, - DirectCodexTurnKind::User, + TurnKind::User, on_agent_message_delta, direct_observer, ) @@ -3335,10 +3336,10 @@ impl CodexAppServerConnection { direct_history_root: Option<&std::path::Path>, direct_client_turn_id: Option<&str>, direct_user_item: Option<&serde_json::Value>, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, - mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, - ) -> Result { + mut direct_observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, + ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); // 回合身份用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), @@ -3356,11 +3357,9 @@ impl CodexAppServerConnection { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::InvalidRequest( - "DirectProject 用户消息不能为空".to_string(), - ), - )); + return Err(RunFailure::from(platform_llm::LlmError::InvalidRequest( + "DirectProject 用户消息不能为空".to_string(), + ))); } if let Some(client_turn_id) = direct_client_turn_id { let user_item = match direct_user_item { @@ -3419,7 +3418,7 @@ impl CodexAppServerConnection { if let Some(outcome) = adapter.finish_model_attempt(&self.inner, false).await { let text = execution::outcome_text(outcome).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -3431,9 +3430,7 @@ impl CodexAppServerConnection { Ok(Some(params)) => { if let Err(error) = self.request("thread/inject_items", params).await { self.release_thread(snapshot, &thread_id).await; - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport(error), - )); + return Err(RunFailure::from(platform_llm::LlmError::Transport(error))); } } Err(error) => { @@ -3574,9 +3571,9 @@ impl CodexAppServerConnection { let text = execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, format!("turn/start 终态未知:{error}"), @@ -3592,7 +3589,7 @@ impl CodexAppServerConnection { { Some(turn_id) => turn_id.to_string(), None => { - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "turn/start 响应缺少 turn.id", @@ -3609,7 +3606,7 @@ impl CodexAppServerConnection { let text = execution::outcome_text(adapter.wait_outcome().await).into_run_result()?; return parse_game_creator_codex_app_server_text(&text, &thread_id, &request) - .map_err(DirectTurnRunFailure::from); + .map_err(RunFailure::from); } } turn_start_guard.armed = false; @@ -3627,7 +3624,7 @@ impl CodexAppServerConnection { // `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能 // 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与 // `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。 - let direct_turn_started_at_ms = direct_now_ms(); + let direct_turn_started_at_ms = now_ms(); let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); let mut guard = CodexTurnGuard { @@ -3658,9 +3655,9 @@ impl CodexAppServerConnection { if let Some(adapter) = approval_adapter.as_ref() { // 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。 adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::TurnHardLimit, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + })) .await; return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); @@ -3690,14 +3687,14 @@ impl CodexAppServerConnection { Err(_) => { if let Some(adapter) = approval_adapter.as_ref() { adapter - .fail_turn(DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::ResponseIdle, - }) + .fail_turn(TurnError::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) .await; return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "等待 turn/completed 超时", @@ -3724,17 +3721,13 @@ impl CodexAppServerConnection { } streamed_text.push_str(&delta); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AccumulatedText( - streamed_text.clone(), - )); + observer(TurnObservation::AccumulatedText(streamed_text.clone())); // 同一 assistant item 的当前累计全文,不能拿"整轮累计"当一段。 let segment_text = direct_project_history .accumulated_text_for(&item_id) .unwrap_or_else(|| delta.clone()); if !segment_text.trim().is_empty() { - observer(DirectCodexTurnObservation::AgentMessageSegment( - segment_text, - )); + observer(TurnObservation::AgentMessageSegment(segment_text)); } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { @@ -3762,17 +3755,15 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::IntermediateText(text)); + observer(TurnObservation::IntermediateText(text)); } } Some(CodexTurnEvent::RawItem(item)) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { if item.is_null() { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Deserialize( - "rawResponseItem/completed 缺少 item".to_string(), - ), - )); + return Err(RunFailure::from(platform_llm::LlmError::Deserialize( + "rawResponseItem/completed 缺少 item".to_string(), + ))); } let entry_item = direct_thread_visible_item(history_root, &item); let history_root = history_root.to_path_buf(); @@ -3793,7 +3784,7 @@ impl CodexAppServerConnection { // 通知的钟就是该阶段唯一可证明的时间。 append_thread_event( &direct_thread_id, - ThreadEvent::item_completed(entry_item, direct_now_ms()), + ThreadEvent::item_completed(entry_item, now_ms()), ); } } @@ -3814,7 +3805,7 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::Activity(activity)) => { if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::Activity(activity)); + observer(TurnObservation::Activity(activity)); } } Some(CodexTurnEvent::Item { completed, params }) => { @@ -3824,7 +3815,7 @@ impl CodexAppServerConnection { .and_then(serde_json::Value::as_str) .unwrap_or_default(); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::Activity( + observer(TurnObservation::Activity( direct_codex_safe_activity_for_item_value(item), )); // item/started 在工具真正开始执行时到达,携带 @@ -3834,12 +3825,10 @@ impl CodexAppServerConnection { if !completed { if let Some(reasoning) = direct_codex_item_reasoning_text(item) { - observer(DirectCodexTurnObservation::Reasoning(reasoning)); + observer(TurnObservation::Reasoning(reasoning)); } if let Some(text) = direct_codex_item_intermediate_text(item) { - observer(DirectCodexTurnObservation::IntermediateText( - text, - )); + observer(TurnObservation::IntermediateText(text)); } } } @@ -3863,11 +3852,9 @@ impl CodexAppServerConnection { .filter(|value| !value.trim().is_empty()) { if let Some(observer) = direct_observer.as_deref_mut() { - observer( - DirectCodexTurnObservation::AgentMessageSegment( - text.to_string(), - ), - ); + observer(TurnObservation::AgentMessageSegment( + text.to_string(), + )); } } } @@ -3884,7 +3871,7 @@ impl CodexAppServerConnection { "userMessage" | "plan" | "reasoning" | "contextCompaction" ) { - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( platform_llm::LlmError::InvalidRequest(format!( "Codex app-server 违反 {}边界,产生非被动 item {item_type}", self.inner.workspace_mode.passive_item_boundary_name(), @@ -3904,12 +3891,7 @@ impl CodexAppServerConnection { &direct_thread_id, ThreadEvent::item_started( entry_item, - thread_item_event_at_ms( - ¶ms, - item, - false, - direct_now_ms(), - ), + thread_item_event_at_ms(¶ms, item, false, now_ms()), ), ); } @@ -3933,7 +3915,7 @@ impl CodexAppServerConnection { { final_text = Some(text.to_string()); if let Some(observer) = direct_observer.as_deref_mut() { - observer(DirectCodexTurnObservation::AgentMessageSegment( + observer(TurnObservation::AgentMessageSegment( text.to_string(), )); } @@ -3952,7 +3934,7 @@ impl CodexAppServerConnection { thread_turn_completed_at_ms( turn, Some(direct_turn_started_at_ms), - direct_now_ms(), + now_ms(), ), )); } @@ -3968,9 +3950,7 @@ impl CodexAppServerConnection { return final_text .filter(|text| !text.trim().is_empty()) .ok_or_else(|| { - DirectTurnRunFailure::from( - platform_llm::LlmError::EmptyResponse, - ) + RunFailure::from(platform_llm::LlmError::EmptyResponse) }); } "interrupted" => { @@ -3981,17 +3961,19 @@ impl CodexAppServerConnection { // 不在这里再写一遍。 if !adapter.is_host_ending() { adapter - .fail_turn(DirectTurnError::TurnInterrupted { - detail: - "本轮模型执行被中断,正在核对自有后台进程。" - .into(), - }) + .fail_turn(TurnError::TurnInterrupted( + TurnInterrupted { + detail: + "本轮模型执行被中断,正在核对自有后台进程。" + .into(), + }, + )) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( platform_llm::LlmError::InvalidRequest( "Codex app-server turn 已中断".to_string(), ), @@ -4016,14 +3998,12 @@ impl CodexAppServerConnection { } } } - return Err(DirectTurnRunFailure::from(native)); + return Err(RunFailure::from(native)); } status => { - return Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Deserialize(format!( - "Codex app-server turn/completed 状态无效:{status}" - )), - )) + return Err(RunFailure::from(platform_llm::LlmError::Deserialize( + format!("Codex app-server turn/completed 状态无效:{status}"), + ))) } } } @@ -4034,15 +4014,15 @@ impl CodexAppServerConnection { // 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态 // 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。 adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: error.clone(), - }) + })) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, error, @@ -4056,15 +4036,15 @@ impl CodexAppServerConnection { // 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能 // 以失败收口,不能报成"被中断"。 adapter - .fail_turn(DirectTurnError::TransportClosed { + .fail_turn(TurnError::TransportClosed(TransportClosed { diagnostic: "Codex app-server turn 事件通道已关闭".into(), - }) + })) .await; } return execution::outcome_text(adapter.wait_outcome().await) .into_run_result(); } - return Err(DirectTurnRunFailure::from( + return Err(RunFailure::from( isolate_game_creator_codex_app_server_terminal_unknown( &self.inner, "Codex app-server turn 事件通道已关闭", @@ -4075,13 +4055,13 @@ impl CodexAppServerConnection { } } }; - let mut collect_result: Result = collect.await; + let mut collect_result: Result = collect.await; // Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。 - let mut direct_terminal: Option = None; + let mut direct_terminal: Option = None; // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 - if let (Some(adapter), Err(DirectTurnRunFailure::Failed(error))) = + if let (Some(adapter), Err(RunFailure::Failed(error))) = (approval_adapter.as_ref(), &collect_result) { if !adapter.is_settled() { @@ -4102,11 +4082,9 @@ impl CodexAppServerConnection { }) .await .unwrap_or_else(|_| { - Err(DirectTurnRunFailure::from( - platform_llm::LlmError::Transport( - "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), - ), - )) + Err(RunFailure::from(platform_llm::LlmError::Transport( + "DirectProject 收尾历史任务退出,未确认历史完整落盘".into(), + ))) }); let fallback_status = model_terminal .as_ref() @@ -4122,12 +4100,12 @@ impl CodexAppServerConnection { .unwrap_or_else(|| fallback_status.to_string()); // 有执行许可时,起止时间包含实际宿主收尾;上游模型完成不能提前结束 UI。 let completed_at = if approval_adapter.is_some() { - direct_now_ms() + now_ms() } else { model_terminal .as_ref() .map(|(_, at)| *at) - .unwrap_or_else(direct_now_ms) + .unwrap_or_else(now_ms) }; // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` @@ -4137,7 +4115,7 @@ impl CodexAppServerConnection { // 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败), // 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把 // 已经失败的回合讲成"已结束"。 - direct_terminal = Some(DirectTurnTerminalContext { + direct_terminal = Some(TerminalContext { status, completed_at, host_failure: approval_adapter @@ -4157,11 +4135,11 @@ impl CodexAppServerConnection { // 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是 // `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有 // 回复、没有任何解释"。 - let turn_result: Result = match collect_result { + let turn_result: Result = match collect_result { Ok(text) => match parse_game_creator_codex_app_server_text(&text, &thread_id, &request) { - Ok(response) => Ok(DirectTurnReport { text, response }), - Err(error) => Err(DirectTurnRunFailure::Failed(error)), + Ok(response) => Ok(TurnReport { text, response }), + Err(error) => Err(RunFailure::Failed(error)), }, Err(failure) => Err(failure), }; @@ -4196,20 +4174,20 @@ impl CodexAppServerConnection { /// `LlmError::InvalidRequest("validation-source-changed: …")`,于是和真失败共用一条投影——终态被 /// 判成 `failed`、界面收到一条用户可见的失败说明,外层还会把它当"可修复错误"再喂给模型。 #[derive(Debug)] -enum DirectTurnRunFailure { +enum RunFailure { /// 这一轮真的失败了(模型 / 传输 / 交付 / 解析)。 Failed(platform_llm::LlmError), /// 封口复核要求继续当前返修批次:**不写终态**,由调用方把 `detail` 写回提示词继续跑。 RepairRequired { detail: String }, } -impl From for DirectTurnRunFailure { +impl From for RunFailure { fn from(error: platform_llm::LlmError) -> Self { Self::Failed(error) } } -impl DirectTurnRunFailure { +impl RunFailure { /// 压回 `LlmError`:只给拿不到"继续返修批次"这条控制流的入口用(非 Direct 的 `run_turn`)。 fn into_llm_error(self) -> platform_llm::LlmError { match self { @@ -4221,19 +4199,17 @@ impl DirectTurnRunFailure { /// 这一轮要不要写终态、写什么内容。 /// -/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定); +/// - `report` 有值:正常终态(报告正文交给 `turn_terminal` 兜底判定); /// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影; /// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 /// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 fn direct_turn_terminal_write<'a>( report: Option<&'a str>, - failure: Option<&DirectTurnRunFailure>, -) -> Option> { + failure: Option<&RunFailure>, +) -> Option> { match failure { - Some(DirectTurnRunFailure::Failed(error)) => { - Some(Err(DirectTurnError::from_model_call(error))) - } - Some(DirectTurnRunFailure::RepairRequired { .. }) => None, + Some(RunFailure::Failed(error)) => Some(Err(TurnError::from_model_call(error))), + Some(RunFailure::RepairRequired { .. }) => None, None => report.map(Ok), } } @@ -4242,7 +4218,7 @@ fn direct_turn_terminal_write<'a>( /// /// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份 /// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。 -struct DirectTurnReport { +struct TurnReport { /// 可展示的回复 / 交付报告正文。 text: String, /// 解析后的响应。 @@ -4253,23 +4229,23 @@ struct DirectTurnReport { /// /// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上 /// 无解——前端只会看到一次没有解释的"已结束"。 -struct DirectTurnTerminalContext { +struct TerminalContext { /// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。 status: String, /// 终态事件的宿主观测时刻。 completed_at: u64, /// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。 - host_failure: Option, + host_failure: Option, /// 逻辑回合身份与开口的用户条目身份。 thread_id: String, user_item_id: Option, } -impl DirectTurnTerminalContext { +impl TerminalContext { /// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果: /// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。 - fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) { - let terminal = direct_turn_terminal( + fn write(&self, collect_outcome: Result<&str, TurnError>, history_root: &Path) { + let terminal = turn_terminal( &self.status, collect_outcome, self.host_failure.as_ref(), @@ -4283,10 +4259,10 @@ impl DirectTurnTerminalContext { } } -impl std::fmt::Display for DirectTurnRunFailure { +impl std::fmt::Display for RunFailure { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Failed(error) => write!(formatter, "{error}"), + Self::Failed(error) => formatter.write_str(&error.to_string()), Self::RepairRequired { detail } => formatter.write_str(detail), } } @@ -4295,8 +4271,8 @@ impl std::fmt::Display for DirectTurnRunFailure { fn finish_direct_project_collect_history( root: &Path, mut history: DirectProjectHistoryAccumulator, - result: Result, -) -> Result { + result: Result, +) -> Result { // 宿主预算/交付终态也会返回 Ok(report),同样需要保留被中断的流式正文。 if let Err(persist_error) = persist_direct_project_partial_items_at(root, &mut history) { let prior = result @@ -4304,11 +4280,9 @@ fn finish_direct_project_collect_history( .err() .map(|error| format!(";原始回合错误:{error}")) .unwrap_or_default(); - return Err(DirectTurnRunFailure::Failed( - platform_llm::LlmError::Transport(format!( - "DirectProject 收尾历史失败:{persist_error}{prior}" - )), - )); + return Err(RunFailure::Failed(platform_llm::LlmError::Transport( + format!("DirectProject 收尾历史失败:{persist_error}{prior}"), + ))); } result } @@ -4330,11 +4304,11 @@ impl Drop for CodexTurnStartGuard { } /// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。 -struct DirectCodexActiveTurnTable { +struct ActiveTurnTable { entries: HashMap, } -impl DirectCodexActiveTurnTable { +impl ActiveTurnTable { fn new() -> Self { Self { entries: HashMap::new(), @@ -4390,15 +4364,15 @@ const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另 /// /// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 /// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由 -/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 +/// [`ActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock< - std::sync::Mutex>>, + std::sync::Mutex>>, > = OnceLock::new(); fn direct_codex_active_turns( -) -> &'static std::sync::Mutex>> { +) -> &'static std::sync::Mutex>> { GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS - .get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new())) + .get_or_init(|| std::sync::Mutex::new(ActiveTurnTable::new())) } /// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀), @@ -4414,12 +4388,12 @@ fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf { } } -struct DirectCodexActiveTurnGuard { +struct ActiveTurnGuard { key: std::path::PathBuf, cancellation: Arc, } -impl Drop for DirectCodexActiveTurnGuard { +impl Drop for ActiveTurnGuard { fn drop(&mut self) { let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else { return; @@ -4437,11 +4411,11 @@ fn register_active_direct_codex_turn( key: std::path::PathBuf, client_turn_id: &str, cancellation: Arc, -) -> DirectCodexActiveTurnGuard { +) -> ActiveTurnGuard { if let Ok(mut entries) = direct_codex_active_turns().lock() { entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation)); } - DirectCodexActiveTurnGuard { key, cancellation } + ActiveTurnGuard { key, cancellation } } /// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 @@ -4452,7 +4426,7 @@ pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; /// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct DirectTurnCancelView { +pub(crate) struct TurnCancelView { /// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或 /// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。 pub(crate) outcome: String, @@ -4463,7 +4437,7 @@ pub(crate) struct DirectTurnCancelView { } /// "终止"这一步要作用在哪:发中断,还是走残留回合的兜底释放。 -enum DirectCodexTurnCancelTarget { +enum TurnCancelTarget { /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 Interrupt(Arc), /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 @@ -4489,7 +4463,7 @@ enum DirectCodexTurnCancelTarget { pub(crate) fn cancel_direct_codex_turn_at( root: &Path, client_turn_id: Option<&str>, -) -> Result { +) -> Result { if let Some(view) = cancel_direct_claude_code_turn_at(root, client_turn_id)? { return Ok(view); } @@ -4515,18 +4489,16 @@ pub(crate) fn cancel_direct_codex_turn_at( .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; match entries.select(&key, client_turn_id) { Ok((_, cancellation)) if cancellation.app_server_alive() => { - DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) - } - Ok(_) => DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), - Err(_) => { - DirectCodexTurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor) + TurnCancelTarget::Interrupt(Arc::clone(cancellation)) } + Ok(_) => TurnCancelTarget::Stale(StaleTurnReleaseReason::ExecutorExited), + Err(_) => TurnCancelTarget::Stale(StaleTurnReleaseReason::NeverReachedExecutor), } }; match target { - DirectCodexTurnCancelTarget::Interrupt(cancellation) => { + TurnCancelTarget::Interrupt(cancellation) => { cancellation.cancel(); - Ok(DirectTurnCancelView { + Ok(TurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), message: "已向正在运行的回合发出终止".to_string(), client_turn_id: client_turn_id @@ -4536,7 +4508,7 @@ pub(crate) fn cancel_direct_codex_turn_at( .to_string(), }) } - DirectCodexTurnCancelTarget::Stale(reason) => { + TurnCancelTarget::Stale(reason) => { // 校验与释放**同一个临界区**(确实登记着一轮、身份对得上、过了启动窗口才允许判成残留; // 占用与兜底终态要么一起落地,要么一个字都不写)。校验完了再单独释放不行:两步之间并发 // 认领可能已经登记了新那一轮的占用,无条件解除就会清掉它。 @@ -4544,7 +4516,7 @@ pub(crate) fn cancel_direct_codex_turn_at( // 这一轮不会再有人替它收尾(占用刚被兜底解除):踢一脚让队列继续。 // 少了这一脚,排在这条后面的待发消息要等到下一次用户动作才会被放行。 crate::agent::kick_queue_dispatch(root); - Ok(DirectTurnCancelView { + Ok(TurnCancelView { outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), message: format!( "{},已释放这一轮的占用,可以直接重新发送消息", @@ -5179,14 +5151,14 @@ async fn fail_game_creator_codex_app_server_connection( // 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再让"连接 // 已死"对看门狗可见(`shutdown_game_creator_codex_app_server_inner` 才置 `closed`)。顺序不能 // 反——执行适配器的看门狗盯着 `closed`,它一旦先醒就会把本轮收束成"被中断";而失败事实是在 - // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `DirectTurnTerminalContext`), + // 模型终态那一刻被**快照**进终态上下文的(见 `run_turn` 里的 `TerminalContext`), // 晚一步补记没有意义,界面只会看到"本轮已结束、没有原因"。 // 这一段中间有两次加锁和一个日志写,都可能让出线程;认领标志保证只有第一个观察者走到这里。 record_execution_turn_failure( &inner, - DirectTurnError::TransportClosed { + TurnError::TransportClosed(TransportClosed { diagnostic: diagnostic.clone(), - }, + }), ) .await; match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await { @@ -5199,7 +5171,7 @@ async fn fail_game_creator_codex_app_server_connection( /// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server /// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放) /// 就是空操作。 -async fn record_execution_turn_failure(inner: &Arc, failure: DirectTurnError) { +async fn record_execution_turn_failure(inner: &Arc, failure: TurnError) { let adapter = { let slot = match inner.execution.lock() { Ok(slot) => slot, @@ -5285,12 +5257,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, -) -> Result { +) -> Result { direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, user_prompt, - DirectCodexTurnKind::User, + TurnKind::User, None, None, None, @@ -5302,17 +5274,17 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( root: &std::path::Path, system_prompt: String, user_prompt: String, - turn_kind: DirectCodexTurnKind, + turn_kind: TurnKind, client_turn_id: Option<&str>, - observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, + observer: Option<&mut (dyn FnMut(TurnObservation) + Send)>, direct_user_item: Option, -) -> Result { +) -> Result { // Resolve project authority before deriving the pool/thread identity. A // caller may hold a stable symlink path whose target changes between // projects, or replace the project manifest in-place; raw path text alone // must never select a connection created for the previous project. let (canonical_root, project_id) = direct_codex_canonical_project_identity(root) - .map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?; + .map_err(|cause| TurnError::ProjectRootUnanchored(ProjectRootUnanchored { cause }))?; let codex_root = if let Some(path) = canonical_root .to_str() .and_then(|value| value.strip_prefix("\\\\?\\")) @@ -5322,10 +5294,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( canonical_root.clone() }; let config = load_game_creator_app_config() - .map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?; + .map_err(|detail| TurnError::EnvironmentNotReady(EnvironmentNotReady { detail }))?; if config.agent_mode == GAME_CREATOR_AGENT_MODE_CLAUDE_CODE_CLI { if let Some(error) = game_creator_claude_code_cli_route_error(&config.llm) { - return Err(DirectTurnError::EnvironmentNotReady { detail: error }); + return Err(TurnError::EnvironmentNotReady(EnvironmentNotReady { + detail: error, + })); } return direct_game_creator_claude_code_chat_at( &codex_root, @@ -5336,14 +5310,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( observer, ) .await - .map_err(|detail| { - DirectTurnError::from_model_call(&platform_llm::LlmError::Transport(detail)) - }); + .map_err(|detail| TurnError::from_model_call(&platform_llm::LlmError::Transport(detail))); } game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| { - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; // 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。 let generated_client_turn_id; @@ -5357,10 +5329,12 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|state| state.client_turn_id) }) .await - .map_err(|_| DirectTurnError::HostStateUnavailable { - detail: "宿主 CLI 回合身份读取中断".to_string(), + .map_err(|_| { + TurnError::HostStateUnavailable(HostStateUnavailable { + detail: "宿主 CLI 回合身份读取中断".to_string(), + }) })? - .map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?; + .map_err(|detail| TurnError::HostStateUnavailable(HostStateUnavailable { detail }))?; Some(generated_client_turn_id.as_str()) } }; @@ -5381,9 +5355,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( allow_idle_context_compaction: false, }; let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| { - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; let connection = Box::pin(CodexAppServerConnection::acquire_at_workspace( &snapshot, @@ -5395,9 +5369,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .await; let connection = connection.map_err(|error| { // 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。 - DirectTurnError::EnvironmentNotReady { + TurnError::EnvironmentNotReady(EnvironmentNotReady { detail: error.to_string(), - } + }) })?; let request = LlmRunRequest::single_turn(system_prompt, user_prompt) .with_api_kind(api_kind) @@ -5420,10 +5394,8 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer( .map(|value| value.text) // 真失败才投影成回合失败;"继续返修批次"这条控制流有自己的变体,直接交给调用方。 .map_err(|failure| match failure { - DirectTurnRunFailure::Failed(error) => DirectTurnError::from_model_call(&error), - DirectTurnRunFailure::RepairRequired { detail } => { - DirectTurnError::RepairRequired { detail } - } + RunFailure::Failed(error) => TurnError::from_model_call(&error), + RunFailure::RepairRequired { detail } => TurnError::RepairRequired { detail }, }) } @@ -5594,7 +5566,7 @@ mod tests { Path::new("not-read"), String::new(), String::new(), - DirectCodexTurnKind::User, + TurnKind::User, Some("not-executed"), None, None, @@ -5618,7 +5590,7 @@ mod tests { /// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。 #[test] fn direct_codex_active_turn_table_selects_only_the_running_turn() { - let mut table: DirectCodexActiveTurnTable = DirectCodexActiveTurnTable::new(); + let mut table: ActiveTurnTable = ActiveTurnTable::new(); let key = std::path::PathBuf::from("C:/projects/direct-turn-demo"); assert_eq!( table.select(&key, None).expect_err("no active turn"), @@ -6271,7 +6243,7 @@ mod tests { /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 #[test] fn terminal_write_skips_the_repair_request_and_projects_real_failures() { - let repair = DirectTurnRunFailure::RepairRequired { + let repair = RunFailure::RepairRequired { detail: "继续当前返修批次".into(), }; assert!( @@ -6279,28 +6251,29 @@ mod tests { "返修要求是控制流,不允许写终态" ); - let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport( - "执行通道已断开".into(), - )); + let failed = RunFailure::Failed(platform_llm::LlmError::Transport("执行通道已断开".into())); let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); - let error = payload.expect_err("失败终态必须带载荷"); - assert_eq!( - error.wire_kind(), - Some(crate::agent::DirectTurnFailureKind::TransportFailed) - ); + match payload.expect_err("失败终态必须带载荷") { + // 传输类 `LlmError` 投影成 `ModelCallFailed` 的 `TransportBroken`:载荷带 typed 分类, + // 不是另立一个"通道关闭"变体。 + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, ModelCallKind::TransportBroken) + } + other => panic!("执行通道断开必须投影成模型调用失败,得到 {other:?}"), + } // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` // 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。 - let parse_failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Deserialize( + let parse_failed = RunFailure::Failed(platform_llm::LlmError::Deserialize( "Codex app-server structured output 不是严格 JSON".into(), )); let payload = direct_turn_terminal_write(None, Some(&parse_failed)).expect("解析失败必须写终态"); - assert_eq!( - payload.expect_err("解析失败必须带载荷").wire_kind(), - Some(crate::agent::DirectTurnFailureKind::ModelFailed) - ); + assert!(matches!( + payload.expect_err("解析失败必须带载荷"), + TurnError::ModelCallFailed(_) + )); let payload = direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态"); @@ -6425,7 +6398,7 @@ mod tests { &request, &direct_codex_user_prompt(&request), temp.path(), - DirectCodexTurnKind::User, + TurnKind::User, Some(&user_item), &skill_roots, ) @@ -6449,7 +6422,7 @@ mod tests { &request, &direct_codex_user_prompt(&request), temp.path(), - DirectCodexTurnKind::HostFeedback, + TurnKind::HostFeedback, Some(&user_item), &skill_roots, ) @@ -6474,12 +6447,9 @@ mod tests { ) .expect("freeze original CLI input"); for (kind, prompt) in [ - (DirectCodexTurnKind::User, "请创建菜单"), - ( - DirectCodexTurnKind::HostFeedback, - "宿主验收未通过:缺少玩法证据", - ), - (DirectCodexTurnKind::HostFeedback, "构建失败:请修复入口"), + (TurnKind::User, "请创建菜单"), + (TurnKind::HostFeedback, "宿主验收未通过:缺少玩法证据"), + (TurnKind::HostFeedback, "构建失败:请修复入口"), ] { // 与发送入口一样,每次先幂等落盘原始用户条目,再构造本次模型输入。 append_direct_project_user_message_at(temp.path(), &user_item) @@ -6520,16 +6490,10 @@ mod tests { let prompt = codex_app_server_text_prompt(&request).expect("sanitized prompt"); assert!(!prompt.contains("data:image")); assert!(prompt.contains("原生视觉输入")); - let input = direct_project_turn_input( - &request, - &prompt, - temp.path(), - DirectCodexTurnKind::User, - None, - &[], - ) - .await - .expect("turn input"); + let input = + direct_project_turn_input(&request, &prompt, temp.path(), TurnKind::User, None, &[]) + .await + .expect("turn input"); assert_eq!(input[0]["type"], "text"); assert_eq!(input[1]["type"], "localImage"); let staged_path = input[1]["path"].as_str().expect("staged path"); @@ -8033,45 +7997,33 @@ while IFS= read -r line; do :; done assert_eq!(streamed, "{\"toolCalls\":"); assert_eq!( observations.first(), - Some(&DirectCodexTurnObservation::Activity("preparing")), + Some(&TurnObservation::Activity("preparing")), "turn/started must produce safe activity before terminal completion" ); let delta_index = observations .iter() .position(|observation| { - *observation - == DirectCodexTurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) + *observation == TurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) }) .expect("agent message delta observation"); assert!( observations[..delta_index] .iter() - .filter(|observation| matches!( - observation, - DirectCodexTurnObservation::Activity(_) - )) + .filter(|observation| matches!(observation, TurnObservation::Activity(_))) .count() >= 4, "real long-tool protocol activity must be visible before final answer delta" ); + assert!(observations + .iter() + .any(|observation| { matches!(observation, TurnObservation::Activity("file-write")) })); assert!(observations.iter().any(|observation| { - matches!( - observation, - DirectCodexTurnObservation::Activity("file-write") - ) - })); - assert!(observations.iter().any(|observation| { - matches!( - observation, - DirectCodexTurnObservation::Activity("command-exec") - ) + matches!(observation, TurnObservation::Activity("command-exec")) })); assert!( observations .iter() - .filter(|observation| { - **observation == DirectCodexTurnObservation::Activity("preparing") - }) + .filter(|observation| { **observation == TurnObservation::Activity("preparing") }) .count() >= 2, "turn start and later plan activity must both remain visible" @@ -8080,7 +8032,7 @@ while IFS= read -r line; do :; done observations .iter() .filter(|observation| { - **observation == DirectCodexTurnObservation::Activity("response-finalization") + **observation == TurnObservation::Activity("response-finalization") }) .count(), 2, @@ -8159,10 +8111,9 @@ while IFS= read -r line; do :; done .expect("run fake app-server turn"); drop(observer); assert!( - !observations.iter().any(|observation| matches!( - observation, - DirectCodexTurnObservation::Activity("preparing") - )), + !observations + .iter() + .any(|observation| matches!(observation, TurnObservation::Activity("preparing"))), "思考增量必须走 ReasoningDelta 正文通道,不能降级成 preparing 活动" ); assert!( @@ -8280,7 +8231,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), - DirectCodexTurnKind::User, + TurnKind::User, None, Some(&mut observer), ) @@ -8324,11 +8275,16 @@ done let failure = terminal .1 .expect("连接死亡必须带失败载荷,否则界面只会看到「本轮已结束」"); - assert_eq!( - failure.kind, - crate::agent::DirectTurnFailureKind::TransportFailed - ); - assert!(failure.message.contains("已退出"), "{}", failure.message); + match &failure { + crate::agent::TurnFailure::TransportClosed(payload) => { + assert!( + payload.diagnostic.contains("已退出"), + "{}", + payload.diagnostic + ) + } + other => panic!("连接死亡必须是 transport-closed 载荷,got {other:?}"), + } } #[cfg(unix)] @@ -8435,7 +8391,7 @@ done Some(&project), Some("turn-0001"), Some(&user_item), - DirectCodexTurnKind::User, + TurnKind::User, None, Some(&mut observer), ) 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/codex_app_server/turn_error.rs similarity index 54% rename from apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_error.rs rename to apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/turn_error.rs index 196ef735a..1dc471acc 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/codex_app_server/turn_error.rs @@ -1,59 +1,64 @@ -//! Direct 回合链路的 typed error:从命令入口到出口只传这一种错误。 +//! Direct 链路的 typed error:三条通道三张类型表,**没有一处靠字符串分流或上屏**。 //! -//! 为什么不是 `struct { kind, message }`:两层错误(**调用级拒绝**与**回合级失败**)根本不共享 -//! 字段——并发拒绝要带两个 invocation id、模型自报失败要带原生分类、等待超时要带是哪条上限、 -//! 通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`,不靠 `kind` 字段 + 共用字段的 -//! 伪结构化,也不靠对错误文本做子串匹配。 +//! | 类型 | 通道 | 变体 | +//! | --- | --- | --- | +//! | [`EnqueueError`] | `#[tauri::command]` 的 `Err`(入队失败) | 9 | +//! | [`TurnError`] | 宿主内部(回合失败 + 控制流),**不导出、不跨进程** | 10 | +//! | [`TurnFailure`] | `turn.completed.failure` 事件载荷 | 9 | //! -//! 走哪条通道由**发生位置**决定,不由错误种类决定(`入队化` 之后的口径): -//! - **入队前**发生的 = 入队失败:只出提示 / 横幅,不做失败载荷、不写失败诊断、不上报成 -//! "智能创作失败"。命令返回 `Err` 的就是这一类。 -//! - **放行后**发生的 = 回合失败:事件载荷、横幅、应用日志、错误上报池四处一致;命令早已返回 -//! `Ok`,所以一律由宿主侧的占用对象投影成 `turn.completed.failure`。 +//! 走哪条通道由**发生位置**决定:入队前发生的进第一张表,放行后发生的进第二张,第二张里 +//! "这一轮已经判失败"的那部分由 [`TurnError::terminal_failure`] 投影成第三张。 +//! 控制流(返修要求)不是失败,投影返回 `None`,于是它既不会进失败载荷,也不需要前端接。 //! -//! 所以"同一种错误在入队与放行走不同通道"是正常的:[`DirectTurnError::EnvironmentNotReady`] 两边 -//! 都可能出现,位置说了算。这里**没有**、也不该有"这个变体是不是回合失败"的判据。 +//! 为什么不是 `struct { kind, message }`:两张表根本不共享字段——模型自报失败要带原生分类、 +//! 等待超时要带是哪条上限、通道断开要带宿主诊断。用不同变体各带各的字段,分流靠 `match`, +//! 不靠 `kind` 字段 + 共用字段的伪结构化,也不靠对错误文本做子串匹配。 //! -//! 事件载荷(`thread_manager::wire::DirectTurnFailure`)仍然只有 `{kind, message}` 两个字段: -//! 那是**线上协议**,由 [`DirectTurnError::wire_kind`] 与 `Display` 在这一个出口投影出来,不是 -//! 另一种状态模型。跨进程边界(`#[tauri::command]`)的**入队失败**载荷是结构化的 -//! [`DirectTurnEnqueueFailure`](变体 + 一条可展示 `message`);`message` 有两条来源: -//! 不需要留痕的失败用 [`DirectTurnError`] 的 `Display`,需要留痕的宿主 / 环境事实改用 -//! `record_direct_codex_failure` 生成的脱敏诊断收口文案(`direct-codex-failure:v2 …`,同时写 -//! `.agent/runtime/errors` 与错误上报池)。其余只需要一句文案的出口仍由 `Display` 生成。 -//! 无论哪条来源,Rust 侧任何地方都不解析这个字符串。 +//! 载荷只装**原始事实**:文案由前端按变体拼(业务拒绝)或给固定句(系统事实),Rust 不再产出 +//! 任何用户可见文案。`detail` / `cause` / `diagnostic` 这类宿主原文留在载荷里,只用于分流与 +//! 诊断、不直接上屏。变体名就是线上的 `type`,载荷字段名就是线上的字段名。 //! //! 谁负责产生哪个变体: -//! - 命令入口与回合编排(`direct_runtime`):调用级拒绝、阶段失败; -//! - app-server 投影([`DirectTurnError::from_model_call`]):模型 / 上游 / 通道类失败; +//! - 命令入口(`direct_runtime::user_input`):入队失败; +//! - 回合编排(`direct_runtime`):阶段失败; +//! - app-server 投影([`TurnError::from_model_call`]):模型 / 上游 / 通道类失败; //! - 执行适配器(`codex_app_server::execution`):宿主亲眼看到的收场事实(通道断开 / 超时 / 中断)。 +//! +//! TODO(文本判据):[`DomainFact::classify`] 仍在读文本——那是产生层还没有 typed 出口的 +//! 事实(美术凭据、项目历史、执行预算)。这条债要在产生层逐个补 typed 变体后清掉。 -use std::fmt; +use std::path::Path; use platform_llm::LlmError; use serde::{Deserialize, Serialize}; use ts_rs::TS; +use crate::agent::{redact_agent_runtime_error, TurnFailure}; + +/// 下发给前端的宿主原文(`detail` / `cause` / `diagnostic`)的字符上限:够说清原因,又不至于把 +/// 整段上游报文塞进事件队列。与改造前整条失败文案的上限同一档。 +const FAILURE_DETAIL_MAX_CHARS: usize = 600; + /// app-server 把"原生失败分类"写进原因文本时的结构化前缀。 /// /// 这是**协议常量**,不是给人读的文案:`codex-app-server-error:`,`` 之后可选跟 -/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`direct_codex_native_kind`] 这一个地方读它。 -const DIRECT_CODEX_NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; +/// 一段 ` detail=...` 的机器字段。宿主侧只允许在 [`native_kind`] 这一个地方读它。 +const NATIVE_KIND_PREFIX: &str = "codex-app-server-error:"; /// 失败发生在交付的哪一段。与错误分类正交:分类说明"怎么回事",阶段说明"走到哪一步"。 /// /// 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexFailureStage { +pub(crate) enum FailureStage { ArtPreparation, CodeGeneration, BrowserValidation, VersionRegistration, } -impl DirectCodexFailureStage { +impl FailureStage { pub(crate) fn id(self) -> &'static str { match self { Self::ArtPreparation => "art-preparation", @@ -67,17 +72,17 @@ impl DirectCodexFailureStage { /// 宿主等不到模型回执时,撞的是哪一条上限。 /// /// 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, TS)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde(rename_all = "kebab-case")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnDeadline { +pub(crate) enum Deadline { /// 空闲上限:一段时间没有新事件。 ResponseIdle, /// 回合硬上限:整轮的总时间。 TurnHardLimit, } -impl DirectTurnDeadline { +impl Deadline { /// 宿主写进失败事实与交付报告的那句原因:两边共用同一份文本,用户看到的现象与交付状态对得上。 fn message(self) -> &'static str { match self { @@ -94,14 +99,14 @@ impl DirectTurnDeadline { /// 在这里登记,否则会被当成"可让模型再试一次"的普通失败。 /// /// 线上取值只给界面选语气用,前端不得拿它做流程分支。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde( tag = "type", rename_all = "kebab-case", rename_all_fields = "camelCase" )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexNativeKind { +pub(crate) enum NativeKind { ContextWindowExceeded, SessionBudgetExceeded, UsageLimitExceeded, @@ -119,7 +124,7 @@ pub(crate) enum DirectCodexNativeKind { }, } -impl DirectCodexNativeKind { +impl NativeKind { fn from_id(kind: &str) -> Self { match kind { "context-window-exceeded" => Self::ContextWindowExceeded, @@ -195,7 +200,7 @@ impl DirectCodexNativeKind { /// 这一条分类值不值得当作"再试一次可能修好":与 [`Self::is_terminal`] 互为反义,但语义不同—— /// 这里问的是"用户重试有没有意义",用于失败诊断的 `retryable` 字段。 /// - /// `Unauthorized` 必须与 [`DirectDomainFact::AuthenticationRejected`] 同口径:登录态失效重登 + /// `Unauthorized` 必须与 [`DomainFact::AuthenticationRejected`] 同口径:登录态失效重登 /// 之后再发一次是有意义的,`recovery_hint` 也是这么写的。两套分类路径给出相反结论,会让同一 /// 份事实的 `retryable` 取决于哪一层先认出它。 fn is_retryable(&self) -> bool { @@ -214,44 +219,18 @@ impl DirectCodexNativeKind { } } -/// 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 -/// -/// 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 -/// 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, -/// 枚举成员名与线上取值一一对应,改名即改协议。 -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnFailureKind { - /// 等待模型回执撞上上限。 - Timeout, - /// 模型 / 上游 / 交付阶段的失败(说不出更细分类的也归这里)。 - ModelFailed, - /// 执行通道断开。 - TransportFailed, - /// app-server 或上游明确拒绝了这次请求。 - RequestRejected, - /// 放行之后的连接 / 配置 / 凭据 / 脚手架未就绪(不是模型的错,界面语气也不同)。 - EnvironmentNotReady, - /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 - TurnInterrupted, - /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 - HostDropped, -} - /// 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 /// -/// 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 -/// 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 +/// 每个变体对应平台层 `LlmError` 的一个分支;前端拿它选语气,不拿它做流程分支。 /// `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde( tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase" )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectModelCallKind { +pub(crate) enum ModelCallKind { /// `LlmError::Timeout`。 ResponseTimedOut { attempts: u32 }, /// `LlmError::Connectivity`。 @@ -261,13 +240,11 @@ pub(crate) enum DirectModelCallKind { /// `LlmError::StreamUnavailable`。 StreamUnavailable, /// `LlmError::InvalidConfig` / `LlmError::InvalidRequest`。 - RequestRejected { - native: Option, - }, + RequestRejected { native: Option }, /// `LlmError::Upstream`(409 除外,那条是 [`Self::PaidCreditsInsufficient`])。 UpstreamFailed { status_code: u16, - native: Option, + native: Option, }, /// `LlmError::Upstream { status_code: 409 }`:平台约定这一条就是泥点余额不足。 /// @@ -277,27 +254,10 @@ pub(crate) enum DirectModelCallKind { /// `LlmError::EmptyResponse`。 EmptyResponse, /// `LlmError::Deserialize`。 - PayloadInvalid { - native: Option, - }, + PayloadInvalid { native: Option }, } -impl DirectModelCallKind { - /// 事件失败载荷里的稳定分类(只影响界面语气,前端不得拿它做流程分支)。 - fn wire_kind(&self) -> DirectTurnFailureKind { - match self { - Self::ResponseTimedOut { .. } => DirectTurnFailureKind::Timeout, - Self::ConnectionFailed { .. } | Self::TransportBroken | Self::StreamUnavailable => { - DirectTurnFailureKind::TransportFailed - } - Self::RequestRejected { .. } => DirectTurnFailureKind::RequestRejected, - Self::UpstreamFailed { .. } - | Self::PaidCreditsInsufficient - | Self::EmptyResponse - | Self::PayloadInvalid { .. } => DirectTurnFailureKind::ModelFailed, - } - } - +impl ModelCallKind { /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得。 fn is_model_repairable(&self) -> bool { match self { @@ -354,9 +314,9 @@ impl DirectModelCallKind { } Self::EmptyResponse => Some("模型未返回内容"), Self::PayloadInvalid { .. } => Some("模型回执无法解析"), - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native - .as_ref() - .and_then(DirectCodexNativeKind::public_summary), + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { + native.as_ref().and_then(NativeKind::public_summary) + } } } @@ -373,168 +333,314 @@ impl DirectModelCallKind { Self::EmptyResponse | Self::PayloadInvalid { .. } => { Some("模型未给出可用的回执,请重试;如持续失败请检查项目诊断") } - Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => native - .as_ref() - .and_then(DirectCodexNativeKind::recovery_hint), + Self::RequestRejected { native } | Self::UpstreamFailed { native, .. } => { + native.as_ref().and_then(NativeKind::recovery_hint) + } } } } -/// 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] +// ══════════════════════════════════════════════════════════════════════════════════════════ +// 入队失败:`#[tauri::command]` 的 `Err`,这一轮没有开始也没有进队列 +// ══════════════════════════════════════════════════════════════════════════════════════════ + +/// 命令边界的 typed 入队失败:变体名就是线上的分流键(`type`),前端只按它选通道,不解析文案。 +/// +/// 可达集合由 `direct_runtime::user_input::enqueue_direct_codex_turn_typed` 固定: +/// `clientTurnId` 校验 → 工作流恢复 → 用户条目冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 +/// 放行之后发生的失败不在这张表里(见 [`TurnError`])。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] #[serde( tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase" )] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectTurnError { - // ───────── 入队失败:这一轮没有开始,也没有进队列 ───────── +pub(crate) enum EnqueueError { /// `clientTurnId` 没给:没有稳定回合身份,拒绝创建可计费身份。 ClientTurnIdMissing, /// `clientTurnId` 形状非法:长度与字符集由宿主定,界面按同一份约束生成。 - ClientTurnIdMalformed { min_chars: usize, max_chars: usize }, + ClientTurnIdMalformed(ClientTurnIdMalformed), /// 待发消息队列已满:这一条没进队,等前面几条发完再发。 - /// - /// 上限只落在宿主这一处(`MAX_PENDING_TURNS`),随载荷带出去,界面不自己数一份。 - QueueFull { limit: usize }, - /// 项目目录锚不定(符号链接 / 权限 / 目录被删)。 - ProjectRootUnanchored { cause: String }, + QueueFull(QueueFull), /// 项目目录不存在或不是绝对路径。 ProjectRootUnusable, - /// 项目权限策略拒绝了这次调用;`policy_detail` 是策略层的原文(带被拒的点位)。 - PermissionRejected { policy_detail: String }, + /// 项目权限策略拒绝了这次调用。 + PermissionRejected(PermissionRejected), /// 用户条目 / 创建类型本身不合法。 - InputRejected { detail: String }, + InputRejected(InputRejected), /// 结构化消息既没有正文也没有任何引用。 ContentEmpty, - /// 环境 / 凭据 / 脚手架未就绪。**入队与放行都可能出现**:入队时是入队失败(工程 / 凭据还没准备 - /// 好),放行后是回合失败(分类 `environment-not-ready`,例如 `turn/start` 之前连不上 app-server)。 - EnvironmentNotReady { detail: String }, + /// 工程 / 凭据 / 脚手架还没准备好。 + EnvironmentNotReady(EnvironmentNotReady), /// 宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 - HostStateUnavailable { detail: String }, - - // ───────── 回合失败:放行之后发生,这一轮已经开始 ───────── - /// 模型调用失败:`kind` 是分类,`detail` 是平台层原文(就是给用户看的那句话)。 - ModelCallFailed { - kind: DirectModelCallKind, - detail: String, - }, - /// 执行通道断开;`diagnostic` 是连接终止时那份诊断(进程退出 / stderr 摘要)。 - TransportClosed { diagnostic: String }, - /// 等待模型回执撞上限。 - TimedOut { deadline: DirectTurnDeadline }, - /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 - TurnInterrupted { detail: String }, - /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**: - /// 交付模块用它把"还缺证据"交给下一步,界面不应该看到失败。 - ReviewRequired { detail: String }, - /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) - /// —— **控制流,不是失败**:与 [`DirectTurnError::ReviewRequired`] 同一族,只是产生点在收尾 - /// 阶段而不是交付复核。调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 - RepairRequired { detail: String }, - /// 已经写成诊断记录的回合失败:`detail` 是诊断正文(阶段 / 分类 / 建议 / 详情引用)。 - TurnFailed { - stage: DirectCodexFailureStage, - detail: String, - }, - /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, - /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 - TurnFailedUnclassified { detail: String }, + HostStateUnavailable(HostStateUnavailable), } -/// 入队失败载荷:命令边界交给前端的**结构化入队失败**。 -/// -/// 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 -/// 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 -/// `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 -#[derive(Clone, Debug, PartialEq, Eq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnEnqueueFailure { - /// 结构化变体:界面按 `error.type` 分流,不解析文案。 - pub(crate) error: DirectTurnError, - /// 可展示文案(`Display` 的唯一出口)。 - pub(crate) message: String, -} - -impl DirectTurnEnqueueFailure { - pub(crate) fn new(error: DirectTurnError) -> Self { - Self { - message: error.to_string(), - error, - } - } -} - -impl DirectTurnError { - /// 命令边界要不要为这条**入队失败**补一份运行错误诊断。 +impl EnqueueError { + /// 命令边界要不要为这条入队失败补一份运行错误诊断。 /// /// 只有"宿主 / 环境的事实故障、用户自己改不了"才值得进 `.agent/runtime/errors` 与应用日志; - /// 空内容、`clientTurnId` 形状、队列满、另一轮在跑、权限策略、目录锚不定 / 不是绝对路径都是 - /// 用户自己就能修的操作结果,留痕只会变成噪声;它们仍按 `Display` 给用户一句可读的话。 - /// 判据按变体分,不看文案。 - /// - /// 回合级失败恒为 `false`:它们在上游(`record_direct_codex_failure`)已经写过诊断,边界再写一次 - /// 就是同一件事留两份。 + /// 空内容、`clientTurnId` 形状、队列满、权限策略、目录不可用都是用户自己就能修的操作结果, + /// 留痕只会变成噪声。判据按变体分,不看文案。 pub(crate) fn is_reportable(&self) -> bool { + matches!( + self, + Self::EnvironmentNotReady(_) | Self::HostStateUnavailable(_) + ) + } + + /// 写进 `.agent/runtime/errors` 与应用日志的宿主原文。只有 [`Self::is_reportable`] 为真的 + /// 变体会被调用;其余变体没有可留痕的事实,返回空串。 + pub(crate) fn diagnostic_detail(&self) -> String { match self { - // 连接 / 配置 / 凭据 / 脚手架未就绪与宿主状态取不到:现场只有宿主知道,必须留痕。 - Self::EnvironmentNotReady { .. } | Self::HostStateUnavailable { .. } => true, - Self::ClientTurnIdMissing - | Self::ClientTurnIdMalformed { .. } - | Self::QueueFull { .. } - // 项目目录锚不定(符号链接 / 权限 / 目录被删)与目录不存在同类:都是用户能自己修好的 - // 文件系统事实,诊断文案不该顶替那句"无法锚定 Direct 调用项目目录:{cause}"。 - | Self::ProjectRootUnanchored { .. } - | Self::ProjectRootUnusable - | Self::PermissionRejected { .. } - | Self::InputRejected { .. } - | Self::ContentEmpty - | Self::ModelCallFailed { .. } - | Self::TransportClosed { .. } - | Self::TimedOut { .. } - | Self::TurnInterrupted { .. } - | Self::ReviewRequired { .. } - | Self::RepairRequired { .. } - | Self::TurnFailed { .. } - | Self::TurnFailedUnclassified { .. } => false, + Self::EnvironmentNotReady(payload) => payload.detail.clone(), + Self::HostStateUnavailable(payload) => payload.detail.clone(), + _ => String::new(), + } + } +} + +/// `clientTurnId` 的长度约束:上下限随载荷带出去,界面不自己数一份。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ClientTurnIdMalformed { + pub(crate) min_chars: usize, + pub(crate) max_chars: usize, +} + +/// 待发消息队列上限:只落在宿主这一处(`MAX_PENDING_TURNS`)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct QueueFull { + pub(crate) limit: usize, +} + +/// 项目权限策略的原文(带被拒的点位)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct PermissionRejected { + pub(crate) policy_detail: String, +} + +/// 用户条目 / 创建类型不合法的原始解释。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct InputRejected { + pub(crate) detail: String, +} + +/// 环境 / 凭据 / 脚手架未就绪的原始解释。 +/// +/// **两张表共用**:入队侧是入队失败,回合侧是回合失败。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct EnvironmentNotReady { + pub(crate) detail: String, +} + +/// 宿主执行账本取不到的原始解释。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HostStateUnavailable { + pub(crate) detail: String, +} + +// ══════════════════════════════════════════════════════════════════════════════════════════ +// 回合失败 + 控制流:宿主内部,放行之后发生,这一轮已经开始 +// ══════════════════════════════════════════════════════════════════════════════════════════ + +/// 回合侧 typed 错误。**不跨进程、不序列化**:它只作宿主内部的 `Err`,前端拿到的是 +/// [`TurnError::terminal_failure`] 投影出来的 [`TurnFailure`] 事件载荷。 +/// +/// 10 个变体里 8 个是失败、2 个是控制流(返修要求)。控制流**不是失败**:投影返回 `None`, +/// 既不进失败载荷也不上报,所以前端不需要、也不会写这两个分支。 +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum TurnError { + /// 项目目录锚不定(符号链接 / 权限 / 目录被删):app-server 侧解析项目身份时判定。 + ProjectRootUnanchored(ProjectRootUnanchored), + /// 放行之后的工程 / 凭据 / 脚手架未就绪(例如 `turn/start` 之前连不上 app-server)。 + EnvironmentNotReady(EnvironmentNotReady), + /// 放行之后宿主执行账本取不到(初始化失败、归属锁被占、状态损坏、时钟回退)。 + HostStateUnavailable(HostStateUnavailable), + /// 模型调用失败。 + ModelCallFailed(ModelCallFailed), + /// 执行通道断开。 + TransportClosed(TransportClosed), + /// 等待模型回执撞上限。 + TimedOut(TimedOut), + /// app-server 单方面把这一轮判成中断(用户没要求停止、宿主也没在收尾)。 + TurnInterrupted(TurnInterrupted), + /// 宿主复核要求继续本轮的返修批次 —— **控制流,不是失败**:交付模块用它把"还缺证据"交给 + /// 下一步。字段不跨进程,所以留在变体内联。 + ReviewRequired { detail: String }, + /// 宿主**封口**复核要求继续当前返修批次(app-server 收尾的 `HostOutcome::RepairRequired`) + /// —— **控制流,不是失败**:与 [`Self::ReviewRequired`] 同一族,只是产生点在收尾阶段。 + /// 调用方把它写回提示词继续跑:不写终态、不进失败载荷、不上报。 + RepairRequired { detail: String }, + /// 已经写成诊断记录的回合失败。 + TurnFailed(TurnFailed), + /// 桥:深层只拿得到字符串的错误。只允许出现在"这一轮已经开始"的层里, + /// 且新分类必须先加 typed 变体,别借这个变体蒙混过关。 + TurnFailedUnclassified(TurnFailedUnclassified), +} + +/// 项目目录锚不定的原始原因。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ProjectRootUnanchored { + pub(crate) cause: String, +} + +/// 模型调用失败的分类与平台层原文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ModelCallFailed { + pub(crate) kind: ModelCallKind, + pub(crate) detail: String, +} + +/// 通道断开时那份宿主诊断(进程退出 / stderr 摘要)。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TransportClosed { + pub(crate) diagnostic: String, +} + +/// 撞上的那条上限。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TimedOut { + pub(crate) deadline: Deadline, +} + +/// app-server 判成中断的原始说明。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TurnInterrupted { + pub(crate) detail: String, +} + +/// 已写成诊断记录的回合失败:阶段 + 诊断正文。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TurnFailed { + pub(crate) stage: FailureStage, + pub(crate) detail: String, +} + +/// 深层尚未 typed 的字符串错误。 +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct TurnFailedUnclassified { + pub(crate) detail: String, +} + +impl TurnError { + /// 这一轮**已经判失败**时的可下发载荷;控制流(返修要求)返回 `None`。 + /// + /// 这是 `turn.completed.failure` 的**唯一**投影点:控制流不是失败,所以它既不写终态、也不进 + /// 载荷,前端因此不需要为它写分支。载荷里的宿主原文(`detail` / `cause` / `diagnostic`)在这 + /// 一处统一脱敏 + 截断,与改造前同一条边界、同一份判据。 + pub(crate) fn terminal_failure(&self, history_root: &Path) -> Option { + let redact = + |value: &str| redact_agent_runtime_error(history_root, value, FAILURE_DETAIL_MAX_CHARS); + match self { + Self::ProjectRootUnanchored(payload) => { + Some(TurnFailure::ProjectRootUnanchored(ProjectRootUnanchored { + cause: redact(&payload.cause), + })) + } + Self::EnvironmentNotReady(payload) => { + Some(TurnFailure::EnvironmentNotReady(EnvironmentNotReady { + detail: redact(&payload.detail), + })) + } + Self::HostStateUnavailable(payload) => { + Some(TurnFailure::HostStateUnavailable(HostStateUnavailable { + detail: redact(&payload.detail), + })) + } + Self::ModelCallFailed(payload) => Some(TurnFailure::ModelCallFailed(ModelCallFailed { + kind: payload.kind.clone(), + detail: redact(&payload.detail), + })), + Self::TransportClosed(payload) => Some(TurnFailure::TransportClosed(TransportClosed { + diagnostic: redact(&payload.diagnostic), + })), + Self::TimedOut(payload) => Some(TurnFailure::TimedOut(payload.clone())), + Self::TurnInterrupted(payload) => Some(TurnFailure::TurnInterrupted(TurnInterrupted { + detail: redact(&payload.detail), + })), + Self::TurnFailed(payload) => Some(TurnFailure::TurnFailed(TurnFailed { + stage: payload.stage, + detail: redact(&payload.detail), + })), + Self::TurnFailedUnclassified(payload) => Some(TurnFailure::TurnFailedUnclassified( + TurnFailedUnclassified { + detail: redact(&payload.detail), + }, + )), + // 控制流:这一轮还没结束,不是失败。 + Self::ReviewRequired { .. } | Self::RepairRequired { .. } => None, } } - /// 事件失败载荷里的稳定分类。调用级拒绝与控制流不会走到这里。 - pub(crate) fn wire_kind(&self) -> Option { + /// 诊断 / 日志用的宿主原文。**不上屏**:界面文案由前端按变体拼,这里只喂 + /// `.agent/runtime/errors`、应用日志与错误上报池。 + pub(crate) fn diagnostic_detail(&self) -> String { match self { - Self::ModelCallFailed { kind, .. } => Some(kind.wire_kind()), - Self::TransportClosed { .. } => Some(DirectTurnFailureKind::TransportFailed), - // 接了单才失败的连接 / 配置 / 凭据类原因:它们不是模型的问题,界面语气也不一样。 - Self::EnvironmentNotReady { .. } => Some(DirectTurnFailureKind::EnvironmentNotReady), - Self::TimedOut { .. } => Some(DirectTurnFailureKind::Timeout), - Self::TurnInterrupted { .. } => Some(DirectTurnFailureKind::TurnInterrupted), - Self::TurnFailed { .. } | Self::TurnFailedUnclassified { .. } => { - Some(DirectTurnFailureKind::ModelFailed) + Self::ProjectRootUnanchored(payload) => { + format!("无法锚定 Direct 调用项目目录:{}", payload.cause) } - _ => None, + Self::EnvironmentNotReady(payload) => payload.detail.clone(), + Self::HostStateUnavailable(payload) => payload.detail.clone(), + Self::ModelCallFailed(payload) => payload.detail.clone(), + Self::TransportClosed(payload) => { + format!( + "执行通道已断开,不能自动重放未确认操作:{}", + payload.diagnostic + ) + } + Self::TimedOut(payload) => payload.deadline.message().to_string(), + Self::TurnInterrupted(payload) => payload.detail.clone(), + Self::ReviewRequired { detail } | Self::RepairRequired { detail } => detail.clone(), + Self::TurnFailed(payload) => payload.detail.clone(), + Self::TurnFailedUnclassified(payload) => payload.detail.clone(), } } /// 已记录失败的诊断阶段;拿不到阶段的错误归到回合主体的代码生成段。 - pub(crate) fn turn_failure_stage(&self) -> DirectCodexFailureStage { + pub(crate) fn turn_failure_stage(&self) -> FailureStage { match self { - Self::TurnFailed { stage, .. } => *stage, - _ => DirectCodexFailureStage::CodeGeneration, + Self::TurnFailed(payload) => payload.stage, + _ => FailureStage::CodeGeneration, } } /// 把这条失败作为下一轮的调试上下文反馈给模型,值不值得(与旧的字面量判据逐条对齐)。 pub(crate) fn is_model_repairable(&self) -> bool { match self { - Self::ModelCallFailed { kind, .. } => kind.is_model_repairable(), + Self::ModelCallFailed(payload) => payload.kind.is_model_repairable(), // 阶段失败 / 桥变体:**认出是哪一类就拦**(产生层还没 typed 出口的深层事实才继续 // 反馈)。已归类的都是模型改不动的事实——凭据 / 权限 / 额度 / 历史一致性与契约变化, // 把同一份输入再跑一轮只会拿到同一结论;旧的字面量判据也是这个口径。 - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).is_none() + Self::TurnFailed(payload) => DomainFact::classify(&payload.detail).is_none(), + Self::TurnFailedUnclassified(payload) => { + DomainFact::classify(&payload.detail).is_none() } _ => false, } @@ -543,12 +649,15 @@ impl DirectTurnError { /// 用户重试这一轮有没有意义。 pub(crate) fn is_retryable(&self) -> bool { match self { - Self::ModelCallFailed { kind, .. } => kind.is_retryable(), - Self::TransportClosed { .. } => false, + Self::ModelCallFailed(payload) => payload.kind.is_retryable(), + Self::TransportClosed(_) => false, // 超时/中断后重试是常规动作:宿主已经把这一轮收干净了。 - Self::TimedOut { .. } | Self::TurnInterrupted { .. } => true, - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).is_none_or(DirectDomainFact::is_retryable) + Self::TimedOut(_) | Self::TurnInterrupted(_) => true, + Self::TurnFailed(payload) => { + DomainFact::classify(&payload.detail).is_none_or(DomainFact::is_retryable) + } + Self::TurnFailedUnclassified(payload) => { + DomainFact::classify(&payload.detail).is_none_or(DomainFact::is_retryable) } _ => false, } @@ -557,9 +666,12 @@ impl DirectTurnError { /// 给用户看的稳定摘要:能一句话说清的才有,其余按阶段兜底。 pub(crate) fn public_summary(&self) -> Option<&'static str> { match self { - Self::ModelCallFailed { kind, .. } => kind.public_summary(), - Self::TurnFailed { detail, .. } | Self::TurnFailedUnclassified { detail } => { - DirectDomainFact::classify(detail).and_then(DirectDomainFact::public_summary) + Self::ModelCallFailed(payload) => payload.kind.public_summary(), + Self::TurnFailed(payload) => { + DomainFact::classify(&payload.detail).and_then(DomainFact::public_summary) + } + Self::TurnFailedUnclassified(payload) => { + DomainFact::classify(&payload.detail).and_then(DomainFact::public_summary) } _ => None, } @@ -568,20 +680,20 @@ impl DirectTurnError { /// 恢复建议:分类 → 阶段 → 深层文案,逐级退让,最后一条由调用方兜底。 pub(crate) fn recovery_hint(&self) -> Option<&'static str> { match self { - Self::ModelCallFailed { kind, .. } => kind.recovery_hint(), - Self::TransportClosed { .. } => { + Self::ModelCallFailed(payload) => payload.kind.recovery_hint(), + Self::TransportClosed(_) => { Some("执行通道已断开,本轮未完成;请重试,若持续失败请检查项目诊断") } - Self::TimedOut { .. } => { + Self::TimedOut(_) => { Some("上游响应超时,本轮未完成;请稍后重试,若持续失败请检查项目诊断") } - Self::TurnInterrupted { .. } => { + Self::TurnInterrupted(_) => { Some("本轮执行被上游中断,请重试;如持续失败请检查项目诊断") } - Self::TurnFailed { stage, detail } => direct_code_failure_recovery_hint(*stage, detail), + Self::TurnFailed(payload) => stage_recovery_hint(payload.stage, &payload.detail), // 桥变体没有阶段可依,按回合主体的默认段给建议。 - Self::TurnFailedUnclassified { detail } => { - direct_code_failure_recovery_hint(DirectCodexFailureStage::CodeGeneration, detail) + Self::TurnFailedUnclassified(payload) => { + stage_recovery_hint(FailureStage::CodeGeneration, &payload.detail) } _ => None, } @@ -590,121 +702,77 @@ impl DirectTurnError { /// 把平台层 `LlmError` 投影成 Direct 回合错误。**分类只在这一个地方做一次。** /// /// 原生分类(`context-window-exceeded` 之类)不再变成"原因文本里的一段字",而是解析成 - /// [`DirectCodexNativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 + /// [`NativeKind`];解析只读 app-server 写下的结构化前缀,不认任何文案。 pub(crate) fn from_model_call(error: &LlmError) -> Self { let detail = error.to_string(); let kind = match error { - LlmError::Timeout { attempts } => DirectModelCallKind::ResponseTimedOut { + LlmError::Timeout { attempts } => ModelCallKind::ResponseTimedOut { attempts: *attempts, }, - LlmError::Connectivity { attempts, .. } => DirectModelCallKind::ConnectionFailed { + LlmError::Connectivity { attempts, .. } => ModelCallKind::ConnectionFailed { attempts: *attempts, }, // Transport / StreamUnavailable 的原因文本由宿主自己写,没有原生分类。 - LlmError::Transport(_) => DirectModelCallKind::TransportBroken, - LlmError::StreamUnavailable => DirectModelCallKind::StreamUnavailable, + LlmError::Transport(_) => ModelCallKind::TransportBroken, + LlmError::StreamUnavailable => ModelCallKind::StreamUnavailable, LlmError::InvalidConfig(_) | LlmError::InvalidRequest(_) => { - DirectModelCallKind::RequestRejected { - native: direct_codex_native_kind(&detail), + ModelCallKind::RequestRejected { + native: native_kind(&detail), } } LlmError::Upstream { status_code, .. } if *status_code == 409 => { - DirectModelCallKind::PaidCreditsInsufficient + ModelCallKind::PaidCreditsInsufficient } - LlmError::Upstream { status_code, .. } => DirectModelCallKind::UpstreamFailed { + LlmError::Upstream { status_code, .. } => ModelCallKind::UpstreamFailed { status_code: *status_code, - native: direct_codex_native_kind(&detail), + native: native_kind(&detail), }, - LlmError::EmptyResponse => DirectModelCallKind::EmptyResponse, - LlmError::Deserialize(_) => DirectModelCallKind::PayloadInvalid { - native: direct_codex_native_kind(&detail), + LlmError::EmptyResponse => ModelCallKind::EmptyResponse, + LlmError::Deserialize(_) => ModelCallKind::PayloadInvalid { + native: native_kind(&detail), }, }; - Self::ModelCallFailed { kind, detail } + Self::ModelCallFailed(ModelCallFailed { kind, detail }) } /// 阶段失败:把深层错误挂到交付的某一段上。深层还没 typed 的口子由这里进桥变体。 - pub(crate) fn turn_failed(stage: DirectCodexFailureStage, detail: impl Into) -> Self { - Self::TurnFailed { + pub(crate) fn turn_failed(stage: FailureStage, detail: impl Into) -> Self { + Self::TurnFailed(TurnFailed { stage, detail: detail.into(), - } + }) + } + + /// 工程 / 凭据 / 脚手架未就绪:与入队侧共用同一个载荷,只是发生在放行之后。 + pub(crate) fn environment_not_ready(detail: impl Into) -> Self { + Self::EnvironmentNotReady(EnvironmentNotReady { + detail: detail.into(), + }) } } -impl fmt::Display for DirectTurnError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::ClientTurnIdMissing => formatter.write_str( - "Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份", - ), - Self::ClientTurnIdMalformed { - min_chars, - max_chars, - } => write!( - formatter, - "clientTurnId 必须为 {min_chars} 到 {max_chars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" - ), - Self::QueueFull { limit } => write!( - formatter, - "待发消息已达上限(最多 {limit} 条),请等前面几条发完再发送" - ), - Self::ProjectRootUnanchored { cause } => { - write!(formatter, "无法锚定 Direct 调用项目目录:{cause}") - } - Self::ProjectRootUnusable => { - formatter.write_str("当前项目目录不存在或不是绝对路径") - } - Self::PermissionRejected { policy_detail } => formatter.write_str(policy_detail), - Self::InputRejected { detail } - | Self::EnvironmentNotReady { detail } - | Self::HostStateUnavailable { detail } - | Self::ModelCallFailed { detail, .. } - | Self::TurnInterrupted { detail } - | Self::TurnFailed { detail, .. } - | Self::TurnFailedUnclassified { detail } => formatter.write_str(detail), - Self::ContentEmpty => formatter.write_str("聊天内容不能为空"), - Self::TransportClosed { diagnostic } => write!( - formatter, - "执行通道已断开,不能自动重放未确认操作:{diagnostic}" - ), - Self::TimedOut { deadline } => formatter.write_str(deadline.message()), - Self::ReviewRequired { detail } | Self::RepairRequired { detail } => { - formatter.write_str(detail) - } - } - } -} - -/// 跨进程边界(`#[tauri::command]`)的序列化:字符串只在这里生成一次。 -impl From for String { - fn from(error: DirectTurnError) -> Self { - error.to_string() - } -} - -/// 桥:深层尚未 typed 的字符串错误落进 [`DirectTurnError::TurnFailedUnclassified`]。 +/// 桥:深层尚未 typed 的字符串错误落进 [`TurnError::TurnFailedUnclassified`]。 /// /// 只给"这一轮已经开始"的层用。调用级(权限、校验、并发)必须显式构造对应变体。 -impl From for DirectTurnError { +impl From for TurnError { fn from(detail: String) -> Self { - Self::TurnFailedUnclassified { detail } + Self::TurnFailedUnclassified(TurnFailedUnclassified { detail }) } } -impl From<&str> for DirectTurnError { +impl From<&str> for TurnError { fn from(detail: &str) -> Self { - Self::TurnFailedUnclassified { + Self::TurnFailedUnclassified(TurnFailedUnclassified { detail: detail.to_string(), - } + }) } } /// 从原因文本里读出 app-server 写下的原生失败分类。 /// /// 只认结构化前缀与紧跟其后的分类 id;读不到就是"没有分类",不猜。 -fn direct_codex_native_kind(detail: &str) -> Option { - let rest = detail.split_once(DIRECT_CODEX_NATIVE_KIND_PREFIX)?.1; +fn native_kind(detail: &str) -> Option { + let rest = detail.split_once(NATIVE_KIND_PREFIX)?.1; let id = rest .split(|character: char| character.is_whitespace()) .next() @@ -712,7 +780,7 @@ fn direct_codex_native_kind(detail: &str) -> Option { if id.is_empty() { return None; } - Some(DirectCodexNativeKind::from_id(id)) + Some(NativeKind::from_id(id)) } /// 深层域事实:**产生层还没有 typed 出口**的事实,在这里读成 typed 值,之后所有决策只 `match`。 @@ -720,7 +788,7 @@ fn direct_codex_native_kind(detail: &str) -> Option { /// 这里的判据仍然是文本,因为产生层给出来的就只有文本(平台美术/凭据、项目历史、执行预算)。 /// 规则:**新分类必须先在产生层加 typed 变体**,别往这份表里加词;每条都注明了应由谁给出 typed 事实。 #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum DirectDomainFact { +enum DomainFact { /// 泥点余额不足:平台付费接口(`direct_paid_submission` / 美术生成 / 上游 409)。 PaidCreditsInsufficient, /// 本机私有凭据目录没准备好(`assets` 的私有凭据存储)。 @@ -759,7 +827,7 @@ enum DirectDomainFact { Cancelled, } -impl DirectDomainFact { +impl DomainFact { fn classify(detail: &str) -> Option { let normalized = detail.to_ascii_lowercase(); let contains = |marker: &str| { @@ -770,9 +838,9 @@ impl DirectDomainFact { } }; // 顺序即优先级:更具体的事实先判,笼统的放后面。 - const CANDIDATES: &[(DirectDomainFact, &[&str])] = &[ + const CANDIDATES: &[(DomainFact, &[&str])] = &[ ( - DirectDomainFact::PaidCreditsInsufficient, + DomainFact::PaidCreditsInsufficient, &[ "泥点余额不足", "可消费泥点不足", @@ -782,31 +850,31 @@ impl DirectDomainFact { ], ), ( - DirectDomainFact::CredentialStorageUnprepared, + DomainFact::CredentialStorageUnprepared, &["private-external-editor-credential-storage-preparation-failed"], ), ( - DirectDomainFact::CredentialNotPersisted, + DomainFact::CredentialNotPersisted, &["private-external-editor-credential-persistence-failed"], ), ( - DirectDomainFact::LocalDeveloperKeyMissing, + DomainFact::LocalDeveloperKeyMissing, &["本机陶泥儿开发者 Key"], ), ( - DirectDomainFact::AuthenticationRejected, + DomainFact::AuthenticationRejected, &["authentication-required", "unauthorized", "http 401"], ), ( - DirectDomainFact::PermissionDenied, + DomainFact::PermissionDenied, &["permission-denied", "http 403"], ), ( - DirectDomainFact::HistoryInjectionOversize, + DomainFact::HistoryInjectionOversize, &["历史注入载荷超过单行上限"], ), ( - DirectDomainFact::HistoryShapeUnsupported, + DomainFact::HistoryShapeUnsupported, &[ "DirectProject 历史记录类型无效", "DirectProject 历史记录缺少 payload", @@ -814,15 +882,15 @@ impl DirectDomainFact { ], ), ( - DirectDomainFact::ProjectWriteLockContention, + DomainFact::ProjectWriteLockContention, &[crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX], ), ( - DirectDomainFact::HistoryContention, + DomainFact::HistoryContention, &[crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER], ), ( - DirectDomainFact::ArtIdentityRejected, + DomainFact::ArtIdentityRejected, &[ "身份不唯一", "身份不匹配", @@ -830,25 +898,22 @@ impl DirectDomainFact { "没有可见像素", ], ), - (DirectDomainFact::ContractChanged, &["合同发生变化"]), + (DomainFact::ContractChanged, &["合同发生变化"]), ( - DirectDomainFact::ValidationBudgetExhausted, + DomainFact::ValidationBudgetExhausted, &["validation-budget-exhausted"], ), ( - DirectDomainFact::ValidationAlreadyRunning, + DomainFact::ValidationAlreadyRunning, &["validation-already-running"], ), ( - DirectDomainFact::PlaytestAttemptLimitExceeded, + DomainFact::PlaytestAttemptLimitExceeded, &["playtest-attempt-limit-exceeded"], ), - (DirectDomainFact::ToolArgumentsInvalid, &["工具参数"]), - (DirectDomainFact::Cancelled, &["取消"]), - ( - DirectDomainFact::CredentialsUnavailable, - &["credential", "凭据"], - ), + (DomainFact::ToolArgumentsInvalid, &["工具参数"]), + (DomainFact::Cancelled, &["取消"]), + (DomainFact::CredentialsUnavailable, &["credential", "凭据"]), ]; CANDIDATES .iter() @@ -952,26 +1017,17 @@ impl DirectDomainFact { } /// 阶段兜底的恢复建议:typed 分类给不出动作时,由阶段给一句与交付状态对得上的话。 -fn direct_code_failure_recovery_hint( - stage: DirectCodexFailureStage, - detail: &str, -) -> Option<&'static str> { - if let Some(fact) = DirectDomainFact::classify(detail) { +fn stage_recovery_hint(stage: FailureStage, detail: &str) -> Option<&'static str> { + if let Some(fact) = DomainFact::classify(detail) { return Some(fact.recovery_hint()); } Some(match stage { - DirectCodexFailureStage::ArtPreparation => { + FailureStage::ArtPreparation => { "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" } - DirectCodexFailureStage::CodeGeneration => { - "Codex 未完成本轮代码修改,请检查运行时配置后重试" - } - DirectCodexFailureStage::BrowserValidation => { - "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" - } - DirectCodexFailureStage::VersionRegistration => { - "产物尚未安全登记为版本,请检查项目目录后重试" - } + FailureStage::CodeGeneration => "Codex 未完成本轮代码修改,请检查运行时配置后重试", + FailureStage::BrowserValidation => "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求", + FailureStage::VersionRegistration => "产物尚未安全登记为版本,请检查项目目录后重试", }) } @@ -979,158 +1035,190 @@ fn direct_code_failure_recovery_hint( mod tests { use super::*; - /// 只有宿主 / 环境事实值得留痕:用户的正常操作结果与回合级失败都不在边界补诊断。 + /// 只有宿主 / 环境事实值得在命令边界补诊断:用户的正常操作结果不补。 #[test] - fn only_host_and_environment_rejections_are_reportable() { - assert!(DirectTurnError::EnvironmentNotReady { + fn only_host_and_environment_enqueue_rejections_are_reportable() { + assert!(EnqueueError::EnvironmentNotReady(EnvironmentNotReady { detail: "Codex app-server 启动失败".into(), - } + }) .is_reportable()); - assert!(DirectTurnError::HostStateUnavailable { + assert!(EnqueueError::HostStateUnavailable(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 { + assert!(!EnqueueError::ContentEmpty.is_reportable()); + assert!(!EnqueueError::ProjectRootUnusable.is_reportable()); + assert!(!EnqueueError::ClientTurnIdMissing.is_reportable()); + assert!(!EnqueueError::PermissionRejected(PermissionRejected { policy_detail: "项目权限策略拒绝执行:conversation.write".into(), - } + }) .is_reportable()); - // 回合级失败在上游已经写过诊断。 - assert!( - !DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败") - .is_reportable() + // 可留痕的变体给出宿主原文;其余变体没有可留痕的事实。 + assert_eq!( + EnqueueError::EnvironmentNotReady(EnvironmentNotReady { + detail: "启动失败".into(), + }) + .diagnostic_detail(), + "启动失败" + ); + assert_eq!( + EnqueueError::ContentEmpty.diagnostic_detail(), + String::new() ); } - /// 边界序列化:字符串只由 Display 生成,且与改造前的可见文本一致。 + /// `terminal_failure` 是失败载荷的唯一投影点:控制流不是失败,返回 `None`。 #[test] - fn boundary_serialization_uses_display() { - let wire: String = DirectTurnError::ContentEmpty.into(); - assert_eq!(wire, "聊天内容不能为空"); - let wire: String = DirectTurnError::TimedOut { - deadline: DirectTurnDeadline::TurnHardLimit, - } - .into(); + fn terminal_failure_projects_failures_and_skips_control_flow() { + let root = Path::new("/tmp/direct-turn-error-test"); + let timed_out = TurnError::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + }); assert_eq!( - wire, - "等待模型回合结束达到硬上限,已停止本轮并核对后台操作。" + timed_out.terminal_failure(root), + Some(TurnFailure::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + })) ); - let wire: String = DirectTurnError::TransportClosed { - diagnostic: "执行通道已断开".into(), - } - .into(); assert_eq!( - wire, - "执行通道已断开,不能自动重放未确认操作:执行通道已断开" + TurnError::ReviewRequired { + detail: "还缺证据".into() + } + .terminal_failure(root), + None + ); + assert_eq!( + TurnError::RepairRequired { + detail: "继续返修".into() + } + .terminal_failure(root), + None ); } - /// 载荷 kind 与改造前的 `direct_turn_failure_kind(&LlmError)` 逐条对齐。 + /// 失败载荷的线上形状:`type` 判别键 + 驼峰字段 + 变体裸名载荷,这是前端唯一读的协议。 + /// 改变体名或字段名就是改协议,必须在这条用例上先失败。 #[test] - fn wire_kind_matches_the_previous_llm_error_classification() { + fn failure_payload_wire_shape_is_stable() { + let payload = TurnFailure::TimedOut(TimedOut { + deadline: Deadline::TurnHardLimit, + }); + assert_eq!( + serde_json::to_value(&payload).expect("serialize"), + serde_json::json!({ "type": "timedOut", "deadline": "turn-hard-limit" }) + ); + assert_eq!( + serde_json::from_value::(serde_json::json!({ + "type": "timedOut", + "deadline": "turn-hard-limit" + })) + .expect("round trip"), + payload + ); + + let payload = TurnFailure::TurnFailed(TurnFailed { + stage: FailureStage::BrowserValidation, + detail: "试玩失败".into(), + }); + assert_eq!( + serde_json::to_value(&payload).expect("serialize"), + serde_json::json!({ + "type": "turnFailed", + "stage": "browser-validation", + "detail": "试玩失败" + }) + ); + + // 没有字段可读的变体只带判别键。 + assert_eq!( + serde_json::to_value(TurnFailure::HostDropped).expect("serialize"), + serde_json::json!({ "type": "hostDropped" }) + ); + } + + /// 平台层 `LlmError` 到 typed 分类的映射,逐条对齐改造前的判据。 + #[test] + fn model_call_kind_matches_the_previous_llm_error_classification() { let cases = [ ( LlmError::Timeout { attempts: 3 }, - DirectTurnFailureKind::Timeout, + ModelCallKind::ResponseTimedOut { attempts: 3 }, ), ( LlmError::InvalidConfig("missing key".into()), - DirectTurnFailureKind::RequestRejected, + ModelCallKind::RequestRejected { native: None }, ), ( LlmError::InvalidRequest("codex-app-server-error:context-window-exceeded".into()), - DirectTurnFailureKind::RequestRejected, + ModelCallKind::RequestRejected { + native: Some(NativeKind::ContextWindowExceeded), + }, ), ( LlmError::Connectivity { attempts: 2, message: "Codex app-server 连接失败".into(), }, - DirectTurnFailureKind::TransportFailed, + ModelCallKind::ConnectionFailed { attempts: 2 }, ), ( LlmError::Transport("DirectProject 收尾历史失败".into()), - DirectTurnFailureKind::TransportFailed, + ModelCallKind::TransportBroken, ), ( LlmError::StreamUnavailable, - DirectTurnFailureKind::TransportFailed, + ModelCallKind::StreamUnavailable, ), ( LlmError::Upstream { status_code: 502, message: "上游 502".into(), }, - DirectTurnFailureKind::ModelFailed, + ModelCallKind::UpstreamFailed { + status_code: 502, + native: None, + }, ), - (LlmError::EmptyResponse, DirectTurnFailureKind::ModelFailed), + (LlmError::EmptyResponse, ModelCallKind::EmptyResponse), ( LlmError::Deserialize("bad payload".into()), - DirectTurnFailureKind::ModelFailed, + ModelCallKind::PayloadInvalid { native: None }, ), ]; for (error, expected) in cases { - let projected = DirectTurnError::from_model_call(&error); - assert_eq!(projected.wire_kind(), Some(expected), "{error:?}"); - assert_eq!(projected.to_string(), error.to_string(), "{error:?}"); - } - } - - /// 载荷 `kind` 的线上取值只有这一份:改枚举成员名就是改协议,必须在这一条用例上先失败。 - /// 新增变体时在这里补一行(生成绑定 `DirectTurnFailureKind.ts` 会同步出现新取值)。 - #[test] - fn failure_kind_wire_values_are_stable() { - let cases = [ - (DirectTurnFailureKind::Timeout, "timeout"), - (DirectTurnFailureKind::ModelFailed, "model-failed"), - (DirectTurnFailureKind::TransportFailed, "transport-failed"), - (DirectTurnFailureKind::RequestRejected, "request-rejected"), - ( - DirectTurnFailureKind::EnvironmentNotReady, - "environment-not-ready", - ), - (DirectTurnFailureKind::TurnInterrupted, "turn-interrupted"), - (DirectTurnFailureKind::HostDropped, "host-dropped"), - ]; - for (kind, wire) in cases { - assert_eq!(serde_json::to_value(kind).expect("serialize"), wire); - assert_eq!( - serde_json::from_str::(&format!("\"{wire}\"")) - .expect("deserialize"), - kind - ); + let projected = TurnError::from_model_call(&error); + match projected { + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, expected, "{error:?}") + } + other => panic!("expected a model call failure, got {other:?}"), + } } } /// 原生分类被读成 typed 值:未知分类不吞掉,落 `Other`。 #[test] fn native_kind_is_read_from_the_structured_prefix_only() { - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:context-window-exceeded detail=fields=codexErrorInfo".into(), )); match projected { - DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( - kind, - DirectModelCallKind::RequestRejected { - native: Some(DirectCodexNativeKind::ContextWindowExceeded) + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { + native: Some(NativeKind::ContextWindowExceeded) } ), other => panic!("expected a model call failure, got {other:?}"), } - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:some-future-kind".into(), )); match projected { - DirectTurnError::ModelCallFailed { kind, .. } => assert_eq!( - kind, - DirectModelCallKind::RequestRejected { - native: Some(DirectCodexNativeKind::Other { + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { + native: Some(NativeKind::Other { kind: "some-future-kind".into() }) } @@ -1138,13 +1226,14 @@ mod tests { other => panic!("expected a model call failure, got {other:?}"), } // 文本里没有结构化前缀就是不分类,不靠"像不像"猜。 - let projected = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let projected = TurnError::from_model_call(&LlmError::InvalidRequest( "Codex app-server turn 已中断".into(), )); match projected { - DirectTurnError::ModelCallFailed { kind, .. } => { - assert_eq!(kind, DirectModelCallKind::RequestRejected { native: None }) - } + TurnError::ModelCallFailed(payload) => assert_eq!( + payload.kind, + ModelCallKind::RequestRejected { native: None } + ), other => panic!("expected a model call failure, got {other:?}"), } } @@ -1152,13 +1241,13 @@ mod tests { /// 上游 409 是平台约定的泥点余额不足,进专属分类。 #[test] fn upstream_payment_refusal_is_its_own_kind() { - let projected = DirectTurnError::from_model_call(&LlmError::Upstream { + let projected = TurnError::from_model_call(&LlmError::Upstream { status_code: 409, message: "泥点余额不足".into(), }); match &projected { - DirectTurnError::ModelCallFailed { kind, .. } => { - assert_eq!(kind, &DirectModelCallKind::PaidCreditsInsufficient) + TurnError::ModelCallFailed(payload) => { + assert_eq!(payload.kind, ModelCallKind::PaidCreditsInsufficient) } other => panic!("expected a model call failure, got {other:?}"), } @@ -1174,27 +1263,26 @@ mod tests { /// 反馈判据:原生分类里"再跑一次也不会变"的那些不再反馈给模型。 #[test] fn terminal_native_kinds_are_not_fed_back_to_the_model() { - let terminal = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let terminal = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:context-window-exceeded".into(), )); assert!(!terminal.is_model_repairable()); - let repairable = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let repairable = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:other detail=fields=codexErrorInfo".into(), )); assert!(repairable.is_model_repairable()); // 通道类失败里只有"连接层反复失败"值得让模型再跑一次。 - assert!(DirectTurnError::from_model_call(&LlmError::Connectivity { + assert!(TurnError::from_model_call(&LlmError::Connectivity { attempts: 2, message: "连接失败".into(), }) .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + assert!(!TurnError::from_model_call(&LlmError::Transport( "DirectProject 收尾历史失败".into() )) .is_model_repairable()); assert!( - !DirectTurnError::from_model_call(&LlmError::Timeout { attempts: 1 }) - .is_model_repairable() + !TurnError::from_model_call(&LlmError::Timeout { attempts: 1 }).is_model_repairable() ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs index 3762251ca..246c484ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/mod.rs @@ -4,7 +4,7 @@ mod model; mod validation; mod wire; -pub(crate) use model::DirectCodexUserItem; +pub(crate) use model::UserItem; pub(crate) use wire::{ direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item, freeze_direct_codex_user_item, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs index 0fbd38640..980b429df 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/model.rs @@ -5,31 +5,31 @@ use ts_rs::TS; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserItem { +pub(crate) enum UserItem { #[serde(rename = "message")] - Message(DirectCodexUserMessageItem), + Message(UserMessageItem), } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserMessageItem { - pub(crate) role: DirectCodexUserRole, - pub(crate) content: Vec, +pub(crate) struct UserMessageItem { + pub(crate) role: UserRole, + pub(crate) content: Vec, pub(crate) id: String, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserRole { +pub(crate) enum UserRole { User, } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum DirectCodexUserContentPart { +pub(crate) enum UserContentPart { #[serde(rename = "input_text")] InputText { text: String }, #[serde(rename = "agc_resource_reference")] @@ -44,16 +44,16 @@ pub(crate) enum DirectCodexUserContentPart { #[serde(rename = "agc_skill_reference")] AgcSkillReference { name: String }, #[serde(rename = "agc_runtime_region_reference")] - AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart), + AgcRuntimeRegionReference(UserRuntimeRegionPart), /// Uploaded project attachment kept inline in canonical content. #[serde(rename = "agc_attachment_reference")] - AgcAttachmentReference(DirectCodexUserAttachmentReferencePart), + AgcAttachmentReference(UserAttachmentReferencePart), } #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserAttachmentReferencePart { +pub(crate) struct UserAttachmentReferencePart { pub(crate) name: String, pub(crate) media_type: String, #[ts(type = "number")] @@ -65,7 +65,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart { #[derive(Clone, Debug, PartialEq, Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectCodexUserRuntimeRegionPart { +pub(crate) struct UserRuntimeRegionPart { pub(crate) label: String, #[serde(default)] pub(crate) run_id: Option, @@ -92,9 +92,9 @@ mod tests { #[test] fn resource_reference_serializes_with_only_camel_case_resource_id() { - let item = DirectCodexUserItem::Message(DirectCodexUserMessageItem { - role: DirectCodexUserRole::User, - content: vec![DirectCodexUserContentPart::AgcResourceReference { + let item = UserItem::Message(UserMessageItem { + role: UserRole::User, + content: vec![UserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), resolved_text: None, }], @@ -116,7 +116,7 @@ mod tests { #[test] fn resource_reference_rejects_extra_identity_fields() { - let error = serde_json::from_value::(json!({ + let error = serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{ @@ -133,7 +133,7 @@ mod tests { /// 旧历史里的资源引用没有 `resolvedText`:缺省必须合法,照样解析成 `None` 的引用。 #[test] fn resource_reference_without_resolved_text_still_parses() { - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "content": [{ @@ -143,9 +143,9 @@ mod tests { "id": "turn-1" })) .expect("legacy resource reference must keep parsing"); - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; match &message.content[0] { - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -158,7 +158,7 @@ mod tests { #[test] fn skill_reference_serializes_with_only_the_stable_name() { - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "content": [{"type": "agc_skill_reference", "name": "agc-web-game-development"}], @@ -172,7 +172,7 @@ mod tests { // canonical part 不接受正文、路径或凭据类附加字段:它们只可能来自宿主私密状态。 for forbidden in ["path", "body", "content", "token", "apiKey"] { - let error = serde_json::from_value::(json!({ + let error = serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{ @@ -192,7 +192,7 @@ mod tests { #[test] fn unknown_content_part_fails_closed() { - serde_json::from_value::(json!({ + serde_json::from_value::(json!({ "type": "message", "role": "user", "content": [{"type": "future_part", "value": "x"}], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs index a14d93b63..c293f2982 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/validation.rs @@ -1,7 +1,4 @@ -use super::model::{ - DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserRole, - DirectCodexUserRuntimeRegionPart, -}; +use super::model::{UserContentPart, UserItem, UserRole, UserRuntimeRegionPart}; use crate::agent::{ read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest, MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS, @@ -16,10 +13,10 @@ pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32; pub(crate) fn validate_direct_codex_user_item( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result { - let DirectCodexUserItem::Message(message) = item; - if !matches!(message.role, DirectCodexUserRole::User) { + let UserItem::Message(message) = item; + if !matches!(message.role, UserRole::User) { return Err("DirectProject 只接受 user message item".to_string()); } if message.id.trim().is_empty() { @@ -36,12 +33,12 @@ pub(crate) fn validate_direct_codex_user_item( let mut skill_count = 0usize; for part in &message.content { match part { - DirectCodexUserContentPart::InputText { .. } => {} - DirectCodexUserContentPart::AgcResourceReference { resource_id, .. } => { + UserContentPart::InputText { .. } => {} + UserContentPart::AgcResourceReference { resource_id, .. } => { reference_count = reference_count.saturating_add(1); validate_resource_id_and_manifest(&manifest, resource_id)?; } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { skill_count = skill_count.saturating_add(1); if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES { return Err(format!( @@ -61,11 +58,11 @@ pub(crate) fn validate_direct_codex_user_item( return Err("引用的 Skill 名称无效,请移除后重新选择".to_string()); } } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { reference_count = reference_count.saturating_add(1); validate_runtime_region_reference(&manifest, reference)?; } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { attachment_count = attachment_count.saturating_add(1); if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS { return Err(format!( @@ -112,9 +109,9 @@ pub(crate) fn validate_direct_codex_user_item( } /// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。 -pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool { +pub(crate) fn content_has_meaningful_input(content: &[UserContentPart]) -> bool { content.iter().any(|part| match part { - DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(), + UserContentPart::InputText { text } => !text.trim().is_empty(), _ => true, }) } @@ -142,7 +139,7 @@ pub(crate) fn validate_resource_id_and_manifest( fn validate_runtime_region_reference( manifest: &GameCreationAppManifest, - reference: &DirectCodexUserRuntimeRegionPart, + reference: &UserRuntimeRegionPart, ) -> Result<(), String> { if reference.label.trim().is_empty() { return Err("运行画面区域缺少名称".to_string()); @@ -164,11 +161,11 @@ mod tests { content_has_meaningful_input, validate_direct_codex_user_item, MAX_DIRECT_CODEX_SKILL_REFERENCES, }; - use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart; + use crate::agent::direct_codex_user_item::model::UserContentPart; use serde_json::json; - fn input_text(text: &str) -> DirectCodexUserContentPart { - DirectCodexUserContentPart::InputText { + fn input_text(text: &str) -> UserContentPart { + UserContentPart::InputText { text: text.to_string(), } } @@ -200,7 +197,7 @@ mod tests { #[test] fn non_text_parts_always_count_as_input() { assert!(content_has_meaningful_input(&[ - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id: "asset-hero".to_string(), resolved_text: None, }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs index ca0837eee..dab0e3ca1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs @@ -1,6 +1,5 @@ use super::model::{ - DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem, - DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart, + UserAttachmentReferencePart, UserContentPart, UserItem, UserMessageItem, UserRuntimeRegionPart, }; use super::validation::validate_direct_codex_user_item; use crate::agent::{ @@ -15,21 +14,21 @@ use serde_json::Value; use std::path::Path; /// 入队检查产出的**冻结条目**:校验 → 把每个引用 part 的解析文本写进它自己的 -/// [`DirectCodexUserContentPart::AgcResourceReference::resolved_text`] → 判空。 +/// [`UserContentPart::AgcResourceReference::resolved_text`] → 判空。 /// /// 这是这条消息**唯一**会算片段、会写盘(UI 设计文档代码导出)的地方,也是唯一的失败出口: /// 之后的 prompt 折叠([`direct_codex_user_item_to_prompt`])与放行都不再重算、不再读 manifest, /// 因此也没有失败可言。冻结结果随条目一路走到历史、事件与 turn input——它们读的都是同一份文本。 pub(crate) fn freeze_direct_codex_user_item( root: &Path, - item: &DirectCodexUserItem, -) -> Result { + item: &UserItem, +) -> Result { let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut content = Vec::with_capacity(message.content.len()); for part in &message.content { match part { - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -42,7 +41,7 @@ pub(crate) fn freeze_direct_codex_user_item( resource_id, )?), }; - content.push(DirectCodexUserContentPart::AgcResourceReference { + content.push(UserContentPart::AgcResourceReference { resource_id: resource_id.clone(), resolved_text, }); @@ -50,7 +49,7 @@ pub(crate) fn freeze_direct_codex_user_item( other => content.push(other.clone()), } } - let frozen = DirectCodexUserItem::Message(DirectCodexUserMessageItem { + let frozen = UserItem::Message(UserMessageItem { role: message.role.clone(), content, id: message.id.clone(), @@ -75,7 +74,7 @@ pub(crate) fn direct_codex_user_item_to_response_item( } return Err("DirectProject 历史 item 缺少 type,无法投影为 Codex item".to_string()); } - let canonical: DirectCodexUserItem = serde_json::from_value(item.clone()) + let canonical: UserItem = serde_json::from_value(item.clone()) .map_err(|error| format!("DirectProject user item 无法转换为 Codex item:{error}"))?; let content = direct_codex_user_item_to_response_content(root, &canonical)?; let mut projected = serde_json::json!({ @@ -91,7 +90,7 @@ pub(crate) fn direct_codex_user_item_to_response_item( fn direct_codex_user_item_to_response_content( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result, String> { let Value::Array(input) = direct_codex_user_item_to_wire_input(root, item)? else { return Err("DirectProject user item wire content 不是数组".to_string()); @@ -125,7 +124,7 @@ fn resource_reference_summary( )) } -fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String { +fn runtime_region_summary(reference: &UserRuntimeRegionPart) -> String { let resources = reference .resource_ids .iter() @@ -153,7 +152,7 @@ fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> Strin /// /// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、 /// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。 -fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String { +fn attachment_reference_summary(reference: &UserAttachmentReferencePart) -> String { let name = sanitize_attachment_name(&reference.name); let media_type = sanitize_attachment_media_type(&reference.media_type); let mut summary = format!( @@ -172,16 +171,16 @@ fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePa /// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。 pub(crate) fn direct_codex_user_item_to_wire_input( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, ) -> Result { // validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。 let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { let text = match part { - DirectCodexUserContentPart::InputText { text } => text.clone(), - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::InputText { text } => text.clone(), + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => match resolved_text { @@ -190,13 +189,13 @@ pub(crate) fn direct_codex_user_item_to_wire_input( // 旧历史没有这份文本,退回按当前 manifest 现算(只有摘要,不产生写副作用)。 None => resource_reference_summary(&manifest, resource_id)?, }, - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { format!("${}", name.trim()) } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { runtime_region_summary(reference) } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { attachment_reference_summary(reference) } }; @@ -207,18 +206,18 @@ pub(crate) fn direct_codex_user_item_to_wire_input( pub(crate) fn direct_codex_user_item_to_codex_turn_input( root: &Path, - item: &DirectCodexUserItem, + item: &UserItem, skill_roots: &[std::path::PathBuf], ) -> Result { let manifest = validate_direct_codex_user_item(root, item)?; - let DirectCodexUserItem::Message(message) = item; + let UserItem::Message(message) = item; let mut input = Vec::with_capacity(message.content.len()); for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => { + UserContentPart::InputText { text } => { input.push(serde_json::json!({ "type": "text", "text": text })); } - DirectCodexUserContentPart::AgcResourceReference { + UserContentPart::AgcResourceReference { resource_id, resolved_text, } => { @@ -230,7 +229,7 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( }, })); } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { let name = name.trim(); let path = skill_roots .iter() @@ -243,13 +242,13 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( "path": path, })); } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { input.push(serde_json::json!({ "type": "text", "text": runtime_region_summary(reference), })); } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { input.push(serde_json::json!({ "type": "text", "text": attachment_reference_summary(reference), @@ -266,24 +265,24 @@ pub(crate) fn direct_codex_user_item_to_codex_turn_input( /// [`freeze_direct_codex_user_item`] 冻结,随条目持久化)。条目里没有引用之外的东西要算, /// 所以这里也不再需要 root。调用方要保证条目是冻结过的;缺省 `resolved_text` 的旧条目 /// 走不了这一条(历史回读用 [`direct_codex_user_item_to_response_item`])。 -pub(crate) fn direct_codex_user_item_to_prompt(item: &DirectCodexUserItem) -> String { - let DirectCodexUserItem::Message(message) = item; +pub(crate) fn direct_codex_user_item_to_prompt(item: &UserItem) -> String { + let UserItem::Message(message) = item; let mut prompt = String::new(); for part in &message.content { match part { - DirectCodexUserContentPart::InputText { text } => prompt.push_str(text), - DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + UserContentPart::InputText { text } => prompt.push_str(text), + UserContentPart::AgcResourceReference { resolved_text, .. } => { if let Some(text) = resolved_text { prompt.push_str(text); } } - DirectCodexUserContentPart::AgcSkillReference { name } => { + UserContentPart::AgcSkillReference { name } => { prompt.push_str(&format!("${}", name.trim())); } - DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => { + UserContentPart::AgcRuntimeRegionReference(reference) => { prompt.push_str(&runtime_region_summary(reference)); } - DirectCodexUserContentPart::AgcAttachmentReference(reference) => { + UserContentPart::AgcAttachmentReference(reference) => { prompt.push_str(&attachment_reference_summary(reference)); } } @@ -351,7 +350,7 @@ mod tests { direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input, freeze_direct_codex_user_item, validate_direct_codex_user_item, }; - use crate::agent::direct_codex_user_item::model::DirectCodexUserItem; + use crate::agent::direct_codex_user_item::model::UserItem; use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE; use serde_json::json; use shared_contracts::game_creation_app::{ @@ -437,7 +436,7 @@ mod tests { #[test] fn ui_design_doc_reference_appends_generated_code_context() { let (project, asset_id) = ui_design_doc_fixture(true); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -453,9 +452,9 @@ mod tests { "{prompt}" ); // 解析文本随条目持久化:历史回放读的就是这一份,不再重算、不再写盘。 - let super::DirectCodexUserItem::Message(message) = &frozen; + let super::UserItem::Message(message) = &frozen; match &message.content[0] { - super::DirectCodexUserContentPart::AgcResourceReference { resolved_text, .. } => { + super::UserContentPart::AgcResourceReference { resolved_text, .. } => { assert_eq!(resolved_text.as_deref(), Some(prompt.as_str())); } other => panic!("expected a frozen resource reference, got {other:?}"), @@ -488,7 +487,7 @@ mod tests { #[test] fn ui_design_generation_failure_keeps_reference_and_reports_error() { let (project, asset_id) = ui_design_doc_fixture(false); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -506,7 +505,7 @@ mod tests { GameCreationAppAssetKind::Character, "image/png", ); - let item: super::DirectCodexUserItem = serde_json::from_value(json!({ + let item: super::UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-multi-1:user", @@ -547,7 +546,7 @@ mod tests { GameCreationAppAssetKind::Character, "image/png", ); - let item: super::DirectCodexUserItem = + let item: super::UserItem = serde_json::from_value(user_item_with_resource_reference(&asset_id)) .expect("canonical user item"); let frozen = freeze_direct_codex_user_item(project.path(), &item).expect("freeze"); @@ -774,8 +773,7 @@ mod tests { {"type": "agc_skill_reference", "name": "missing-skill"} ] }); - let user_item: DirectCodexUserItem = - serde_json::from_value(item).expect("parse canonical item"); + let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item"); // 已启用目录里没有这个 Skill:转换必须在启动回合前失败关闭, // 不能把不可用的引用降级成正文放行。 let error = direct_codex_user_item_to_codex_turn_input( @@ -804,8 +802,7 @@ mod tests { {"type": "input_text", "text": "然后创建菜单"} ] }); - let user_item: DirectCodexUserItem = - serde_json::from_value(item).expect("parse canonical item"); + let user_item: UserItem = serde_json::from_value(item).expect("parse canonical item"); let input = direct_codex_user_item_to_codex_turn_input(root.path(), &user_item, &[skill_root]) .expect("available skill should convert"); @@ -884,7 +881,7 @@ mod tests { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-1:user", @@ -908,7 +905,7 @@ mod tests { let root = tempfile::tempdir().expect("temp project"); crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试") .expect("init project"); - let item: DirectCodexUserItem = serde_json::from_value(json!({ + let item: UserItem = serde_json::from_value(json!({ "type": "message", "role": "user", "id": "turn-1:user", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs index df71818da..7db42349f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_delivery.rs @@ -699,12 +699,12 @@ pub(super) async fn finish_sealing( /// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。 /// -/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 +/// 返修要求是**控制流**([`TurnError::ReviewRequired`]),不是失败:调用方据此把要求写回 /// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。 pub(super) async fn review_reply( root: &Path, session: &Arc, -) -> Result, DirectTurnError> { +) -> Result, TurnError> { if let Some(report) = terminal_report(session) { return Ok(Some(report)); } @@ -755,7 +755,7 @@ pub(super) async fn review_reply( .map_err(|_| "delivery-review-worker-exited")??; return Ok(Some(report)); } - Err(DirectTurnError::ReviewRequired { + Err(TurnError::ReviewRequired { detail: format!("delivery-review-required: {detail}"), }) } @@ -922,7 +922,7 @@ mod tests { for _ in 0..2 { assert!(matches!( review_reply(new_game.path(), &required).await.unwrap_err(), - DirectTurnError::ReviewRequired { .. } + TurnError::ReviewRequired { .. } )); } assert!(review_reply(new_game.path(), &required) 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 831b3d823..a3b6654e4 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 @@ -1937,45 +1937,114 @@ fn direct_taonier_art_generation_outcome( } /// 回合级失败最多反馈给模型几次:工具 / 构建 / 试玩类失败继续修,基础设施类失败在 -/// [`DirectTurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 +/// [`TurnError::is_model_repairable`] 里就已经被拦下,不会走到这里。 const DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS: usize = 3; fn direct_codex_error_feedback_prompt(error: &str) -> String { format!(prompt_text!("direct.errorFeedback"), error = error) } +/// 回合失败诊断的默认恢复建议。 +const DIRECT_CODEX_DEFAULT_RECOVERY_HINT: &str = "请重试;如持续失败请检查项目诊断"; + /// 把 typed 失败写成诊断:摘要 / 可重试 / 建议全部由 typed 分类判定,文本只用于详情与兜底。 /// -/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的调用级拒绝** -/// ([`DirectTurnError::is_reportable`],宿主 / 环境事实)。 +/// 两个调用方:回合失败路径(这一轮已经开始了),以及命令边界上**可留痕的入队失败** +/// ([`EnqueueError::is_reportable`],宿主 / 环境事实)。 /// -/// 返回给用户看的那行 `direct-codex-failure:v2 ...` 文本:它是这一轮(或这次拒绝)的收口说明, -/// 事件载荷与横幅共用同一份,命令边界也只序列化它一次。诊断文件的引用**不进**这份文案: -/// 界面不再展开详情,线索只留在宿主侧(`.agent/runtime/errors`、应用日志与错误上报池)。 +/// 返回值是 `direct-codex-failure:v2 ...` 这行宿主收口文本:它只进 `.agent/runtime/errors` +/// 与应用日志、错误上报池,**不再下发给前端**(前端按 typed 变体自己拼用户可见文案)。 pub(crate) fn record_direct_codex_failure( root: &Path, - failure: &DirectTurnError, + failure: &TurnError, client_turn_id: Option<&str>, ) -> String { - let detail = failure.to_string(); - let stage = failure.turn_failure_stage(); + let detail = failure.diagnostic_detail(); let summary = failure .public_summary() .map(str::to_string) .unwrap_or_else(|| redact_agent_runtime_error(root, &detail, 320)); + record_direct_codex_failure_facts( + root, + client_turn_id, + failure.turn_failure_stage().id(), + direct_codex_error_code(failure), + failure.is_retryable(), + failure + .recovery_hint() + .unwrap_or(DIRECT_CODEX_DEFAULT_RECOVERY_HINT), + detail, + summary, + ) +} + +/// 入队失败边界的诊断:入队侧没有阶段、也没有恢复建议,只留宿主原文。 +pub(crate) fn record_direct_codex_enqueue_failure( + root: &Path, + failure: &EnqueueError, + client_turn_id: Option<&str>, +) -> String { + let detail = failure.diagnostic_detail(); + let summary = redact_agent_runtime_error(root, &detail, 320); + record_direct_codex_failure_facts( + root, + client_turn_id, + FailureStage::CodeGeneration.id(), + classify_direct_codex_error(&detail), + false, + DIRECT_CODEX_DEFAULT_RECOVERY_HINT, + detail, + summary, + ) +} + +/// 失败标签:能由 typed 事实决定的先看变体,只有产生层还没有 typed 出口的才回落到文本分类。 +/// +/// 修掉两处错标:`TimedOut` 撞的是哪条上限是 typed 的(改造前两种超时都被读成空闲超时,而 +/// `turn-hard-timeout` 那条标签没有任何生产者、根本不可达);通道断开也不再要求诊断文本里恰好 +/// 出现 "transport closed"。 +fn direct_codex_error_code(failure: &TurnError) -> &'static str { + match failure { + TurnError::TimedOut(payload) => match payload.deadline { + Deadline::ResponseIdle => "turn-idle-timeout", + Deadline::TurnHardLimit => "turn-hard-timeout", + }, + TurnError::TransportClosed(_) => "transport-closed", + // 产生层还没有 typed 出口:按宿主原文分类(TODO(文本判据))。 + TurnError::ProjectRootUnanchored(_) + | TurnError::EnvironmentNotReady(_) + | TurnError::HostStateUnavailable(_) + | TurnError::ModelCallFailed(_) + | TurnError::TurnInterrupted(_) + | TurnError::ReviewRequired { .. } + | TurnError::RepairRequired { .. } => { + classify_direct_codex_error(&failure.diagnostic_detail()) + } + TurnError::TurnFailed(payload) => classify_direct_codex_error(&payload.detail), + TurnError::TurnFailedUnclassified(payload) => classify_direct_codex_error(&payload.detail), + } +} + +#[allow(clippy::too_many_arguments)] +fn record_direct_codex_failure_facts( + root: &Path, + client_turn_id: Option<&str>, + stage: &str, + error_code: &str, + retryable: bool, + recovery_hint: &str, + detail: String, + summary: String, +) -> String { let summary = summary.split_whitespace().collect::>().join(" "); let summary = if summary.trim().is_empty() { "未提供可安全展示的详细原因".to_string() } else { summary }; - let retryable = failure.is_retryable(); - let recovery_hint = failure - .recovery_hint() - .unwrap_or("请重试;如持续失败请检查项目诊断"); let diagnostic = serde_json::json!({ "schemaVersion": "direct-codex-diagnostic.v1", - "stage": stage.id(), + "stage": stage, "summary": summary, "retryable": retryable, "recoveryHint": recovery_hint, @@ -1998,14 +2067,12 @@ pub(crate) fn record_direct_codex_failure( } else { "未能保存项目诊断" }; - // 诊断 code 仍走共享的 runtime_error 分类(它是跨链路的持久化标签,不是流程判据)。 - let error_code = classify_direct_codex_error(&detail); // sidecar 照写,但引用不进用户可见文案。 let _ = persist_agent_runtime_error( root, client_turn_id, "direct-codex", - stage.id(), + stage, error_code, retryable, &summary, @@ -2019,7 +2086,7 @@ pub(crate) fn record_direct_codex_failure( .ok(); let public_text = format!( "direct-codex-failure:v2 stage={} code={} retryable={} summary={};建议:{};{}", - stage.id(), + stage, error_code, retryable, diagnostic["summary"] @@ -2034,23 +2101,19 @@ pub(crate) fn record_direct_codex_failure( public_text } -/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后连同**结构化 -/// 变体**一起交给前端;其余只输出 [`DirectTurnError`] 的 `Display`。 +/// 入队失败边界:可留痕的调用级失败(宿主 / 环境事实)在这里补一份运行错误诊断,然后原样把 +/// **结构化变体**交给前端。命令返回的就是变体本身,没有第二层包装、也没有预拼文案。 /// /// 命令边界只有这一份判据,禁止在各处再写一套。 pub(crate) fn direct_turn_enqueue_failure( root: &Path, client_turn_id: Option<&str>, - failure: DirectTurnError, -) -> DirectTurnEnqueueFailure { - if !failure.is_reportable() { - return DirectTurnEnqueueFailure::new(failure); - } - let message = record_direct_codex_failure(root, &failure, client_turn_id); - DirectTurnEnqueueFailure { - error: failure, - message, + failure: EnqueueError, +) -> EnqueueError { + if failure.is_reportable() { + record_direct_codex_enqueue_failure(root, &failure, client_turn_id); } + failure } fn direct_taonier_art_generation_runtime_context( @@ -4187,11 +4250,11 @@ fn is_direct_codex_item_started_work_detail(value: &str) -> bool { /// item activity remain `running` because they describe work rather than the /// user-visible reply body. fn direct_codex_observation_status( - observation: &DirectCodexTurnObservation, + observation: &TurnObservation, stream_enabled: bool, ) -> &'static str { match observation { - DirectCodexTurnObservation::AccumulatedText(_) if stream_enabled => "streaming", + TurnObservation::AccumulatedText(_) if stream_enabled => "streaming", _ => "running", } } @@ -4279,19 +4342,21 @@ pub(crate) fn check_direct_turn_preconditions( root: &Path, prompt: &str, creation_type: Option<&str>, -) -> Result<(), DirectTurnError> { +) -> Result<(), EnqueueError> { if !root.is_absolute() || !root.is_dir() { - return Err(DirectTurnError::ProjectRootUnusable); + return Err(EnqueueError::ProjectRootUnusable); } - enforce_project_permission_policy(root, "conversation.read") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; - enforce_project_permission_policy(root, "conversation.write") - .map_err(|policy_detail| DirectTurnError::PermissionRejected { policy_detail })?; + enforce_project_permission_policy(root, "conversation.read").map_err(|policy_detail| { + EnqueueError::PermissionRejected(PermissionRejected { policy_detail }) + })?; + enforce_project_permission_policy(root, "conversation.write").map_err(|policy_detail| { + EnqueueError::PermissionRejected(PermissionRejected { policy_detail }) + })?; if prompt.trim().is_empty() { - return Err(DirectTurnError::ContentEmpty); + return Err(EnqueueError::ContentEmpty); } direct_creation_type_system_context(creation_type) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; + .map_err(|detail| EnqueueError::InputRejected(InputRejected { detail }))?; Ok(()) } /// Direct 回合在 `direct_execution::begin` 之后的分段 trace。 @@ -4319,7 +4384,7 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt crate::analytics::store::AnalyticsWriter, )>, release_identity_generation: u64, -) -> Result { +) -> Result { let prompt = prompt.trim(); emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { @@ -4353,10 +4418,10 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type_and_emitt if let Some(emitter) = turn_emitter { emitter.emit("failed", Some("none")); } - Err(DirectTurnError::TurnFailed { + Err(TurnError::TurnFailed(TurnFailed { stage, detail: error, - }) + })) } } } @@ -4372,28 +4437,22 @@ async fn run_direct_game_creator_turn_inner( crate::analytics::store::AnalyticsWriter, )>, release_identity_generation: u64, -) -> Result { +) -> Result { let requires_contract = super::direct_delivery::requires_new_web_contract( root, creation_type, turn_emitter.is_none(), ) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; // CLI 没有首页适配器;可信、尚未交付的脚手架沿用同一宿主准备入口。 if requires_contract && turn_emitter.is_none() { crate::environment_check::prepare_new_web_project_at(root, Some("game")) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; } let execution_config = load_game_creator_app_config() - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })? + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))? .validation; let analytics_run = capture.as_ref().map(|(context, _)| { crate::analytics::run::Metadata::new( @@ -4410,9 +4469,7 @@ async fn run_direct_game_creator_turn_inner( analytics_run, ) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let execution_session = execution_guard.session(); execution_session.set_analytics_capture(capture.clone()); direct_turn_trace("session-ready"); @@ -4428,7 +4485,7 @@ async fn run_direct_game_creator_turn_inner( } direct_turn_trace("run-analytics-accepted"); // 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。 - let result: Result = async { + let result: Result = async { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { return Ok(report); } @@ -4446,9 +4503,7 @@ async fn run_direct_game_creator_turn_inner( .as_deref(), ) }) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?, }; if execution_session.newly_accepted { if let Ok(ledger) = execution_session.snapshot() { @@ -4467,16 +4522,11 @@ async fn run_direct_game_creator_turn_inner( } let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let base_system_prompt = - build_direct_codex_system_prompt_with_creation_type(root, creation_type).map_err( - |error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - }, - )?; + build_direct_codex_system_prompt_with_creation_type(root, creation_type) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; direct_turn_trace("system-prompt-ready"); // 三维请求:把"自选三维技术栈、解除 Phaser 固定约束"的合同放在系统提示最前, // 避免被长度上限截断,也不阻断任何工具。 @@ -4492,10 +4542,10 @@ async fn run_direct_game_creator_turn_inner( direct_turn_trace("executor-dispatch"); let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); - let mut observer = |observation: DirectCodexTurnObservation| { + let mut observer = |observation: TurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { - DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + TurnObservation::AccumulatedText(accumulated_text) => { let visible_text = project_direct_codex_accumulated_text( stream_enabled, &accumulated_text, @@ -4505,13 +4555,13 @@ async fn run_direct_game_creator_turn_inner( } emitter.emit(status, None); } - DirectCodexTurnObservation::AgentMessageSegment(accumulated_text) => { + TurnObservation::AgentMessageSegment(accumulated_text) => { if project_direct_codex_visible_text(&accumulated_text).is_none() { return; } emitter.emit(status, None); } - DirectCodexTurnObservation::IntermediateText(intermediate_text) => { + TurnObservation::IntermediateText(intermediate_text) => { let is_visible = stream_enabled || is_direct_codex_item_started_work_detail(&intermediate_text); if is_visible @@ -4520,17 +4570,17 @@ async fn run_direct_game_creator_turn_inner( emitter.emit(status, None); } } - DirectCodexTurnObservation::Activity(activity) => { + TurnObservation::Activity(activity) => { emitter.emit(status, Some(activity)); } - DirectCodexTurnObservation::Reasoning(_) => { + TurnObservation::Reasoning(_) => { // 思考增量只把活动回合标成 running;正文由运行态事件投影下发。 emitter.emit("running", None); } } }; let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; + let mut turn_kind = TurnKind::User; let mut attempt = 1; let reply_result = loop { let result = direct_game_creator_codex_chat_at_with_optional_observer( @@ -4543,7 +4593,7 @@ async fn run_direct_game_creator_turn_inner( Some(direct_user_item.clone()), ) .await; - turn_kind = DirectCodexTurnKind::HostFeedback; + turn_kind = TurnKind::HostFeedback; match result { Ok(value) => { match super::direct_delivery::review_reply(root, &execution_session).await { @@ -4554,8 +4604,8 @@ async fn run_direct_game_creator_turn_inner( // 两条路的次数上限都在产生侧(交付复核 `ledger.max_runs`、执行账本的批次上限), // 这里不另设计数,否则会把本来能收敛的长返修提前掐断。 Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, + TurnError::ReviewRequired { detail } + | TurnError::RepairRequired { detail }, ) => { emitter.emit("running", Some("host-review")); feedback_prompt = format!( @@ -4574,7 +4624,8 @@ async fn run_direct_game_creator_turn_inner( None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + let detail = + redact_agent_runtime_error(root, &error.diagnostic_detail(), 1800); emitter.emit("running", Some("error-feedback")); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail); @@ -4587,7 +4638,7 @@ async fn run_direct_game_creator_turn_inner( reply_result } else { let mut feedback_prompt = prompt.to_string(); - let mut turn_kind = DirectCodexTurnKind::User; + let mut turn_kind = TurnKind::User; let mut attempt = 1; let response = loop { let result = direct_game_creator_codex_chat_at_with_optional_observer( @@ -4600,7 +4651,7 @@ async fn run_direct_game_creator_turn_inner( Some(direct_user_item.clone()), ) .await; - turn_kind = DirectCodexTurnKind::HostFeedback; + turn_kind = TurnKind::HostFeedback; match result { Ok(value) => { match super::direct_delivery::review_reply(root, &execution_session).await { @@ -4610,8 +4661,8 @@ async fn run_direct_game_creator_turn_inner( // `RepairRequired` 是同一族的第二条来源(app-server 封口复核),处理完全一样; // 次数上限在产生侧(见流式分支同一处注释),这里不另设计数。 Err( - DirectTurnError::ReviewRequired { detail } - | DirectTurnError::RepairRequired { detail }, + TurnError::ReviewRequired { detail } + | TurnError::RepairRequired { detail }, ) => { feedback_prompt = format!( prompt_text!("direct.deliveryFeedback"), @@ -4630,7 +4681,8 @@ async fn run_direct_game_creator_turn_inner( None if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS && error.is_model_repairable() => { - let detail = redact_agent_runtime_error(root, &error.to_string(), 1800); + let detail = + redact_agent_runtime_error(root, &error.diagnostic_detail(), 1800); attempt += 1; feedback_prompt = direct_codex_error_feedback_prompt(&detail); } @@ -4639,15 +4691,12 @@ async fn run_direct_game_creator_turn_inner( } }; response.ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "陶泥儿错误反馈回合未返回结果", - ) + TurnError::turn_failed(FailureStage::CodeGeneration, "陶泥儿错误反馈回合未返回结果") }) }?; let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + TurnError::turn_failed( + FailureStage::CodeGeneration, "陶泥儿未返回可展示的回复".to_string(), ) })?; @@ -4665,10 +4714,7 @@ async fn run_direct_game_creator_turn_inner( } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error, - ) + TurnError::turn_failed(FailureStage::VersionRegistration, error) })?; } Ok(visible_reply) @@ -4792,7 +4838,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( root: &Path, prompt: &str, prepare_art: bool, -) -> Result { +) -> Result { if prepare_art { let mode = if direct_prompt_requests_fresh_art_generation(prompt) { DirectTaonierArtPreparationMode::Regenerate @@ -4801,14 +4847,11 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( }; ensure_direct_taonier_art_package_at(root, prompt, mode) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::ArtPreparation, error) - })?; + .map_err(|error| TurnError::turn_failed(FailureStage::ArtPreparation, error))?; } let previous_output_fingerprint = direct_codex_output_fingerprint(root); - let mut system_prompt = build_direct_codex_system_prompt(root).map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + let mut system_prompt = build_direct_codex_system_prompt(root) + .map_err(|error| TurnError::turn_failed(FailureStage::CodeGeneration, error))?; if prepare_art { system_prompt.push_str(prompt_text!("direct.production.preparedArt")); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); @@ -4817,11 +4860,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( emit_direct_game_creator_progress(root, "codex.start", "正在修改已有游戏并进行试玩"); } let _initial_reply = - direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()).await?; let initial_output_fingerprint = direct_codex_output_fingerprint(root); let mut completion_error = direct_game_output_completion_error(root); let mut evidence_attempts = 0_usize; @@ -4837,9 +4876,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( evidence = Some( run_direct_browser_evidence_at(root, evidence_attempts) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?, ); browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); } else { @@ -4853,11 +4890,8 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( completion_error.as_deref(), ); emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); - let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + let mut reply = + direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt).await?; let repaired_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); if completion_error.is_none() @@ -4869,9 +4903,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( evidence = Some( run_direct_browser_evidence_at(root, evidence_attempts) .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?, ); browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); } else if completion_error.is_some() { @@ -4890,11 +4922,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "codex.repair", "试玩未通过,正在进行最后一次自主修复", ); - reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) - .await - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, error) - })?; + reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt).await?; let final_fingerprint = direct_codex_output_fingerprint(root); completion_error = direct_game_output_completion_error(root); if completion_error.is_none() @@ -4910,10 +4938,7 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( run_direct_browser_evidence_at(root, evidence_attempts) .await .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, - error, - ) + TurnError::turn_failed(FailureStage::BrowserValidation, error) })?, ); } else if completion_error.is_some() { @@ -4924,30 +4949,25 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( if let Some(evidence) = evidence.as_ref() { write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) .map_err(|error| { - DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, - error, - ) + TurnError::turn_failed(FailureStage::VersionRegistration, error) })?; } - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::VersionRegistration, + return Err(TurnError::turn_failed( + FailureStage::VersionRegistration, format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), )); } let Some(evidence) = evidence else { - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", )); }; if !evidence.passed { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?; - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?; + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, format!( "Codex 自主试玩未通过,未登记完成版本:{}", evidence @@ -4962,11 +4982,9 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( } if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) - .map_err(|error| { - DirectTurnError::turn_failed(DirectCodexFailureStage::BrowserValidation, error) - })?; - return Err(DirectTurnError::turn_failed( - DirectCodexFailureStage::BrowserValidation, + .map_err(|error| TurnError::turn_failed(FailureStage::BrowserValidation, error))?; + return Err(TurnError::turn_failed( + FailureStage::BrowserValidation, "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" .to_string(), )); @@ -4976,12 +4994,10 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( "codex.ready", "游戏已通过真实浏览器试玩,正在登记项目版本", ); - sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( - |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), - )?; - write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( - |error| DirectTurnError::turn_failed(DirectCodexFailureStage::VersionRegistration, error), - )?; + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; + write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts) + .map_err(|error| TurnError::turn_failed(FailureStage::VersionRegistration, error))?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); Ok(format!( "{}\n\n{}", @@ -5042,47 +5058,45 @@ mod tests { #[test] fn model_failures_are_fed_back_only_when_another_attempt_can_repair_them() { // 工具 / 构建 / 试玩这类"代码里的问题"没有 typed 事实,按可修处理。 - assert!(DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!(TurnError::turn_failed( + FailureStage::CodeGeneration, "agc_browser_playtest 失败:页面抛出异常" ) .is_model_repairable()); - assert!(DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "npm run build 编译失败" - ) - .is_model_repairable()); + assert!( + TurnError::turn_failed(FailureStage::CodeGeneration, "npm run build 编译失败") + .is_model_repairable() + ); // 模型自报 `codexErrorInfo=other`:分类认得出,仍值得再跑一轮。 - assert!(DirectTurnError::from_model_call(&LlmError::InvalidRequest( + assert!(TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:other detail=fields=codexErrorInfo".into() )) .is_model_repairable()); // 鉴权、通道断开、上下文超限:再跑一轮不会变好。 - assert!(!DirectTurnError::from_model_call(&LlmError::InvalidRequest( + assert!(!TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:unauthorized".into() )) .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Transport( + assert!(!TurnError::from_model_call(&LlmError::Transport( "Codex app-server 连接已关闭".into() )) .is_model_repairable()); - assert!(!DirectTurnError::from_model_call(&LlmError::Upstream { + assert!(!TurnError::from_model_call(&LlmError::Upstream { status_code: 503, message: "Codex app-server 上游服务暂时不可用".into(), }) .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - "项目身份不匹配" - ) - .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!( + !TurnError::turn_failed(FailureStage::CodeGeneration, "项目身份不匹配") + .is_model_repairable() + ); + assert!(!TurnError::turn_failed( + FailureStage::CodeGeneration, "工具参数 attempt 必须是 1 到 3 的整数" ) .is_model_repairable()); - assert!(!DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + assert!(!TurnError::turn_failed( + FailureStage::CodeGeneration, "authentication-required: HTTP 401" ) .is_model_repairable()); @@ -5118,7 +5132,7 @@ mod tests { #[test] fn direct_codex_insufficient_mud_points_has_explicit_non_retryable_guidance() { - let error = DirectTurnError::from_model_call(&LlmError::Upstream { + let error = TurnError::from_model_call(&LlmError::Upstream { status_code: 409, message: "泥点余额不足".into(), }); @@ -5130,8 +5144,8 @@ mod tests { assert!(!error.is_retryable()); // 深层(还没 typed 出口)的同一个事实也必须落到同一条建议上。 - let deep = DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + let deep = TurnError::turn_failed( + FailureStage::ArtPreparation, "平台图片生成任务失败:泥点余额不足", ); assert_eq!( @@ -5148,12 +5162,12 @@ mod tests { /// 界面还会出现"请重新登录陶泥儿后重试"却同时标着不可重试的矛盾组合。 #[test] fn direct_codex_authentication_failure_is_retryable_in_both_paths() { - let native = DirectTurnError::from_model_call(&LlmError::InvalidRequest( + let native = TurnError::from_model_call(&LlmError::InvalidRequest( "codex-app-server-error:unauthorized".into(), )); assert!(native.is_retryable()); - let deep = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let deep = TurnError::turn_failed( + FailureStage::CodeGeneration, "authentication-required: HTTP 401", ); assert!(deep.is_retryable()); @@ -5164,8 +5178,8 @@ mod tests { #[test] fn direct_project_history_shape_failure_has_explicit_non_retryable_guidance() { - let error = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let error = TurnError::turn_failed( + FailureStage::CodeGeneration, "DirectProject 历史记录类型无效:$PROJECT_ROOT/.agent/conversations/project.jsonl", ); assert_eq!( @@ -5175,8 +5189,8 @@ mod tests { assert!(!error.is_retryable()); // 历史文件的 IO 失败仍按可重试处理:它与行形状无关,重试可能成功。 - let io_error = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let io_error = TurnError::turn_failed( + FailureStage::CodeGeneration, "打开 DirectProject 历史失败:拒绝访问", ); assert_eq!( @@ -5194,10 +5208,7 @@ mod tests { "获取DirectProject 历史追加写{}", crate::project::PROJECT_APPEND_LOCK_TIMEOUT_MARKER ); - let append_lock = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - append_lock_timeout, - ); + let append_lock = TurnError::turn_failed(FailureStage::CodeGeneration, append_lock_timeout); assert_eq!( append_lock.recovery_hint(), Some("另一个客户端进程正在读写该项目的历史,本轮历史未能落盘;请稍后重试,若确认没有其它客户端在运行请重启客户端后再发送需求") @@ -5207,10 +5218,8 @@ mod tests { "{}C:/project", crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX ); - let write_lock = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, - write_lock_contention, - ); + let write_lock = + TurnError::turn_failed(FailureStage::CodeGeneration, write_lock_contention); assert_eq!( write_lock.recovery_hint(), Some("当前项目仍有写入正在结束,请稍后再次发送该需求"), @@ -5859,9 +5868,9 @@ mod tests { #[test] fn direct_observation_status_separates_reply_stream_from_work_activity() { - let accumulated = DirectCodexTurnObservation::AccumulatedText("阶段性回复".to_string()); - let intermediate = DirectCodexTurnObservation::IntermediateText("正在调用工具".to_string()); - let activity = DirectCodexTurnObservation::Activity("command-exec"); + let accumulated = TurnObservation::AccumulatedText("阶段性回复".to_string()); + let intermediate = TurnObservation::IntermediateText("正在调用工具".to_string()); + let activity = TurnObservation::Activity("command-exec"); assert_eq!( direct_codex_observation_status(&accumulated, true), @@ -7283,23 +7292,23 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_enqueue_failure( - &root, - Some("direct-codex:turn-1:user"), - DirectTurnError::EnvironmentNotReady { - detail: "Codex app-server 启动失败:找不到可执行文件".into(), - }, - ) - .message; + let failure = EnqueueError::EnvironmentNotReady(EnvironmentNotReady { + detail: "Codex app-server 启动失败:找不到可执行文件".into(), + }); + // 命令边界只补诊断,**原样**把结构化变体交给前端,不拼第二份文案。 + assert_eq!( + direct_turn_enqueue_failure(&root, Some("direct-codex:turn-1:user"), failure.clone()), + failure + ); - assert!(text.contains("direct-codex-failure:v2"), "{text}"); - assert!(!text.contains("详情:"), "{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("direct-codex-failure:v2"), "{sidecar}"); + assert!(!sidecar.contains("详情:"), "{sidecar}"); assert!(sidecar.contains("Codex app-server 启动失败"), "{sidecar}"); } @@ -7310,36 +7319,47 @@ mod tests { let root = parent.path().join("project"); init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); - let text = direct_turn_enqueue_failure(&root, None, DirectTurnError::ContentEmpty).message; - - assert_eq!(text, "聊天内容不能为空"); + let failure = EnqueueError::ContentEmpty; + assert_eq!( + direct_turn_enqueue_failure(&root, None, failure.clone()), + failure + ); assert!(!root.join(".agent/runtime/errors").exists()); } - /// 回合失败已经在上游写过诊断,边界不得再写第二份。 + /// 入队边界只认入队失败这张表:用户自己就能修的业务拒绝一条都不留痕。 + /// 回合失败进不了这里——两张表分型,回合失败在上游已经写过诊断。 #[test] - fn turn_failure_text_is_not_recorded_twice_at_the_boundary() { + fn business_enqueue_rejections_are_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 failure = - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, "模型失败"); - let recorded = record_direct_codex_failure(&root, &failure, None); - // 上游把返回串挂进 `TurnFailed.detail`,边界再见到它时只做 `Display`,不再写诊断。 - let text = direct_turn_enqueue_failure( - &root, - None, - DirectTurnError::turn_failed(DirectCodexFailureStage::CodeGeneration, recorded.clone()), - ) - .message; - - 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, "边界不得为同一条失败再写一份诊断"); + for failure in [ + EnqueueError::ClientTurnIdMissing, + EnqueueError::ClientTurnIdMalformed(ClientTurnIdMalformed { + min_chars: 8, + max_chars: 64, + }), + EnqueueError::QueueFull(QueueFull { limit: 3 }), + EnqueueError::ProjectRootUnusable, + EnqueueError::PermissionRejected(PermissionRejected { + policy_detail: "项目权限策略拒绝执行:conversation.write".into(), + }), + EnqueueError::InputRejected(InputRejected { + detail: "用户条目不合法".into(), + }), + EnqueueError::ContentEmpty, + ] { + assert_eq!( + direct_turn_enqueue_failure(&root, None, failure.clone()), + failure + ); + } + assert!( + !root.join(".agent/runtime/errors").exists(), + "业务拒绝不得留痕" + ); } #[test] @@ -7349,8 +7369,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", ), None, @@ -7392,8 +7412,8 @@ mod tests { .to_string(); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + &TurnError::turn_failed( + FailureStage::CodeGeneration, format!("DirectProject 历史记录类型无效:{history_path}"), ), None, @@ -7432,8 +7452,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", ), None, @@ -7453,8 +7473,8 @@ mod tests { init_local_game_project_at(&root, "direct-diagnostic", "直连诊断").expect("init project"); let error = record_direct_codex_failure( &root, - &DirectTurnError::turn_failed( - DirectCodexFailureStage::ArtPreparation, + &TurnError::turn_failed( + FailureStage::ArtPreparation, "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", ), None, 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 d907d60b6..63609f514 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 @@ -7,9 +7,9 @@ use super::*; pub(crate) fn normalize_direct_client_turn_id( client_turn_id: Option<&str>, -) -> Result { +) -> Result { let Some(client_turn_id) = client_turn_id else { - return Err(DirectTurnError::ClientTurnIdMissing); + return Err(EnqueueError::ClientTurnIdMissing); }; let client_turn_id = client_turn_id.trim(); let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) @@ -20,10 +20,10 @@ pub(crate) fn normalize_direct_client_turn_id( .is_some_and(|byte| byte.is_ascii_alphanumeric()); let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); if !valid_length || !valid_first || !valid_rest { - return Err(DirectTurnError::ClientTurnIdMalformed { + return Err(EnqueueError::ClientTurnIdMalformed(ClientTurnIdMalformed { min_chars: MIN_DIRECT_CLIENT_TURN_ID_CHARS, max_chars: MAX_DIRECT_CLIENT_TURN_ID_CHARS, - }); + })); } Ok(client_turn_id.to_string()) } @@ -37,7 +37,7 @@ pub(crate) fn normalize_direct_client_turn_id( /// 于是"这一轮跑成什么"仍然只有订阅事件一个来源:命令返回 `Ok` 只说明**入队成立**。真正的回合边界 /// (`turn.started` / `turn.completed`)由 Thread Manager 在**放行**时写出(见 `thread_manager::dispatch`), /// 入队失败不写用户条目、不产生任何事件。可留痕的调用级失败(宿主 / 环境事实)仍在边界补一份运行 -/// 错误诊断,返回串不带诊断引用。 +/// 错误诊断;命令返回的就是 typed 变体本身。 /// /// 它是 DirectProject 唯一的命令入口:终端入口 `--direct-codex-chat`(它要保持 await 才能把回复打到 /// 终端上)已经退役,不要再为"手工跑一轮"新增第二条直接起回合的路径。 @@ -47,10 +47,10 @@ pub(crate) fn normalize_direct_client_turn_id( #[tauri::command] pub(crate) async fn enqueue_direct_codex_turn( project_path: String, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, client_turn_id: Option, -) -> Result<(), DirectTurnEnqueueFailure> { +) -> Result<(), EnqueueError> { let root = Path::new(project_path.trim()); let boundary_turn_id = client_turn_id.clone(); enqueue_direct_codex_turn_typed(root, user_item, creation_type, client_turn_id) @@ -67,23 +67,23 @@ pub(crate) async fn enqueue_direct_codex_turn( /// 回合事件与整轮都在放行那一侧。 async fn enqueue_direct_codex_turn_typed( root: &Path, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, client_turn_id: Option, -) -> Result<(), DirectTurnError> { +) -> Result<(), EnqueueError> { let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; recover_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - DirectTurnError::HostStateUnavailable { + EnqueueError::HostStateUnavailable(HostStateUnavailable { detail: redact_agent_runtime_error( root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500, ), - } + }) })?; // 冻结是这条消息唯一的算片段 / 写盘时机:引用解析文本就此写进条目自身,放行只重投影。 let user_item = freeze_direct_codex_user_item(root, &user_item) - .map_err(|detail| DirectTurnError::InputRejected { detail })?; + .map_err(|detail| EnqueueError::InputRejected(InputRejected { detail }))?; let user_prompt = direct_codex_user_item_to_prompt(&user_item); check_direct_turn_preconditions(root, &user_prompt, creation_type.as_deref())?; let thread_id = thread_id_for_project(root); @@ -91,25 +91,27 @@ async fn enqueue_direct_codex_turn_typed( // 准备——那是分钟级的活(`npm ci` + Vite 构建),还会在磁盘上留下产物。权威判据仍然是入队那一刻 // 临界区里的容量检查(下面 `enqueue_pending_turn`):这里只是快速失败,中间被别人的消息 // 挤满时那一条照样拦得住。 - queue_has_room(pending_turn_count(&thread_id)).map_err(|_| DirectTurnError::QueueFull { - limit: MAX_PENDING_TURNS, + queue_has_room(pending_turn_count(&thread_id)).map_err(|_| { + EnqueueError::QueueFull(QueueFull { + limit: MAX_PENDING_TURNS, + }) })?; // 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。 crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()) .await .map_err(|error| { let detail = redact_agent_runtime_error(root, &error, 1800); - DirectTurnError::EnvironmentNotReady { detail } + EnqueueError::EnvironmentNotReady(EnvironmentNotReady { detail }) })?; // 入队:到这里这一条已经过了全部检查,剩下的就是排队等放行。条目只带走它自己的事实 // (用户条目、创建类型、入队时刻),canonical 形状与 prompt 放行时从它重投影——放行没有失败出口。 - let pending = PendingTurn::new(turn_id, user_item, creation_type, direct_now_ms()); + let pending = PendingTurn::new(turn_id, user_item, creation_type, now_ms()); match enqueue_pending_turn(&thread_id, pending) { Ok(_) => {} Err(EnqueueRejection::QueueFull) => { - return Err(DirectTurnError::QueueFull { + return Err(EnqueueError::QueueFull(QueueFull { limit: MAX_PENDING_TURNS, - }) + })) } } // 入队之后立刻踢一脚:队列空且没有回合在跑时,放行就是这一脚,用户点发送不必再等一个调度周期。 @@ -146,7 +148,23 @@ mod tests { } } - fn user_item(text: &str) -> DirectCodexUserItem { + /// 载荷里那份宿主原文:测试只关心"有没有原因 / 是哪一类",不关心前端怎么拼文案。 + fn failure_detail(failure: &TurnFailure) -> String { + match failure { + TurnFailure::ProjectRootUnanchored(payload) => payload.cause.clone(), + TurnFailure::EnvironmentNotReady(payload) => payload.detail.clone(), + TurnFailure::HostStateUnavailable(payload) => payload.detail.clone(), + TurnFailure::ModelCallFailed(payload) => payload.detail.clone(), + TurnFailure::TransportClosed(payload) => payload.diagnostic.clone(), + TurnFailure::TimedOut(payload) => format!("{:?}", payload.deadline), + TurnFailure::TurnInterrupted(payload) => payload.detail.clone(), + TurnFailure::TurnFailed(payload) => payload.detail.clone(), + TurnFailure::TurnFailedUnclassified(payload) => payload.detail.clone(), + TurnFailure::HostDropped => String::new(), + } + } + + fn user_item(text: &str) -> UserItem { serde_json::from_value(serde_json::json!({ "type": "message", "role": "user", @@ -191,7 +209,7 @@ mod tests { assert_eq!(status, "failed"); let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( - !failure.message.trim().is_empty(), + !failure_detail(failure).trim().is_empty(), "放行之后的失败必须带上原因" ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); @@ -317,18 +335,11 @@ mod tests { let (status, failure) = terminals[0]; assert_eq!(status, "failed"); let failure = failure.as_ref().expect("失败终态必须带载荷"); - assert!( - failure.message.contains("写入本项目对话历史失败"), - "{}", - failure.message - ); + let detail = failure_detail(failure); + assert!(detail.contains("写入本项目对话历史失败"), "{detail}"); // 这一轮已经放行,所以走的是**回合失败**:入队失败那套 `direct-codex-failure:v2` 收口文案 // 不许出现在这里(它只属于可留痕的入队失败)。 - assert!( - !failure.message.contains("direct-codex-failure"), - "{}", - failure.message - ); + assert!(!detail.contains("direct-codex-failure"), "{detail}"); // 占用已释放:下一轮还能继续。 assert!(!crate::agent::thread_turn_is_active(&thread_id)); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs deleted file mode 100644 index 1d3d004c9..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_failure.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! 失败终态的宿主侧策略:把"这一轮为什么失败"翻译成可下发的 `failure` 载荷,并在宿主自己 -//! 提前收场时补一条失败终态。 -//! -//! 这个模块只有三件事,别再往里加第四件: -//! 1. [`direct_turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; -//! 2. [`DirectTurnTerminal::event`]:把终态投影成 `turn.completed` 事件。 -//! -//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 `thread_manager::dispatch` 的放行占用对象里: -//! 这个模块只负责"什么算失败、原因怎么写"。 -//! -//! 失败载荷的**形状**属于线上协议,定义在 `thread_manager::wire`(`DirectTurnFailure`); -//! 载荷的 `kind` 与 `message` 由 [`DirectTurnError`] 投影而来(`kind` 的取值表见 -//! [`DirectTurnError::wire_kind`]);这里只负责"什么算失败、原因怎么写、什么时候兜底", -//! 不碰事件队列的搬运规则,也不自己认 `LlmError`。 - -use std::path::Path; - -use super::{ - redact_agent_runtime_error, DirectTurnError, DirectTurnFailure, DirectTurnFailureKind, - ThreadEvent, -}; - -/// `turn.completed.failure.message` 的字符上限:与本地错误文案同一档——够说清原因,又不至于 -/// 把整段上游报文塞进事件队列。 -const DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS: usize = 600; - -/// 宿主任务提前结束(panic / future 被丢弃 / 终态之前的早退)时的分类与文案。 -const DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE: &str = - "陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。"; - -/// 一轮的终态:写进事件的 `status` 与(失败时的)载荷。**状态由载荷反推**,不由收尾阶段推。 -pub(crate) struct DirectTurnTerminal { - pub(crate) status: String, - pub(crate) failure: Option, -} - -impl DirectTurnTerminal { - /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { - let event = match self.failure { - Some(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), - None => ThreadEvent::turn_completed(self.status, completed_at), - }; - event.with_user_item_id(user_item_id) - } -} - -/// 拿这一轮的**事实**判定终态。判据按优先级: -/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), -/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; -/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: -/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`DirectTurnError`] 再进来; -/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 -/// 阶段的账本读不出来时只有它可用。 -/// -/// **有载荷就一定是 `failed`,没载荷就用收尾阶段的 `session_status`。** 这条反推关系是这个模块存在 -/// 的理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, -/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", -/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 -/// -/// 载荷的 `kind` 与 `message` 在这一个出口从 typed 错误投影:`kind` 决定界面语气,`message` 是脱敏 -/// 截断后的原因文本;Rust 侧没有第二个地方再解析它。 -pub(crate) fn direct_turn_terminal( - session_status: &str, - collect_outcome: Result<&str, DirectTurnError>, - host_failure: Option<&DirectTurnError>, - history_root: &Path, -) -> DirectTurnTerminal { - let failure = match (host_failure, collect_outcome) { - (Some(failure), _) => Some(failure.clone()), - (None, Err(error)) => Some(error.clone()), - // 账本读不出来时没有 typed 原因可用:报告文本就是这一轮唯一的收口依据,按未分类失败发出去, - // 不能让界面停在"已结束、没原因"。 - (None, Ok(report)) if session_status == "failed" => { - Some(DirectTurnError::TurnFailedUnclassified { - detail: report.to_string(), - }) - } - (None, Ok(_)) => None, - }; - match failure { - Some(failure) => DirectTurnTerminal::failed(history_root, &failure), - None => DirectTurnTerminal { - status: session_status.to_string(), - failure: None, - }, - } -} - -impl DirectTurnTerminal { - /// 一次失败终态:`kind` 与 `message` 只在这一个出口从 typed 错误投影。 - pub(crate) fn failed(history_root: &Path, failure: &DirectTurnError) -> Self { - Self { - status: "failed".to_string(), - failure: Some(DirectTurnFailure::new( - failure - .wire_kind() - .unwrap_or(DirectTurnFailureKind::ModelFailed), - redact_agent_runtime_error( - history_root, - &failure.to_string(), - DIRECT_TURN_FAILURE_MESSAGE_MAX_CHARS, - ), - )), - } - } - - /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 - /// - /// 这类收场说不出原因,只给分类;能说清原因的一律走 [`Self::failed`]。 - pub(crate) fn host_dropped() -> Self { - Self { - status: "failed".to_string(), - failure: Some(DirectTurnFailure::new( - DirectTurnFailureKind::HostDropped, - DIRECT_TURN_FAILURE_HOST_DROPPED_MESSAGE.to_string(), - )), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::agent::{consume_thread, subscribe_thread}; - use platform_llm::LlmError; - - fn history_root() -> std::path::PathBuf { - std::path::PathBuf::from("/tmp/direct-turn-failure-test") - } - - /// 正常收场:不带载荷,`status` 就用收尾阶段推出来的那个。 - #[test] - fn non_failure_terminals_keep_the_session_status() { - for status in ["completed", "interrupted", "aborted"] { - let terminal = direct_turn_terminal(status, Ok("报告不重要"), None, &history_root()); - assert!(terminal.failure.is_none(), "{status} 不该带失败载荷"); - assert_eq!(terminal.status, status); - } - } - - /// 拿得到错误:分类与原因都取自错误。 - #[test] - fn collect_error_becomes_a_failure_terminal() { - let error = DirectTurnError::from_model_call(&LlmError::Transport( - "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), - )); - let terminal = direct_turn_terminal("completed", Err(error), None, &history_root()); - let failure = terminal - .failure - .expect("transport error must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); - assert!(failure.message.contains("收尾历史失败")); - } - - /// **收尾阶段的中断不能把已经失败的一轮讲成"已结束"。** 模型自报失败在调用点被投影成 typed - /// 错误(原因带 `codex-app-server-error:` 前缀),宿主收尾自己又把 ledger 阶段推成 - /// `Interrupted`(`session_status` 因此是 `interrupted`):事实就是失败、原因就是那份投影, - /// 必须原样发出去——否则界面只剩"本轮已结束",用户看不到任何东西。 - #[test] - fn projected_native_failure_outranks_the_interrupted_session_status() { - let error = DirectTurnError::from_model_call(&LlmError::InvalidRequest( - "codex-app-server-error:context-window-exceeded".into(), - )); - let terminal = direct_turn_terminal("interrupted", Err(error), None, &history_root()); - let failure = terminal.failure.expect("native failure must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::RequestRejected); - assert_eq!( - failure.message, - "codex-app-server-error:context-window-exceeded" - ); - } - - /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, - /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 - #[test] - fn unreadable_session_ledger_still_reports_a_payload() { - let terminal = direct_turn_terminal("failed", Ok("报告"), None, &history_root()); - assert_eq!(terminal.status, "failed"); - let failure = terminal - .failure - .expect("unreadable ledger must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::ModelFailed); - assert_eq!(failure.message, "报告"); - } - - /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 - #[test] - fn host_recorded_failure_outranks_every_other_source() { - let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ -stderrClass=nonempty;stderrBytes=1000"; - let host_failure = DirectTurnError::TransportClosed { - diagnostic: diagnostic.to_string(), - }; - let terminal = direct_turn_terminal( - "interrupted", - Ok("执行连接已结束,正在核对自有子进程与在途操作。"), - Some(&host_failure), - &history_root(), - ); - let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(terminal.status, "failed"); - assert_eq!(failure.kind, DirectTurnFailureKind::TransportFailed); - assert!(failure.message.contains("SIGKILL")); - assert!(!failure.message.contains("正在核对自有子进程")); - - // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 - let error = DirectTurnError::from_model_call(&LlmError::Transport( - "DirectProject 收尾历史失败".into(), - )); - let host_failure = DirectTurnError::TurnInterrupted { - detail: "本轮模型执行被中断".into(), - }; - let terminal = direct_turn_terminal( - "interrupted", - Err(error), - Some(&host_failure), - &history_root(), - ); - let failure = terminal.failure.expect("host fact must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::TurnInterrupted); - assert!(failure.message.contains("本轮模型执行被中断")); - } - - /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 - #[test] - fn terminal_event_carries_the_payload_and_the_opening_identity() { - let error = DirectTurnError::from_model_call(&LlmError::Upstream { - status_code: 502, - message: "上游 502".into(), - }); - let failing = direct_turn_terminal("interrupted", Err(error), None, &history_root()); - let event = failing.event(2_000, Some("direct-codex:turn-1:user")); - assert_eq!( - event.failure().map(|failure| failure.kind), - Some(DirectTurnFailureKind::ModelFailed) - ); - assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!(event.at(), Some(2_000)); - - let quiet = direct_turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); - let event = quiet.event(3_000, None); - assert!(event.failure().is_none()); - assert!(matches!( - event, - ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" - )); - } - - /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 - #[test] - fn host_dropped_terminal_only_carries_the_classification() { - let terminal = DirectTurnTerminal::host_dropped(); - assert_eq!(terminal.status, "failed"); - let failure = terminal.failure.expect("host-dropped must fail the turn"); - assert_eq!(failure.kind, DirectTurnFailureKind::HostDropped); - assert!(!failure.message.trim().is_empty()); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index dafa34ab1..bc15a7d0f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -112,7 +112,7 @@ impl DirectGameCreatorTurnUpdateEmitter { return; } let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; - let updated_at = crate::agent::direct_now_ms(); + let updated_at = crate::agent::now_ms(); update_active_turn( &self.thread_id, &self.turn_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs index f8be8982e..19cb1dbb6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/dispatch.rs @@ -19,10 +19,10 @@ use futures::FutureExt; use crate::agent::PendingTurn; use crate::agent::{ append_direct_project_user_message_at, claim_pending_turn, complete_turn_if_reserved, - direct_codex_user_item_to_prompt, direct_now_ms, record_direct_codex_failure, + direct_codex_user_item_to_prompt, now_ms, record_direct_codex_failure, redact_agent_runtime_error, run_direct_game_creator_turn_at_with_creation_type_and_emitter, - thread_id_for_project, DirectCodexFailureStage, DirectGameCreatorTurnUpdateEmitter, - DirectTurnError, DirectTurnTerminal, DispatchedTurn, + thread_id_for_project, DirectGameCreatorTurnUpdateEmitter, DispatchedTurn, FailureStage, + TurnCompletion, TurnError, TurnFailed, }; /// 一次放行的占用。持有它就代表这一轮还没收口。 @@ -54,11 +54,11 @@ impl TurnReservation { /// 放行之后还没走到深层终态就失败的收口口:只有这一轮仍被自己占用时才写。 /// /// 深层(真正跑完这一轮的代码)已经写出终态时返回 `false`,兜底不覆盖真实结果。 - pub(crate) fn finish_if_unfinished(&self, terminal: DirectTurnTerminal) -> bool { + pub(crate) fn finish_if_unfinished(&self, terminal: TurnCompletion) -> bool { complete_turn_if_reserved( &self.thread_id, &self.token, - terminal.event(direct_now_ms(), self.user_item_id.as_deref()), + terminal.event(now_ms(), self.user_item_id.as_deref()), ) } @@ -75,7 +75,7 @@ impl TurnReservation { })) .expect("canonical user item"), None, - direct_now_ms(), + now_ms(), ); super::enqueue_pending_turn(thread_id, pending).expect("enqueue test turn"); let dispatched = claim_pending_turn(thread_id).expect("claim test turn"); @@ -87,7 +87,7 @@ impl Drop for TurnReservation { fn drop(&mut self) { // 兜底:任务 panic、future 被丢弃、或今后在终态之前新增的 `?` 早退。 // 这类失败说不出原因,只给分类;能说清原因的错误必须由调用方在更早的地方显式收口。 - let finalized_by_guard = self.finish_if_unfinished(DirectTurnTerminal::host_dropped()); + let finalized_by_guard = self.finish_if_unfinished(TurnCompletion::host_dropped()); // 兜底一旦真的收口,就说明这一轮**从未写下终态**:深层既没成功也没失败地退出了。 // 这条必须留应用日志,否则离线只剩一个 `phase=working` 的账本,无从判断是哪一层 // 提前退出(真实案例:2026-10-02 连续两轮只留下 working 账本,errors/ 与 @@ -96,13 +96,13 @@ impl Drop for TurnReservation { if finalized_by_guard { // 项目侧也留一份脱敏诊断:用户在项目目录里就能看到这一轮的收场, // 不必只依赖 AppData 的应用日志。 - let failure = DirectTurnError::TurnFailed { - stage: DirectCodexFailureStage::CodeGeneration, - detail: format!( + let failure = TurnError::turn_failed( + FailureStage::CodeGeneration, + format!( "DirectProject 宿主任务提前结束(panic、future 被丢弃或被取消),本轮未写下终态;threadId={} turnToken={}", self.thread_id, self.token ), - }; + ); let _ = record_direct_codex_failure(Path::new(&self.thread_id), &failure, None); app_log!( "agent.direct_turn.host_dropped threadId={} turnToken={} userItemId={} panicking={}", @@ -219,14 +219,16 @@ async fn run_dispatched_direct_turn( if let Err(error) = append_direct_project_user_message_at(&root, &canonical_user_item) { // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口用户 // 消息的助手回复,而且失败会被静默掉。 - let failure = DirectTurnError::EnvironmentNotReady { - detail: redact_agent_runtime_error( - &root, - &format!("写入本项目对话历史失败:{error}"), - 600, - ), - }; - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &failure)); + let failure = TurnError::environment_not_ready(redact_agent_runtime_error( + &root, + &format!("写入本项目对话历史失败:{error}"), + 600, + )); + reservation.finish_if_unfinished(TurnCompletion::failed( + failure + .terminal_failure(&root) + .expect("回合失败必可投影成失败载荷"), + )); return; } // 用户条目落盘成功即下发:这一轮从"放行"到"起 codex"之间的一切失败(连不上 app-server、执行器 @@ -254,8 +256,8 @@ async fn run_dispatched_direct_turn( { Ok(result) => result, Err(payload) => { - let failure = DirectTurnError::turn_failed( - DirectCodexFailureStage::CodeGeneration, + let failure = TurnError::turn_failed( + FailureStage::CodeGeneration, format!( "DirectProject 宿主任务 panic:{}", direct_turn_panic_detail(payload.as_ref()) @@ -263,7 +265,7 @@ async fn run_dispatched_direct_turn( ); let stage = failure.turn_failure_stage(); let detail = record_direct_codex_failure(&root, &failure, Some(turn_id.as_str())); - Err(DirectTurnError::TurnFailed { stage, detail }) + Err(TurnError::TurnFailed(TurnFailed { stage, detail })) } }; match outcome { @@ -274,7 +276,11 @@ async fn run_dispatched_direct_turn( Err(error) => { // 放行之后的失败一律是回合失败:失败诊断与失败说明已由上层写过,这里补终态事件。 // 深层已经写出终态时它不覆盖(同一轮只允许一条终态)。 - reservation.finish_if_unfinished(DirectTurnTerminal::failed(&root, &error)); + reservation.finish_if_unfinished(TurnCompletion::failed( + error + .terminal_failure(&root) + .expect("回合失败必可投影成失败载荷"), + )); } } } @@ -283,8 +289,8 @@ async fn run_dispatched_direct_turn( mod tests { use super::*; use crate::agent::{ - consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, - DirectTurnFailure, DirectTurnFailureKind, PendingTurn, ThreadEvent, + consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, Deadline, + PendingTurn, ThreadEvent, TimedOut, TurnFailure, }; use uuid::Uuid; @@ -318,7 +324,7 @@ mod tests { })) .expect("canonical user item"), None, - direct_now_ms(), + now_ms(), ) } @@ -347,9 +353,9 @@ mod tests { }, ); - assert!(!foreign.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(!foreign.finish_if_unfinished(TurnCompletion::host_dropped())); assert!(thread_turn_is_active(&thread)); - assert!(owner.finish_if_unfinished(DirectTurnTerminal::host_dropped())); + assert!(owner.finish_if_unfinished(TurnCompletion::host_dropped())); assert!(!thread_turn_is_active(&thread)); // 显式收口之后 Drop 不再补第二条:兜底只负责"没人写过"的那一种。 drop(foreign); @@ -367,17 +373,16 @@ mod tests { // 深层收口:真正跑完这一轮的代码算出来的终态。 let deep = ThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - DirectTurnFailureKind::Timeout, - "等待模型回执超时".to_string(), - ), + TurnFailure::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + }), 2_000, ) .with_user_item_id(Some("direct-codex:turn-1:user")); crate::agent::complete_turn(&thread, deep); assert!( - !reservation.finish_if_unfinished(DirectTurnTerminal::host_dropped()), + !reservation.finish_if_unfinished(TurnCompletion::host_dropped()), "深层已收口时兜底不许再写" ); drop(reservation); @@ -388,8 +393,10 @@ mod tests { match completed[0] { ThreadEvent::TurnCompleted { failure, .. } => { assert_eq!( - failure.as_ref().map(|f| f.kind), - Some(DirectTurnFailureKind::Timeout) + failure, + &Some(TurnFailure::TimedOut(TimedOut { + deadline: Deadline::ResponseIdle, + })) ); } other => panic!("expected a terminal, got {other:?}"), @@ -418,7 +425,7 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if status == "failed" && failure.kind == DirectTurnFailureKind::HostDropped + if status == "failed" && matches!(failure, TurnFailure::HostDropped) ), "{events:?}" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs index 87b19c252..a5889f166 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/mod.rs @@ -6,10 +6,12 @@ pub(crate) mod dispatch; pub(crate) mod queue; +pub(crate) mod turn_completion; pub(crate) mod wire; pub(crate) use dispatch::*; pub(crate) use queue::*; +pub(crate) use turn_completion::*; pub(crate) use wire::*; use std::collections::{HashMap, HashSet}; @@ -17,7 +19,7 @@ use std::sync::{Mutex, OnceLock}; use uuid::Uuid; use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, direct_now_ms, queue_has_room, ConsumeResult, + direct_codex_user_item_id_for_client_turn_id, now_ms, queue_has_room, ConsumeResult, EnqueueOutcome, EnqueueRejection, PendingTurn, QueueRemovalOutcome, QueueRemovalReason, SubscriptionBootstrap, ThreadEvent, }; @@ -377,7 +379,8 @@ impl ThreadManager { // `turn.started` 的口径同一份(`PendingTurn::user_item_id`)。 self.append( thread_id, - ThreadEvent::turn_completed("aborted".to_string(), now_ms).with_user_item_id( + TurnCompletion::Aborted.event( + now_ms, direct_codex_user_item_id_for_client_turn_id(&released).as_deref(), ), ); @@ -854,7 +857,7 @@ pub(crate) fn remove_pending_turn(thread_id: &str, client_turn_id: &str) -> Queu global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove_pending_turn(thread_id, client_turn_id, direct_now_ms()) + .remove_pending_turn(thread_id, client_turn_id, now_ms()) }; if outcome == QueueRemovalOutcome::Removed { notify_subscribers(thread_id); @@ -873,7 +876,7 @@ pub(crate) fn claim_pending_turn(thread_id: &str) -> Option { global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .claim_pending_turn(thread_id, direct_now_ms()) + .claim_pending_turn(thread_id, now_ms()) }; if claimed.is_some() { notify_subscribers(thread_id); @@ -950,12 +953,7 @@ pub(crate) fn release_stale_direct_turn( global_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) - .release_stale_turn( - thread_id, - expected_client_turn_id, - min_age_ms, - direct_now_ms(), - ) + .release_stale_turn(thread_id, expected_client_turn_id, min_age_ms, now_ms()) }; if matches!(outcome, StaleTurnRelease::Released(_)) { notify_subscribers(thread_id); @@ -1261,13 +1259,7 @@ mod tests { manager.append("thread-1", ThreadEvent::turn_started(1_000)); manager.append( "thread-1", - ThreadEvent::turn_completed_failed( - crate::agent::DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::HostDropped, - "回合宿主任务提前结束", - ), - FIXED_AT_MS, - ), + ThreadEvent::turn_completed_failed(crate::agent::TurnFailure::HostDropped, FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); @@ -1275,8 +1267,10 @@ mod tests { bootstrap.events.as_slice(), [ThreadEvent::TurnCompleted { status, failure, at, .. }] if status == "failed" - && failure.as_ref().is_some_and(|failure| failure.kind - == crate::agent::DirectTurnFailureKind::HostDropped) + && failure.as_ref().is_some_and(|failure| matches!( + failure, + crate::agent::TurnFailure::HostDropped + )) && *at == Some(FIXED_AT_MS) )); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs index 12fc6879c..3c9745b32 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/queue.rs @@ -9,9 +9,7 @@ //! 这条判据的成员折叠(`ThreadManager::pending_turns`)。 //! 它不碰锁、不碰 Tauri、不写盘、不重算 prompt:入队检查在命令侧,放行顺序在 Thread Manager。 -use crate::agent::{ - direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, ThreadEvent, -}; +use crate::agent::{direct_codex_user_item_id_for_client_turn_id, ThreadEvent, UserItem}; /// 一个项目最多能同时排队的待发消息条数。 /// @@ -30,7 +28,7 @@ pub(crate) struct PendingTurn { /// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 pub(crate) client_turn_id: String, /// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。 - pub(crate) user_item: DirectCodexUserItem, + pub(crate) user_item: UserItem, pub(crate) creation_type: Option, /// 入队那一刻的宿主毫秒钟。 pub(crate) at: u64, @@ -39,7 +37,7 @@ pub(crate) struct PendingTurn { impl PendingTurn { pub(crate) fn new( client_turn_id: String, - user_item: DirectCodexUserItem, + user_item: UserItem, creation_type: Option, at: u64, ) -> Self { @@ -116,7 +114,7 @@ mod tests { use super::*; use serde_json::json; - fn user_item(text: &str, id: &str) -> DirectCodexUserItem { + fn user_item(text: &str, id: &str) -> UserItem { serde_json::from_value(json!({ "type": "message", "role": "user", diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs new file mode 100644 index 000000000..0525f0cb0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/turn_completion.rs @@ -0,0 +1,306 @@ +//! 一轮的**终态**:宿主侧的内部判别联合,不下发、不导出。 +//! +//! 属于线上协议的只有失败**载荷** [`TurnFailure`](`turn.completed.failure` 的形状,留在 +//! [`super::wire`]);"这一轮是怎么收场的"是宿主自己的概念,所以放在这里、不进 wire,也绝不 +//! 加 `Serialize` / `TS`——一旦能序列化就会被误当成线上形状。 +//! +//! 这里只有两件事,别再往里加第三件: +//! 1. [`turn_terminal`]:拿这一轮的事实判定终态——是不是失败、原因是什么、状态写什么; +//! 2. [`TurnCompletion::event`]:把终态投影成 `turn.completed` 事件(`status` 由变体反推)。 +//! +//! 终态的**出口**(谁写、什么时候兜底)不在这里,在 [`super::dispatch`] 的放行占用对象里: +//! 这里只负责"什么算失败、原因怎么写"。 +//! +//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它, +//! 也没有任何地方再解析它。 + +use std::path::Path; + +use crate::agent::{ThreadEvent, TurnError, TurnFailedUnclassified}; + +use super::wire::TurnFailure; + +/// 一轮的收场:正常收场只有前三档,失败必须带载荷。 +/// +/// **有载荷就一定是失败,没载荷才看收尾阶段推出来的 `status`**——这条反推关系是这个类型存在的 +/// 理由:`session_status` 是宿主收尾时按 ledger 阶段推的,收尾本身会把阶段推成 `Interrupted`, +/// 于是"模型已经判失败"的一轮会被写成 `status="interrupted"` 且不带载荷——界面只剩"本轮已结束", +/// 用户看不到任何原因(连接/上游断开时就是这个现象)。事实判失败就必须报失败。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum TurnCompletion { + Completed, + Interrupted, + /// 执行进程已退出 / 这一轮从没进执行器:不会再有人替它发终态的兜底收场。 + Aborted, + Failed(TurnFailure), +} + +impl TurnCompletion { + /// 终态事件:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + pub(crate) fn event(self, completed_at: u64, user_item_id: Option<&str>) -> ThreadEvent { + let event = match self { + Self::Failed(failure) => ThreadEvent::turn_completed_failed(failure, completed_at), + Self::Completed => ThreadEvent::turn_completed("completed".to_string(), completed_at), + Self::Interrupted => { + ThreadEvent::turn_completed("interrupted".to_string(), completed_at) + } + Self::Aborted => ThreadEvent::turn_completed("aborted".to_string(), completed_at), + }; + event.with_user_item_id(user_item_id) + } + + /// 一次失败终态:载荷已由 [`TurnError::terminal_failure`] 投影并脱敏。 + pub(crate) fn failed(failure: TurnFailure) -> Self { + Self::Failed(failure) + } + + /// 宿主任务提前结束(panic / future 被丢弃 / 取消)的兜底终态。 + /// + /// 这类收场说不出原因;能说清原因的一律走 [`Self::failed`]。 + pub(crate) fn host_dropped() -> Self { + Self::Failed(TurnFailure::HostDropped) + } +} + +/// 拿这一轮的**事实**判定终态。判据按优先级: +/// 1. `host_failure`:宿主自己观察 / 判定的失败(执行通道断开、等待超时、app-server 单方面中断…), +/// 原因就用宿主当场写下的那句——它比交付报告更接近现场,报告只说明"收束到哪一步"; +/// 2. `collect_outcome` 是错误:真失败(模型 / 传输 / 历史落盘)。模型自报失败也走这一档: +/// 原生 `turn/completed.status="failed"` 的 `error` 由调用点投影成 [`TurnError`] 再进来; +/// 3. `session_status` 已经判成 `failed`、而拿到的只是一份交付报告:原因用那份报告兜底——收尾 +/// 阶段的账本读不出来时只有它可用。 +/// +/// 载荷在这一个出口从 typed 错误投影([`TurnError::terminal_failure`]):脱敏与截断也在那 +/// 一处完成,Rust 侧没有第二个地方再拼它、也没有任何地方再解析它。 +pub(crate) fn turn_terminal( + session_status: &str, + collect_outcome: Result<&str, TurnError>, + host_failure: Option<&TurnError>, + history_root: &Path, +) -> TurnCompletion { + let outcome = SessionOutcome::parse(session_status); + let failure = match (host_failure, collect_outcome) { + (Some(failure), _) => Some(failure.clone()), + (None, Err(error)) => Some(error.clone()), + // 账本读不出来时(`session_status == "failed"`)没有 typed 原因可用:报告文本就是这一轮 + // 唯一的收口依据,按未分类失败发出去,不能让界面停在"已结束、没原因"。 + (None, Ok(report)) if session_status == "failed" => { + Some(TurnError::TurnFailedUnclassified(TurnFailedUnclassified { + detail: report.to_string(), + })) + } + (None, Ok(_)) => None, + }; + match ( + failure.and_then(|failure| failure.terminal_failure(history_root)), + outcome, + ) { + (Some(payload), _) => TurnCompletion::Failed(payload), + (None, Some(outcome)) => outcome.into_completion(), + // 收尾阶段的 `status` 认不出来(当前不可能发生):宁可报一条说不出原因的失败,也不冒充 + // 正常收场;载荷照样从 typed 错误投影,保持"只在一处拼载荷"。 + (None, None) => TurnCompletion::Failed( + TurnError::TurnFailedUnclassified(TurnFailedUnclassified { + detail: format!("收尾阶段给出的回合终态无法识别:{session_status}"), + }) + .terminal_failure(history_root) + .unwrap_or(TurnFailure::HostDropped), + ), + } +} + +/// 收尾阶段按 ledger 阶段推出来的**非失败**终态(只可能是这三档)。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum SessionOutcome { + Completed, + Interrupted, + Aborted, +} + +impl SessionOutcome { + /// `status` 只在协议边界是字符串,这里是它进入宿主内部的唯一收口。认不出的值返回 `None`: + /// 由 [`turn_terminal`] 按失败兜底,绝不冒充正常收场。 + fn parse(status: &str) -> Option { + match status { + "completed" => Some(Self::Completed), + "interrupted" => Some(Self::Interrupted), + "aborted" => Some(Self::Aborted), + _ => None, + } + } + + fn into_completion(self) -> TurnCompletion { + match self { + Self::Completed => TurnCompletion::Completed, + Self::Interrupted => TurnCompletion::Interrupted, + Self::Aborted => TurnCompletion::Aborted, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::{ + ModelCallKind, ThreadEvent, TransportClosed, TurnError, TurnFailedUnclassified, + TurnInterrupted, + }; + use platform_llm::LlmError; + + fn history_root() -> std::path::PathBuf { + std::path::PathBuf::from("/tmp/direct-turn-completion-test") + } + + fn model_call(payload: &TurnFailure) -> (&ModelCallKind, &str) { + match payload { + TurnFailure::ModelCallFailed(payload) => (&payload.kind, &payload.detail), + other => panic!("expected a model call failure, got {other:?}"), + } + } + + /// 正常收场:不带载荷,变体就是收尾阶段推出来的那个。 + #[test] + fn non_failure_terminals_keep_the_session_status() { + assert_eq!( + turn_terminal("completed", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Completed + ); + assert_eq!( + turn_terminal("interrupted", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Interrupted + ); + assert_eq!( + turn_terminal("aborted", Ok("报告不重要"), None, &history_root()), + TurnCompletion::Aborted + ); + } + + /// 认不出的 `status` 不冒充正常收场:按未分类失败发出去,并把原文留在原因里。 + #[test] + fn unknown_session_status_fails_closed() { + let completion = turn_terminal("something-new", Ok("报告"), None, &history_root()); + match completion { + TurnCompletion::Failed(TurnFailure::TurnFailedUnclassified(payload)) => { + assert!(payload.detail.contains("something-new")); + } + other => panic!("unknown status must fail closed, got {other:?}"), + } + } + + /// 拿得到错误:分类与原因都取自错误。 + #[test] + fn collect_error_becomes_a_failure_terminal() { + let error = TurnError::from_model_call(&LlmError::Transport( + "DirectProject 收尾历史失败:写入 project.jsonl 失败".into(), + )); + let completion = turn_terminal("completed", Err(error), None, &history_root()); + let TurnCompletion::Failed(failure) = completion else { + panic!("transport error must fail the turn"); + }; + let (kind, detail) = model_call(&failure); + assert_eq!(*kind, ModelCallKind::TransportBroken); + assert!(detail.contains("project.jsonl")); + } + + /// 收尾阶段的账本读不出来(`session_status` 只能是 `failed`)时没有错误可用:用交付报告兜底, + /// 但照样要带载荷发出去,不能让界面停在"已结束、没原因"。 + #[test] + fn unreadable_session_ledger_still_reports_a_payload() { + assert_eq!( + turn_terminal("failed", Ok("报告"), None, &history_root()), + TurnCompletion::Failed(TurnFailure::TurnFailedUnclassified( + TurnFailedUnclassified { + detail: "报告".into() + } + )) + ); + } + + /// 宿主自己记下的失败排在最前面:它比交付报告更接近现场。 + #[test] + fn host_recorded_failure_outranks_every_other_source() { + let diagnostic = "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL);\ +stderrClass=nonempty;stderrBytes=1000"; + let host_failure = TurnError::TransportClosed(TransportClosed { + diagnostic: diagnostic.to_string(), + }); + let completion = turn_terminal( + "interrupted", + Ok("执行连接已结束,正在核对自有子进程与在途操作。"), + Some(&host_failure), + &history_root(), + ); + let TurnCompletion::Failed(failure) = completion else { + panic!("host fact must fail the turn"); + }; + match &failure { + TurnFailure::TransportClosed(payload) => { + assert!(payload.diagnostic.contains("SIGKILL")); + assert!(!payload.diagnostic.contains("正在核对自有子进程")); + } + other => panic!("expected a transport-closed payload, got {other:?}"), + } + + // 即使同时拿到了错误,宿主亲眼看到的事实仍然是第一顺位。 + let error = + TurnError::from_model_call(&LlmError::Transport("DirectProject 收尾历史失败".into())); + let host_failure = TurnError::TurnInterrupted(TurnInterrupted { + detail: "本轮模型执行被中断".into(), + }); + let completion = turn_terminal( + "interrupted", + Err(error), + Some(&host_failure), + &history_root(), + ); + let TurnCompletion::Failed(failure) = completion else { + panic!("host fact must fail the turn"); + }; + match &failure { + TurnFailure::TurnInterrupted(payload) => { + assert!(payload.detail.contains("本轮模型执行被中断")) + } + other => panic!("expected a turn-interrupted payload, got {other:?}"), + } + } + + /// 终态事件的形状:失败时同一个 `turn.completed` 带载荷,其余只带 `status`。 + #[test] + fn terminal_event_carries_the_payload_and_the_opening_identity() { + let error = TurnError::from_model_call(&LlmError::Upstream { + status_code: 502, + message: "上游 502".into(), + }); + let failing = turn_terminal("interrupted", Err(error), None, &history_root()); + let event = failing.event(2_000, Some("direct-codex:turn-1:user")); + match event.failure() { + Some(TurnFailure::ModelCallFailed(payload)) => assert_eq!( + payload.kind, + ModelCallKind::UpstreamFailed { + status_code: 502, + native: None + } + ), + other => panic!("expected a model call failure payload, got {other:?}"), + } + assert_eq!(event.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!(event.at(), Some(2_000)); + + let quiet = turn_terminal("completed", Ok("本轮交付已完成"), None, &history_root()); + let event = quiet.event(3_000, None); + assert!(event.failure().is_none()); + assert!(matches!( + event, + ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + )); + } + + /// 兜底终态:说不出原因的那一种只给分类,不冒充真实原因。 + #[test] + fn host_dropped_terminal_only_carries_the_classification() { + assert_eq!( + TurnCompletion::host_dropped(), + TurnCompletion::Failed(TurnFailure::HostDropped) + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs deleted file mode 100644 index 9bab8b252..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire.rs +++ /dev/null @@ -1,1621 +0,0 @@ -//! DirectProject 聊天事件的线上模型与投影。 -//! -//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, -//! 与 Rust 定义同源:加一个字段不会只改一边。 -//! -//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 -//! 合并规则全部属于前端投影,这里一概不出现。 -//! -//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server -//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, -//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 -//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 - -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::agent::DirectCodexUserItem; -use crate::agent::DirectTurnFailureKind; -use crate::redact_absolute_path_tokens; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::path::Path; -use ts_rs::TS; - -/// 宿主观测时刻:Unix 毫秒。 -pub(crate) fn direct_now_ms() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - .min(u64::MAX as u128) as u64 -} - -/// 正文(消息 / 思考)上限。 -const THREAD_TEXT_MAX_CHARS: usize = 8_000; -/// 工具明细(命令 / 参数 / 输出)上限。 -const THREAD_DETAIL_MAX_CHARS: usize = 4_000; -/// 单条变更路径上限。 -const THREAD_PATH_MAX_CHARS: usize = 300; - -/// 一条文件变更。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct ThreadFileChange { - pub(crate) path: String, - /// `add` | `update` | `delete` - pub(crate) kind: String, -} - -/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 -/// -/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 -/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 -/// -/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, -/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadItem { - #[serde(rename = "message")] - Message { - /// 归一身份:全链路只有这一个 id。 - item_id: String, - /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 - role: String, - text: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "reasoning")] - Reasoning { - item_id: String, - text: String, - #[ts(as = "f64")] - at: u64, - }, - /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 - /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 - #[serde(rename = "function_call")] - FunctionCall { - item_id: String, - name: String, - arguments: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "function_call_output")] - FunctionCallOutput { - item_id: String, - output: String, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "commandExecution")] - CommandExecution { - item_id: String, - command: String, - #[serde(default)] - output: Option, - /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … - #[serde(default)] - status: Option, - #[serde(default)] - #[ts(as = "Option")] - exit_code: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "fileChange")] - FileChange { - item_id: String, - changes: Vec, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "mcpToolCall")] - McpToolCall { - item_id: String, - tool: String, - arguments: String, - #[serde(default)] - output: Option, - #[serde(default)] - status: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "webSearch")] - WebSearch { - item_id: String, - #[serde(default)] - query: Option, - #[serde(default)] - output: Option, - #[ts(as = "f64")] - at: u64, - }, - #[serde(rename = "contextCompaction")] - ContextCompaction { - item_id: String, - #[ts(as = "f64")] - at: u64, - }, - /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 - #[serde(rename = "other")] - Other { - item_id: String, - raw_type: String, - #[ts(as = "f64")] - at: u64, - }, -} - -impl ThreadItem { - /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 - pub(crate) fn item_id(&self) -> &str { - match self { - Self::Message { item_id, .. } - | Self::Reasoning { item_id, .. } - | Self::FunctionCall { item_id, .. } - | Self::FunctionCallOutput { item_id, .. } - | Self::CommandExecution { item_id, .. } - | Self::FileChange { item_id, .. } - | Self::McpToolCall { item_id, .. } - | Self::WebSearch { item_id, .. } - | Self::ContextCompaction { item_id, .. } - | Self::Other { item_id, .. } => item_id, - } - } - - /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; - /// 那两类边界用事件级 `at`。 - pub(crate) fn at(&self) -> u64 { - match self { - Self::Message { at, .. } - | Self::Reasoning { at, .. } - | Self::FunctionCall { at, .. } - | Self::FunctionCallOutput { at, .. } - | Self::CommandExecution { at, .. } - | Self::FileChange { at, .. } - | Self::McpToolCall { at, .. } - | Self::WebSearch { at, .. } - | Self::ContextCompaction { at, .. } - | Self::Other { at, .. } => *at, - } - } -} - -/// 增量正文属于哪类条目。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadDeltaKind { - /// assistant 正文。 - Message, - /// 思考正文。 - Reasoning, -} - -/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadRequestKind { - #[serde(rename = "approval.requested")] - ApprovalRequested, - #[serde(rename = "ask.requested")] - AskRequested, - #[serde(rename = "request.resolved")] - RequestResolved, -} - -impl ThreadRequestKind { - /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 - pub(crate) fn is_request(&self) -> bool { - matches!(self, Self::ApprovalRequested | Self::AskRequested) - } - - pub(crate) fn is_resolution(&self) -> bool { - !self.is_request() - } -} - -/// 一条待发消息离开队列的原因。 -/// -/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 -/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum QueueRemovalReason { - /// 用户取消了这条待发消息。 - Cancelled, - /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 - Dispatched, -} - -/// 取消一条待发消息的结果。 -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum QueueRemovalOutcome { - /// 已从队列移除。 - Removed, - /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 - AlreadyDispatched, - /// 队列里没有这个身份,也没有在跑的一轮是它。 - NotFound, -} - -/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 -/// -/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 -/// 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct DirectTurnFailure { - /// 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 - /// [`DirectTurnError::wire_kind`]。 - pub(crate) kind: DirectTurnFailureKind, - /// 脱敏 + 截断后的失败原因。 - pub(crate) message: String, -} - -impl DirectTurnFailure { - pub(crate) fn new(kind: DirectTurnFailureKind, message: impl Into) -> Self { - Self { - kind, - message: message.into(), - } - } -} - -/// Thread Manager 下发的运行态事件。 -/// -/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: -/// 游标是 Thread Manager 的内部事实,不下发。 -/// -/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 -/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 -/// -/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) -/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 -/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 -/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; -/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 -/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 -/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, -/// 不能在前端收到或重放时重新取当前时间。 -/// -/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical -/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; -/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 -/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 -/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 -/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) enum ThreadEvent { - #[serde(rename = "turn.started")] - TurnStarted { - /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 - /// `turn/start` 的时刻)。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - user_item_id: Option, - }, - #[serde(rename = "turn.completed")] - TurnCompleted { - /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, - /// 此时必须带 `failure` 载荷。 - status: String, - /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - failure: Option, - /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - user_item_id: Option, - }, - #[serde(rename = "item.started")] - ItemStarted { - item: ThreadItem, - /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, - #[serde(rename = "item.completed")] - ItemCompleted { - item: ThreadItem, - /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, - #[serde(rename = "item.delta")] - ItemDelta { - item_id: String, - kind: ThreadDeltaKind, - delta: String, - }, - #[serde(rename = "request")] - Request { - kind: ThreadRequestKind, - #[serde(default)] - request_id: Option, - }, - /// 待发消息入队:数组顺序就是队首到队尾的顺序。 - /// - /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 - /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 - /// - /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 - /// 从这条条目重投影。 - #[serde(rename = "queue.enqueued")] - QueueEnqueued { - /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 - client_turn_id: String, - /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 - user_item: DirectCodexUserItem, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - creation_type: Option, - /// 入队那一刻的宿主毫秒钟。 - #[ts(as = "f64")] - at: u64, - }, - /// 待发消息离开队列:`reason` 是取消还是放行。 - #[serde(rename = "queue.removed")] - QueueRemoved { - client_turn_id: String, - reason: QueueRemovalReason, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional, as = "Option")] - at: Option, - }, -} - -impl ThreadEvent { - pub(crate) fn turn_started(at: u64) -> Self { - Self::TurnStarted { - at: Some(at), - user_item_id: None, - } - } - - pub(crate) fn turn_completed(status: String, at: u64) -> Self { - Self::TurnCompleted { - status, - failure: None, - at: Some(at), - user_item_id: None, - } - } - - /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 - pub(crate) fn turn_completed_failed(failure: DirectTurnFailure, at: u64) -> Self { - Self::TurnCompleted { - status: "failed".to_string(), - failure: Some(failure), - at: Some(at), - user_item_id: None, - } - } - - /// 失败载荷:只有失败终态有。 - pub(crate) fn failure(&self) -> Option<&DirectTurnFailure> { - match self { - Self::TurnCompleted { failure, .. } => failure.as_ref(), - _ => None, - } - } - - /// 附上本轮开口用户条目的 canonical itemId。 - /// - /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 - /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 - pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { - let user_item_id = user_item_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match self { - Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, - Self::TurnCompleted { - status, - failure, - at, - .. - } => Self::TurnCompleted { - status, - failure, - at, - user_item_id, - }, - other => other, - } - } - - /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 - /// - /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 - #[cfg(test)] - pub(crate) fn user_item_id(&self) -> Option<&str> { - match self { - Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { - user_item_id.as_deref() - } - _ => None, - } - } - - pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { - Self::ItemStarted { item, at: Some(at) } - } - - pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { - Self::ItemCompleted { item, at: Some(at) } - } - - pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { - Self::ItemDelta { - item_id, - kind, - delta, - } - } - - pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { - Self::Request { kind, request_id } - } - - /// 待发消息入队事件。 - pub(crate) fn queue_enqueued( - client_turn_id: String, - user_item: DirectCodexUserItem, - creation_type: Option, - at: u64, - ) -> Self { - Self::QueueEnqueued { - client_turn_id, - user_item, - creation_type, - at, - } - } - - /// 待发消息离开队列事件。 - pub(crate) fn queue_removed( - client_turn_id: String, - reason: QueueRemovalReason, - at: u64, - ) -> Self { - Self::QueueRemoved { - client_turn_id, - reason, - at: Some(at), - } - } - - /// 这条事件属于哪条待发消息:只有队列事件有。 - pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { - match self { - Self::QueueEnqueued { client_turn_id, .. } - | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), - _ => None, - } - } - - /// 待发消息离开队列的原因:只有 `queue.removed` 有。 - pub(crate) fn queue_removal_reason(&self) -> Option { - match self { - Self::QueueRemoved { reason, .. } => Some(*reason), - _ => None, - } - } - - /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 - /// - /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 - #[cfg(test)] - pub(crate) fn at(&self) -> Option { - match self { - Self::TurnStarted { at, .. } - | Self::TurnCompleted { at, .. } - | Self::ItemStarted { at, .. } - | Self::ItemCompleted { at, .. } => *at, - Self::QueueEnqueued { at, .. } => Some(*at), - Self::QueueRemoved { at, .. } => *at, - Self::ItemDelta { .. } | Self::Request { .. } => None, - } - } - - /// 事件关联的条目身份:只有 item 事件有。 - pub(crate) fn item_id(&self) -> Option<&str> { - match self { - Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { - Some(item.item_id()) - } - _ => None, - } - } - - pub(crate) fn request_id(&self) -> Option<&str> { - match self { - Self::Request { request_id, .. } => request_id.as_deref(), - _ => None, - } - } - - pub(crate) fn request_kind(&self) -> Option { - match self { - Self::Request { kind, .. } => Some(*kind), - _ => None, - } - } -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct SubscriptionBootstrap { - pub(crate) subscription_id: String, - /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 - #[serde(default)] - pub(crate) last_completed_item_id: Option, - /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct ConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] -pub(crate) struct HistorySlice { - /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 - pub(crate) items: Vec, - pub(crate) has_more: bool, - /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 - #[serde(default)] - pub(crate) first_item_id: Option, -} - -fn bounded(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_string(); - } - let mut truncated = value.chars().take(max_chars).collect::(); - truncated.push('…'); - truncated -} - -fn detail_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) -} - -/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 -fn value_text(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - other => serde_json::to_string_pretty(other).ok(), - } -} - -fn item_text(root: &Path, item: &Value) -> Option { - let raw = item - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None - }) - .filter(|text| !text.trim().is_empty())?; - Some(bounded( - &sanitize_detail_text(root, &raw), - THREAD_TEXT_MAX_CHARS, - )) -} - -/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 -pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { - bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) -} - -fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default(); - if from_metadata > 0 { - return from_metadata; - } - for key in ["startedAtMs", "completedAtMs"] { - let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); - if value > 0 { - return value; - } - } - observed_at_ms -} - -/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 -fn json_ms(container: &Value, key: &str) -> Option { - container - .get(key) - .and_then(Value::as_u64) - .filter(|value| *value > 0) -} - -/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 -/// -/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, -/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 -/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 -/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 -pub(crate) fn thread_item_event_at_ms( - params: &Value, - item: &Value, - completed: bool, - observed_at_ms: u64, -) -> u64 { - let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); - if !completed { - return started_ms().unwrap_or(observed_at_ms); - } - if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { - return at; - } - let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); - match (started_ms(), duration_ms) { - (Some(started), Some(duration)) => started.saturating_add(duration), - _ => observed_at_ms, - } -} - -/// `turn.completed` 的事件级阶段时间(毫秒)。 -/// -/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 -/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 -/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 -/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 -/// 整轮吞掉。因此这里不采用任何秒字段: -/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; -/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 -/// -/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` -/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 -pub(crate) fn thread_turn_completed_at_ms( - turn: &Value, - high_precision_started_at_ms: Option, - observed_at_ms: u64, -) -> u64 { - match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { - (Some(started), Some(duration)) => started.saturating_add(duration), - _ => observed_at_ms, - } -} - -/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 -pub(crate) fn thread_item_identity(item: &Value) -> Option { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - call_id.or(id) -} - -fn item_changes(root: &Path, item: &Value) -> Vec { - item.get("changes") - .and_then(Value::as_array) - .map(|changes| { - changes - .iter() - .filter_map(|change| { - let path = change - .get("path") - .and_then(Value::as_str) - .map(str::trim) - .filter(|path| !path.is_empty())?; - Some(ThreadFileChange { - path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -fn field_text(root: &Path, item: &Value, key: &str) -> Option { - item.get(key) - .and_then(value_text) - .map(|value| detail_text(root, &value)) -} - -/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 -/// -/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn thread_item_from_value( - root: &Path, - item: &Value, - observed_at_ms: u64, -) -> Option { - if !item.is_object() { - return None; - } - let item_id = thread_item_identity(item)?; - let item_type = item - .get("type") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())?; - let at = item_at_ms(item, observed_at_ms); - let text = item_text(root, item); - let role = item - .get("role") - .and_then(Value::as_str) - .map(str::trim) - .filter(|role| !role.is_empty()) - .map(str::to_string); - - Some(match item_type { - "message" | "agentMessage" | "userMessage" => ThreadItem::Message { - item_id, - role: role.unwrap_or_else(|| { - if item_type == "userMessage" { - "user".to_string() - } else { - "assistant".to_string() - } - }), - text: text?, - at, - }, - "reasoning" => ThreadItem::Reasoning { - item_id, - text: text?, - at, - }, - "function_call" => ThreadItem::FunctionCall { - item_id, - name: item - .get("name") - .and_then(Value::as_str) - .unwrap_or_default() - .to_string(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), - at, - }, - "function_call_output" => ThreadItem::FunctionCallOutput { - item_id, - output: field_text(root, item, "output").unwrap_or_default(), - at, - }, - "commandExecution" => ThreadItem::CommandExecution { - item_id, - command: item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| detail_text(root, command)) - .unwrap_or_default(), - output: ["aggregatedOutput", "output", "error"] - .iter() - .find_map(|key| field_text(root, item, key)), - status: item - .get("status") - .and_then(Value::as_str) - .map(str::to_string), - exit_code: item.get("exitCode").and_then(Value::as_i64), - at, - }, - "fileChange" => ThreadItem::FileChange { - item_id, - changes: item_changes(root, item), - at, - }, - "mcpToolCall" => ThreadItem::McpToolCall { - item_id, - tool: item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)) - .unwrap_or_default(), - arguments: field_text(root, item, "arguments").unwrap_or_default(), - output: ["result", "error"] - .iter() - .find_map(|key| field_text(root, item, key)), - status: item - .get("status") - .and_then(Value::as_str) - .map(str::to_string), - at, - }, - "webSearch" => ThreadItem::WebSearch { - item_id, - query: field_text(root, item, "query").or_else(|| { - item.get("action") - .and_then(|action| action.get("query")) - .and_then(value_text) - .map(|query| detail_text(root, &query)) - }), - output: field_text(root, item, "output"), - at, - }, - "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, - other => ThreadItem::Other { - item_id, - raw_type: other.to_string(), - at, - }, - }) -} - -/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 -/// -/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn thread_items_from_history( - root: &Path, - items: &[Value], - timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { - items - .iter() - .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) - .collect() -} - -/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 -fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { - let mut index = start; - let mut relative = String::new(); - while index < value.len() { - let character = value[index..].chars().next().unwrap_or_default(); - if matches!(character, '/' | '\\') { - if !relative.is_empty() { - relative.push('/'); - } - index += character.len_utf8(); - continue; - } - if character.is_whitespace() - || matches!( - character, - '\'' | '"' - | '`' - | ',' - | ';' - | '|' - | '&' - | '(' - | ')' - | '[' - | ']' - | '{' - | '}' - | '<' - | '>' - | ':' - ) - { - break; - } - relative.push(character); - index += character.len_utf8(); - } - while relative.ends_with('/') { - relative.pop(); - } - (index, relative) -} - -/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 -/// -/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 -/// ``,之后就再也认不出哪些路径在项目内了。 -/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 -fn relativize_project_root_paths(root: &Path, value: &str) -> String { - let root_text = root.to_string_lossy(); - let root_text = root_text.trim_end_matches(['/', '\\']); - if root_text.is_empty() { - return value.to_string(); - } - let mut needles = [ - root_text.to_string(), - root_text.replace('\\', "/"), - root_text.replace('/', "\\"), - ] - .into_iter() - .map(|needle| needle.to_ascii_lowercase()) - .filter(|needle| !needle.is_empty()) - .collect::>(); - needles.sort(); - needles.dedup(); - let lower = value.to_ascii_lowercase(); - - let mut output = String::with_capacity(value.len()); - let mut cursor = 0usize; - while cursor < value.len() { - let mut hit: Option<(usize, usize)> = None; - for needle in &needles { - let mut search = cursor; - while let Some(relative) = lower[search..].find(needle.as_str()) { - let start = search + relative; - let end = start + needle.len(); - let left_is_boundary = start == 0 - || lower[..start].chars().next_back().is_some_and(|character| { - !character.is_alphanumeric() && character != '_' && character != '-' - }); - if left_is_boundary && value[end..].starts_with(['/', '\\']) { - if hit.is_none_or(|(best_start, _)| start < best_start) { - hit = Some((start, end)); - } - break; - } - search = end; - } - } - let Some((start, end)) = hit else { - break; - }; - output.push_str(&value[cursor..start]); - let (consumed, relative) = project_relative_path_segment(value, end); - if relative.is_empty() { - // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 - output.push_str(""); - } else { - output.push_str(&relative); - } - cursor = consumed; - } - output.push_str(&value[cursor..]); - output -} - -/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 -/// 错误上下文脱敏。 -/// -/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 -/// 仍然会留下;这里先归一化路径 token,再处理密钥。 -pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { - let without_project_root = relativize_project_root_paths(root, value); - let without_absolute = redact_absolute_path_tokens(&without_project_root); - let without_secret = redact_secret_tokens(&without_absolute); - sanitize_error_context(&without_secret) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_carries_role_text_and_turn() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - 0, - ) - .expect("user item"); - assert_eq!( - item, - ThreadItem::Message { - item_id: "direct-codex:turn-1:user".to_string(), - role: "user".to_string(), - text: "做一个拼图游戏".to_string(), - at: 0, - } - ); - } - - #[test] - fn app_server_agent_message_defaults_to_assistant_role() { - let item = thread_item_from_value( - root(), - &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), - 1000, - ) - .expect("agent message"); - assert!(matches!( - item, - ThreadItem::Message { role, .. } if role == "assistant" - )); - } - - #[test] - fn tool_item_identity_is_normalized_to_one_id() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - 1000, - ) - .expect("tool item"); - // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 - assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - assert!(matches!( - item, - ThreadItem::FunctionCall { name, arguments, .. } - if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" - )); - } - - #[test] - fn command_execution_keeps_raw_status_and_exit_code() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "call-1", - "type": "commandExecution", - "command": "ls", - "status": "failed", - "exitCode": 2, - "aggregatedOutput": "boom", - }), - 0, - ) - .expect("command item"); - assert!(matches!( - item, - ThreadItem::CommandExecution { - command, - output: Some(output), - status: Some(status), - exit_code: Some(2), - .. - } if command == "ls" && output == "boom" && status == "failed" - )); - } - - #[test] - fn secrets_and_absolute_paths_are_not_leaked() { - let item = thread_item_from_value( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], - }), - 0, - ) - .expect("assistant item"); - let ThreadItem::Message { text, .. } = item else { - panic!("message item"); - }; - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); - } - - #[test] - fn history_keeps_call_and_output_as_two_items_with_one_identity() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let projected = thread_items_from_history(root(), &items, |_| 0); - assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); - assert!(matches!( - projected[1], - ThreadItem::FunctionCallOutput { .. } - )); - // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 - assert_eq!(projected[0].item_id(), projected[1].item_id()); - assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - } - - #[test] - fn unknown_item_types_are_passed_through_without_body() { - let item = thread_item_from_value( - root(), - &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), - 0, - ) - .expect("unknown item"); - assert!(matches!( - item, - // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 - // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 - ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" - )); - } - - /// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, - /// 后者只说明条目什么时候被看到。 - #[test] - fn event_stage_time_is_independent_from_item_display_time() { - let params = json!({ - "completedAtMs": 2_000u64, - "item": { - "id": "call-1", - "type": "commandExecution", - "command": "ls", - "startedAtMs": 1_000u64, - }, - }); - let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); - // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 - assert_eq!(item.at(), 1_000); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), - 2_000 - ); - } - - #[test] - fn item_started_event_at_uses_notification_stage_time() { - // 通知层 `startedAtMs` 优先于条目自带的同名字段。 - let params = json!({ - "threadId": "thread-1", - "turnId": "turn-1", - "startedAtMs": 1_700_000_000_123u64, - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_700_000_000_000u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), - 1_700_000_000_123 - ); - } - - #[test] - fn item_event_at_falls_back_to_nested_item_then_host_clock() { - let nested = json!({ - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_700_000_000_500u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), - 1_700_000_000_500 - ); - - // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 - let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); - assert_eq!( - thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), - 9_999 - ); - assert_eq!( - thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), - 9_999 - ); - } - - #[test] - fn item_completed_event_at_prefers_completion_over_start() { - // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 - let params = json!({ - "startedAtMs": 1_000u64, - "completedAtMs": 2_000u64, - "durationMs": 1_000u64, - "item": {"id": "call-1", "type": "commandExecution"}, - }); - assert_eq!( - thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), - 2_000 - ); - - // 完成时间只在条目里:同样取完成时间。 - let nested = json!({ - "item": { - "id": "call-1", - "type": "commandExecution", - "startedAtMs": 1_000u64, - "completedAtMs": 2_500u64, - }, - }); - assert_eq!( - thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), - 2_500 - ); - } - - #[test] - fn item_completed_event_at_derives_end_only_with_reliable_start() { - let with_start = json!({ - "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, - }); - assert_eq!( - thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), - 1_250 - ); - - // 只有时长不足以证明结束时刻:回落到宿主钟。 - let duration_only = json!({ - "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, - }); - assert_eq!( - thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), - 9_999 - ); - } - - /// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, - /// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 - /// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 - #[test] - fn turn_completed_at_ignores_second_truncated_upstream_fields() { - let seconds_only = json!({ - "id": "turn-1", - "status": "completed", - "startedAt": 1_700_000_000i64, - "completedAt": 1_700_000_042i64, - }); - assert_eq!( - thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), - 9_999, - "没有 durationMs 时用宿主钟,不换算秒字段" - ); - assert_eq!( - thread_turn_completed_at_ms(&seconds_only, None, 9_999), - 9_999 - ); - } - - #[test] - fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { - let with_duration = json!({ - "id": "turn-1", - "status": "completed", - "startedAt": 1_700_000_000i64, - "completedAt": 1_700_000_042i64, - "durationMs": 42_500u64, - }); - // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 - assert_eq!( - thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), - 1_700_000_043_000 - ); - // 起点不可证明时不派生。 - assert_eq!( - thread_turn_completed_at_ms(&with_duration, None, 9_999), - 9_999 - ); - // 时长为 0 同样按缺失处理。 - let zero_duration = json!({"durationMs": 0u64}); - assert_eq!( - thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), - 9_999 - ); - } - - /// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 - /// 反序列化仍成立,且不会序列化出 `at: null`。 - #[test] - fn lifecycle_events_serialize_event_level_at_as_optional_number() { - let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) - .expect("serialize turn.started"); - assert_eq!( - started, - json!({"type": "turn.started", "at": 1_700_000_000_123u64}) - ); - assert_eq!( - serde_json::from_value::(started).expect("round trip"), - ThreadEvent::turn_started(1_700_000_000_123) - ); - - let completed = - serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) - .expect("serialize turn.completed"); - assert_eq!( - completed, - json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) - ); - - let item = ThreadItem::CommandExecution { - item_id: "call-1".to_string(), - command: "ls".to_string(), - output: None, - status: Some("completed".to_string()), - exit_code: None, - at: 1_500, - }; - let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) - .expect("serialize item.started"); - assert_eq!(item_started["at"], json!(1_000u64)); - // 事件级 `at` 不动条目自己的展示时间。 - assert_eq!(item_started["item"]["at"], json!(1_500u64)); - let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) - .expect("serialize item.completed"); - assert_eq!(item_completed["at"], json!(2_000u64)); - - // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) - .expect("legacy turn.started without at"); - assert_eq!( - legacy, - ThreadEvent::TurnStarted { - at: None, - user_item_id: None, - } - ); - assert_eq!(legacy.at(), None); - assert_eq!(legacy.user_item_id(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({"type": "turn.started"}) - ); - assert_eq!( - serde_json::to_value(ThreadEvent::request( - ThreadRequestKind::RequestResolved, - None, - )) - .expect("serialize request"), - json!({"type": "request", "kind": "request.resolved", "requestId": null}) - ); - } - - /// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), - /// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 - #[test] - fn lifecycle_events_carry_optional_opener_user_item_id() { - let started = - ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&started).expect("serialize turn.started"), - json!({ - "type": "turn.started", - "at": 1_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&started).expect("serialize") - ) - .expect("round trip"), - started - ); - - let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) - .with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&completed).expect("serialize turn.completed"), - json!({ - "type": "turn.completed", - "status": "interrupted", - "at": 2_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - // 起止同源:同一轮的两条边界带同一个身份。 - assert_eq!(started.user_item_id(), completed.user_item_id()); - - // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 - for empty in ["", " "] { - let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); - assert_eq!(event.user_item_id(), None); - assert_eq!( - serde_json::to_value(&event).expect("serialize"), - json!({"type": "turn.started", "at": 1_000u64}) - ); - } - - // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({ - "type": "turn.completed", - "status": "completed", - "at": 3_000u64, - })) - .expect("legacy turn.completed without userItemId"); - assert_eq!(legacy.user_item_id(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) - ); - - // 条目事件没有这个字段:身份只在生命周期事件上。 - let item_event = ThreadEvent::item_completed( - ThreadItem::CommandExecution { - item_id: "call-1".to_string(), - command: "ls".to_string(), - output: None, - status: None, - exit_code: None, - at: 1_500, - }, - 1_600, - ); - assert_eq!(item_event.user_item_id(), None); - } - - /// 失败终态:`status="failed"` 必须带 `failure{kind,message}`,正常终态不带;载荷跟着身份 - /// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 - #[test] - fn turn_completed_carries_failure_payload_only_when_failed() { - let failed = ThreadEvent::turn_completed_failed( - DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::ModelFailed, - "上游返回 500:模型服务暂不可用", - ), - 4_000, - ) - .with_user_item_id(Some("direct-codex:turn-1:user")); - assert_eq!( - failed.failure(), - Some(&DirectTurnFailure::new( - crate::agent::DirectTurnFailureKind::ModelFailed, - "上游返回 500:模型服务暂不可用" - )) - ); - assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); - assert_eq!( - serde_json::to_value(&failed).expect("serialize failed turn"), - json!({ - "type": "turn.completed", - "status": "failed", - "failure": {"kind": "model-failed", "message": "上游返回 500:模型服务暂不可用"}, - "at": 4_000u64, - "userItemId": "direct-codex:turn-1:user", - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&failed).expect("serialize") - ) - .expect("round trip"), - failed - ); - - // 正常终态不带载荷,也不回写 `failure: null`。 - let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); - assert_eq!(completed.failure(), None); - assert_eq!( - serde_json::to_value(&completed).expect("serialize completed turn"), - json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) - ); - - // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 - let sparse: ThreadEvent = serde_json::from_value(json!({ - "type": "turn.completed", - "status": "failed", - })) - .expect("failed turn without failure payload"); - assert_eq!(sparse.failure(), None); - } - - /// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, - /// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 - #[test] - fn queue_removed_carries_a_typed_reason() { - let dispatched = - ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); - assert_eq!( - serde_json::to_value(&dispatched).expect("serialize queue.removed"), - json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "dispatched", - "at": 2_000u64, - }) - ); - assert_eq!( - serde_json::from_value::( - serde_json::to_value(&dispatched).expect("serialize") - ) - .expect("round trip"), - dispatched - ); - assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); - assert_eq!( - dispatched.queue_removal_reason(), - Some(QueueRemovalReason::Dispatched) - ); - - // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 - serde_json::from_value::(json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "timeout", - })) - .expect_err("unknown queue removal reason must fail closed"); - - // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 - let legacy: ThreadEvent = serde_json::from_value(json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "cancelled", - })) - .expect("queue.removed without at"); - assert_eq!(legacy.at(), None); - assert_eq!( - serde_json::to_value(legacy).expect("serialize legacy"), - json!({ - "type": "queue.removed", - "clientTurnId": "turn-1", - "reason": "cancelled", - }) - ); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs new file mode 100644 index 000000000..781f909e9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/clock.rs @@ -0,0 +1,8 @@ +/// 宿主观测时刻:Unix 毫秒。 +pub(crate) fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs new file mode 100644 index 000000000..3b73091c7 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/failure.rs @@ -0,0 +1,48 @@ +//! 失败终态的**线上载荷**。 +//! +//! [`TurnFailure`] 是 `turn.completed.status == "failed"` 时必有的下发形状,形状属于线上协议, +//! 所以留在这个 wire 模块里;`turn.completed` 事件本身在 [`super::turn`]。 +//! +//! "什么算失败、原因怎么写"是宿主内部策略,不在 wire 里:那是 +//! [`crate::agent::TurnCompletion`](`thread_manager::turn_completion`)。 +//! +//! 载荷与脱敏都由 [`TurnError::terminal_failure`] 一处投影:Rust 侧没有第二个地方再拼它, +//! 也没有任何地方再解析它。这里不碰事件队列的搬运规则,也不自己认 `LlmError`。 + +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::agent::{ + EnvironmentNotReady, HostStateUnavailable, ModelCallFailed, ProjectRootUnanchored, TimedOut, + TransportClosed, TurnFailed, TurnFailedUnclassified, TurnInterrupted, +}; + +/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 +/// +/// 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 +/// 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 +/// 不直接上屏。 +/// +/// 唯一投影点是 [`crate::agent::TurnError::terminal_failure`]:控制流(返修要求)返回 +/// `None`,所以控制流既不会出现在这里,前端也不需要为它写分支。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase", + deny_unknown_fields +)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum TurnFailure { + ProjectRootUnanchored(ProjectRootUnanchored), + EnvironmentNotReady(EnvironmentNotReady), + HostStateUnavailable(HostStateUnavailable), + ModelCallFailed(ModelCallFailed), + TransportClosed(TransportClosed), + TimedOut(TimedOut), + TurnInterrupted(TurnInterrupted), + TurnFailed(TurnFailed), + TurnFailedUnclassified(TurnFailedUnclassified), + /// 宿主任务提前结束(panic / 被取消):说不出原因的那一种兜底。 + HostDropped, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs new file mode 100644 index 000000000..ac4e06338 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/items.rs @@ -0,0 +1,635 @@ +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// 正文(消息 / 思考)上限。 +const THREAD_TEXT_MAX_CHARS: usize = 8_000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const THREAD_DETAIL_MAX_CHARS: usize = 4_000; +/// 单条变更路径上限。 +const THREAD_PATH_MAX_CHARS: usize = 300; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`ThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`ThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl ThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } + + /// 条目展示时间(毫秒)。只用于条目自身的展示,不能当工具的开始 / 完成边界; + /// 那两类边界用事件级 `at`。 + pub(crate) fn at(&self) -> u64 { + match self { + Self::Message { at, .. } + | Self::Reasoning { at, .. } + | Self::FunctionCall { at, .. } + | Self::FunctionCallOutput { at, .. } + | Self::CommandExecution { at, .. } + | Self::FileChange { at, .. } + | Self::McpToolCall { at, .. } + | Self::WebSearch { at, .. } + | Self::ContextCompaction { at, .. } + | Self::Other { at, .. } => *at, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl ThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +fn bounded(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded(&sanitize_detail_text(root, value), THREAD_DETAIL_MAX_CHARS) +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + THREAD_TEXT_MAX_CHARS, + )) +} + +/// 流式正文与完成态条目使用同一套脱敏和字符预算,避免增量文本绕过历史投影的安全边界。 +pub(crate) fn thread_delta_text(root: &Path, value: &str) -> String { + bounded(&sanitize_detail_text(root, value), THREAD_TEXT_MAX_CHARS) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 原生毫秒时间戳:0(协议里的"缺省")与非法值一样按缺失处理。 +fn json_ms(container: &Value, key: &str) -> Option { + container + .get(key) + .and_then(Value::as_u64) + .filter(|value| *value > 0) +} + +/// `item/started` / `item/completed` 的事件级阶段时间(毫秒)。 +/// +/// 字段位置按当前 app-server 协议:通知层带 `params.startedAtMs` / `params.completedAtMs`, +/// 条目自带时用条目里的同名毫秒字段。完成事件即使同时 +/// 带着开始字段也只取**完成**时间;两者都没有、但 `durationMs` 有可靠起点时按 +/// 起点 + 时长派生结束。都没有就用宿主处理该事件的钟——原生缺阶段时间时这是唯一诚实的值。 +pub(crate) fn thread_item_event_at_ms( + params: &Value, + item: &Value, + completed: bool, + observed_at_ms: u64, +) -> u64 { + let started_ms = || json_ms(params, "startedAtMs").or_else(|| json_ms(item, "startedAtMs")); + if !completed { + return started_ms().unwrap_or(observed_at_ms); + } + if let Some(at) = json_ms(params, "completedAtMs").or_else(|| json_ms(item, "completedAtMs")) { + return at; + } + let duration_ms = json_ms(params, "durationMs").or_else(|| json_ms(item, "durationMs")); + match (started_ms(), duration_ms) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// `turn.completed` 的事件级阶段时间(毫秒)。 +/// +/// Turn 里的 `startedAt` / `completedAt` 是 Unix **秒**(协议 `format: int64`,字段名不带 +/// `Ms` 的都是秒),而 `durationMs` 才是毫秒。秒级截断在这里是不能用的:它既撑不起前端 +/// 0.1 秒粒度的展示(显示出来的小数位是假精度),也可能让"完成时刻"落进该轮用户消息所在的 +/// 同一秒、落在用户真实发送时间之前,前端按"结束早于开始"判成无效边界,于是一轮新回合被 +/// 整轮吞掉。因此这里不采用任何秒字段: +/// - 只有 `durationMs` 与**高精度起点**都可靠时才按 `起点 + 时长` 派生结束; +/// - 否则取宿主处理终态的钟,语义与条目侧"没有原生阶段时间就用宿主钟"完全一致。 +/// +/// `high_precision_started_at_ms` 是本轮开始时宿主记下的那个毫秒起点(即 `turn.started` +/// 事件写入的同一个值),不是从上游秒字段换算出来的,`None` 表示起点也不可证明。 +pub(crate) fn thread_turn_completed_at_ms( + turn: &Value, + high_precision_started_at_ms: Option, + observed_at_ms: u64, +) -> u64 { + match (high_precision_started_at_ms, json_ms(turn, "durationMs")) { + (Some(started), Some(duration)) => started.saturating_add(duration), + _ => observed_at_ms, + } +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(root: &Path, item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(ThreadFileChange { + path: bounded(&sanitize_detail_text(root, path), THREAD_PATH_MAX_CHARS), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(root: &Path, item: &Value, key: &str) -> Option { + item.get(key) + .and_then(value_text) + .map(|value| detail_text(root, &value)) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn thread_item_from_value( + root: &Path, + item: &Value, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let item_id = thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(root, item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => ThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => ThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => ThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => ThreadItem::FunctionCallOutput { + item_id, + output: field_text(root, item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => ThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => ThreadItem::FileChange { + item_id, + changes: item_changes(root, item), + at, + }, + "mcpToolCall" => ThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)) + .unwrap_or_default(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => ThreadItem::WebSearch { + item_id, + query: field_text(root, item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + .map(|query| detail_text(root, &query)) + }), + output: field_text(root, item, "output"), + at, + }, + "contextCompaction" => ThreadItem::ContextCompaction { item_id, at }, + other => ThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn thread_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| thread_item_from_value(root, item, timestamp_of(item))) + .collect() +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs new file mode 100644 index 000000000..916fac81d --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/mod.rs @@ -0,0 +1,31 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 +//! +//! 文件按职责拆开: +//! - [`items`]:条目与历史切片的投影、脱敏、截断; +//! - [`turn`]:事件、订阅 / 消费载荷与队列出队语义; +//! - [`failure`]:失败终态的线上载荷; +//! - [`clock`]:宿主观测钟。 + +mod clock; +mod failure; +mod items; +mod turn; + +#[cfg(test)] +mod tests; + +pub(crate) use clock::*; +pub(crate) use failure::*; +pub(crate) use items::*; +pub(crate) use turn::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs new file mode 100644 index 000000000..da0663f23 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/tests.rs @@ -0,0 +1,576 @@ +use super::clock::*; +use super::{failure::*, items::*, turn::*}; +use serde_json::json; +use std::path::Path; + +fn root() -> &'static Path { + Path::new(".") +} + +#[test] +fn message_item_carries_role_text_and_turn() { + let item = thread_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + ThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); +} + +#[test] +fn app_server_agent_message_defaults_to_assistant_role() { + let item = thread_item_from_value( + root(), + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + ThreadItem::Message { role, .. } if role == "assistant" + )); +} + +#[test] +fn tool_item_identity_is_normalized_to_one_id() { + let item = thread_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + ThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); +} + +#[test] +fn command_execution_keeps_raw_status_and_exit_code() { + let item = thread_item_from_value( + root(), + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + ThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); +} + +#[test] +fn secrets_and_absolute_paths_are_not_leaked() { + let item = thread_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + 0, + ) + .expect("assistant item"); + let ThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); +} + +#[test] +fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = thread_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!(projected[0], ThreadItem::FunctionCall { .. })); + assert!(matches!( + projected[1], + ThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); +} + +#[test] +fn unknown_item_types_are_passed_through_without_body() { + let item = thread_item_from_value( + root(), + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + ThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); +} + +/// 事件级 `at` 与条目展示时间 `item.at` 是两件事:前者是本阶段的真实边界, +/// 后者只说明条目什么时候被看到。 +#[test] +fn event_stage_time_is_independent_from_item_display_time() { + let params = json!({ + "completedAtMs": 2_000u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "startedAtMs": 1_000u64, + }, + }); + let item = thread_item_from_value(root(), ¶ms["item"], 7_777).expect("item"); + // 条目展示时间不受事件级时间影响,仍按条目自己的字段推导。 + assert_eq!(item.at(), 1_000); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 7_777), + 2_000 + ); +} + +#[test] +fn item_started_event_at_uses_notification_stage_time() { + // 通知层 `startedAtMs` 优先于条目自带的同名字段。 + let params = json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "startedAtMs": 1_700_000_000_123u64, + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_000u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], false, 9_999), + 1_700_000_000_123 + ); +} + +#[test] +fn item_event_at_falls_back_to_nested_item_then_host_clock() { + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_700_000_000_500u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(&nested, &nested["item"], false, 9_999), + 1_700_000_000_500 + ); + + // 原生没有任何阶段时间:用宿主处理这条事件的钟,不编造。 + let bare = json!({"item": {"id": "call-1", "type": "commandExecution"}}); + assert_eq!( + thread_item_event_at_ms(&bare, &bare["item"], false, 9_999), + 9_999 + ); + assert_eq!( + thread_item_event_at_ms(&bare, &bare["item"], true, 9_999), + 9_999 + ); +} + +#[test] +fn item_completed_event_at_prefers_completion_over_start() { + // 通知层两个字段都在时必须取完成时间,不能退回开始时间。 + let params = json!({ + "startedAtMs": 1_000u64, + "completedAtMs": 2_000u64, + "durationMs": 1_000u64, + "item": {"id": "call-1", "type": "commandExecution"}, + }); + assert_eq!( + thread_item_event_at_ms(¶ms, ¶ms["item"], true, 9_999), + 2_000 + ); + + // 完成时间只在条目里:同样取完成时间。 + let nested = json!({ + "item": { + "id": "call-1", + "type": "commandExecution", + "startedAtMs": 1_000u64, + "completedAtMs": 2_500u64, + }, + }); + assert_eq!( + thread_item_event_at_ms(&nested, &nested["item"], true, 9_999), + 2_500 + ); +} + +#[test] +fn item_completed_event_at_derives_end_only_with_reliable_start() { + let with_start = json!({ + "item": {"id": "call-1", "type": "commandExecution", "startedAtMs": 1_000u64, "durationMs": 250u64}, + }); + assert_eq!( + thread_item_event_at_ms(&with_start, &with_start["item"], true, 9_999), + 1_250 + ); + + // 只有时长不足以证明结束时刻:回落到宿主钟。 + let duration_only = json!({ + "item": {"id": "call-1", "type": "commandExecution", "durationMs": 250u64}, + }); + assert_eq!( + thread_item_event_at_ms(&duration_only, &duration_only["item"], true, 9_999), + 9_999 + ); +} + +/// Turn 上游的 `startedAt` / `completedAt` 是**秒**级:既支撑不了 0.1 秒粒度的展示, +/// 也可能让完成时刻落进该轮用户消息的同一秒、被判成无效边界后吞掉整轮新回合。 +/// 因此秒字段一律不采用,回合边界回落到宿主处理该阶段时的毫秒钟。 +#[test] +fn turn_completed_at_ignores_second_truncated_upstream_fields() { + let seconds_only = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + }); + assert_eq!( + thread_turn_completed_at_ms(&seconds_only, Some(1_700_000_000_500), 9_999), + 9_999, + "没有 durationMs 时用宿主钟,不换算秒字段" + ); + assert_eq!( + thread_turn_completed_at_ms(&seconds_only, None, 9_999), + 9_999 + ); +} + +#[test] +fn turn_completed_at_derives_end_only_from_duration_and_high_precision_start() { + let with_duration = json!({ + "id": "turn-1", + "status": "completed", + "startedAt": 1_700_000_000i64, + "completedAt": 1_700_000_042i64, + "durationMs": 42_500u64, + }); + // 高精度起点(宿主在本轮开始时记下的毫秒值)+ 上游 durationMs:结束严格晚于起点。 + assert_eq!( + thread_turn_completed_at_ms(&with_duration, Some(1_700_000_000_500), 9_999), + 1_700_000_043_000 + ); + // 起点不可证明时不派生。 + assert_eq!( + thread_turn_completed_at_ms(&with_duration, None, 9_999), + 9_999 + ); + // 时长为 0 同样按缺失处理。 + let zero_duration = json!({"durationMs": 0u64}); + assert_eq!( + thread_turn_completed_at_ms(&zero_duration, Some(1_000), 9_999), + 9_999 + ); +} + +/// 线上形状:四种生命周期事件带事件级 `at`(number),历史 / 无时间夹具缺该字段时 +/// 反序列化仍成立,且不会序列化出 `at: null`。 +#[test] +fn lifecycle_events_serialize_event_level_at_as_optional_number() { + let started = serde_json::to_value(ThreadEvent::turn_started(1_700_000_000_123)) + .expect("serialize turn.started"); + assert_eq!( + started, + json!({"type": "turn.started", "at": 1_700_000_000_123u64}) + ); + assert_eq!( + serde_json::from_value::(started).expect("round trip"), + ThreadEvent::turn_started(1_700_000_000_123) + ); + + let completed = + serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) + .expect("serialize turn.completed"); + assert_eq!( + completed, + json!({"type": "turn.completed", "status": "completed", "at": 2_000u64}) + ); + + let item = ThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: Some("completed".to_string()), + exit_code: None, + at: 1_500, + }; + let item_started = serde_json::to_value(ThreadEvent::item_started(item.clone(), 1_000)) + .expect("serialize item.started"); + assert_eq!(item_started["at"], json!(1_000u64)); + // 事件级 `at` 不动条目自己的展示时间。 + assert_eq!(item_started["item"]["at"], json!(1_500u64)); + let item_completed = serde_json::to_value(ThreadEvent::item_completed(item, 2_000)) + .expect("serialize item.completed"); + assert_eq!(item_completed["at"], json!(2_000u64)); + + // 历史 / 夹具里的旧事件没有 `at`:反序列化成 `None`,回写时不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({"type": "turn.started"})) + .expect("legacy turn.started without at"); + assert_eq!( + legacy, + ThreadEvent::TurnStarted { + at: None, + user_item_id: None, + } + ); + assert_eq!(legacy.at(), None); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.started"}) + ); + assert_eq!( + serde_json::to_value(ThreadEvent::request( + ThreadRequestKind::RequestResolved, + None, + )) + .expect("serialize request"), + json!({"type": "request", "kind": "request.resolved", "requestId": null}) + ); +} + +/// 回合生命周期事件带可选的开口用户条目身份:线上是 `userItemId`(camelCase 的可选 string), +/// 缺省不写字段,旧事件反序列化仍是 `None`,空白身份按缺失处理(不猜)。 +#[test] +fn lifecycle_events_carry_optional_opener_user_item_id() { + let started = + ThreadEvent::turn_started(1_000).with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(started.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&started).expect("serialize turn.started"), + json!({ + "type": "turn.started", + "at": 1_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&started).expect("serialize")) + .expect("round trip"), + started + ); + + let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(completed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&completed).expect("serialize turn.completed"), + json!({ + "type": "turn.completed", + "status": "interrupted", + "at": 2_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + // 起止同源:同一轮的两条边界带同一个身份。 + assert_eq!(started.user_item_id(), completed.user_item_id()); + + // 空白 / 空串按缺失处理:不能把 "" 当成一条用户条目的身份发下去。 + for empty in ["", " "] { + let event = ThreadEvent::turn_started(1_000).with_user_item_id(Some(empty)); + assert_eq!(event.user_item_id(), None); + assert_eq!( + serde_json::to_value(&event).expect("serialize"), + json!({"type": "turn.started", "at": 1_000u64}) + ); + } + + // 旧事件(没有 `userItemId`)反序列化成 `None`,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "completed", + "at": 3_000u64, + })) + .expect("legacy turn.completed without userItemId"); + assert_eq!(legacy.user_item_id(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({"type": "turn.completed", "status": "completed", "at": 3_000u64}) + ); + + // 条目事件没有这个字段:身份只在生命周期事件上。 + let item_event = ThreadEvent::item_completed( + ThreadItem::CommandExecution { + item_id: "call-1".to_string(), + command: "ls".to_string(), + output: None, + status: None, + exit_code: None, + at: 1_500, + }, + 1_600, + ); + assert_eq!(item_event.user_item_id(), None); +} + +/// 失败终态:`status="failed"` 必须带 typed 的 `failure` 载荷,正常终态不带;载荷跟着身份 +/// 一起流转,缺载荷的 `failed` 事件仍能反序列化(前端按"没有原因"处理,不猜)。 +#[test] +fn turn_completed_carries_failure_payload_only_when_failed() { + let payload = TurnFailure::ModelCallFailed(crate::agent::ModelCallFailed { + kind: crate::agent::ModelCallKind::UpstreamFailed { + status_code: 500, + native: None, + }, + detail: "上游返回 500:模型服务暂不可用".to_string(), + }); + let failed = ThreadEvent::turn_completed_failed(payload.clone(), 4_000) + .with_user_item_id(Some("direct-codex:turn-1:user")); + assert_eq!(failed.failure(), Some(&payload)); + assert_eq!(failed.user_item_id(), Some("direct-codex:turn-1:user")); + assert_eq!( + serde_json::to_value(&failed).expect("serialize failed turn"), + json!({ + "type": "turn.completed", + "status": "failed", + "failure": { + "type": "modelCallFailed", + "kind": {"type": "upstreamFailed", "statusCode": 500, "native": null}, + "detail": "上游返回 500:模型服务暂不可用" + }, + "at": 4_000u64, + "userItemId": "direct-codex:turn-1:user", + }) + ); + assert_eq!( + serde_json::from_value::(serde_json::to_value(&failed).expect("serialize")) + .expect("round trip"), + failed + ); + + // 正常终态不带载荷,也不回写 `failure: null`。 + let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); + assert_eq!(completed.failure(), None); + assert_eq!( + serde_json::to_value(&completed).expect("serialize completed turn"), + json!({"type": "turn.completed", "status": "completed", "at": 5_000u64}) + ); + + // 精简 / 旧形状:`failed` 但没有载荷也要能反序列化。 + let sparse: ThreadEvent = serde_json::from_value(json!({ + "type": "turn.completed", + "status": "failed", + })) + .expect("failed turn without failure payload"); + assert_eq!(sparse.failure(), None); +} + +/// 待发消息离开队列:`reason` 是 typed 枚举(`cancelled` / `dispatched`),界面按取值分流, +/// 不解析字符串。`at` 缺省时反序列化仍是 `None`。 +#[test] +fn queue_removed_carries_a_typed_reason() { + let dispatched = + ThreadEvent::queue_removed("turn-1".to_string(), QueueRemovalReason::Dispatched, 2_000); + assert_eq!( + serde_json::to_value(&dispatched).expect("serialize queue.removed"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "dispatched", + "at": 2_000u64, + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&dispatched).expect("serialize") + ) + .expect("round trip"), + dispatched + ); + assert_eq!(dispatched.queue_client_turn_id(), Some("turn-1")); + assert_eq!( + dispatched.queue_removal_reason(), + Some(QueueRemovalReason::Dispatched) + ); + + // 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。 + serde_json::from_value::(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "timeout", + })) + .expect_err("unknown queue removal reason must fail closed"); + + // 没有 `at` 的老形状仍能反序列化,回写不补 `null`。 + let legacy: ThreadEvent = serde_json::from_value(json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + })) + .expect("queue.removed without at"); + assert_eq!(legacy.at(), None); + assert_eq!( + serde_json::to_value(legacy).expect("serialize legacy"), + json!({ + "type": "queue.removed", + "clientTurnId": "turn-1", + "reason": "cancelled", + }) + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs new file mode 100644 index 000000000..ca053d12f --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/thread_manager/wire/turn.rs @@ -0,0 +1,359 @@ +use super::failure::TurnFailure; +use super::items::{ThreadDeltaKind, ThreadItem, ThreadRequestKind}; +use crate::agent::UserItem; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +/// 一条待发消息离开队列的原因。 +/// +/// typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经离开队列并成为回合 +/// (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalReason { + /// 用户取消了这条待发消息。 + Cancelled, + /// 放行:这条待发消息已经离开队列,成为正在跑的那一轮。 + Dispatched, +} + +/// 取消一条待发消息的结果。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum QueueRemovalOutcome { + /// 已从队列移除。 + Removed, + /// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。 + AlreadyDispatched, + /// 队列里没有这个身份,也没有在跑的一轮是它。 + NotFound, +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +/// +/// 四种生命周期事件(`turn.started` / `turn.completed` / `item.started` / `item.completed`) +/// 额外带事件级 `at`:它是**该阶段本身**的发生时间(毫秒),不是条目展示时间。条目上的 +/// `item.at` 只说明"这条条目什么时候被看到",工具计时不得拿它当开始或完成边界。 +/// 条目阶段优先用通知层的毫秒字段(`startedAtMs` / `completedAtMs`),缺失才用宿主钟; +/// 回合阶段没有可用的毫秒上游字段(Turn 只有秒级 `startedAt` / `completedAt`),一律用宿主 +/// 在该阶段取的毫秒钟——见 `thread_turn_completed_at_ms` 的说明。 +/// `at` 在事件进入 Thread Manager 时就固定:重放(bootstrap / consume)必须沿用原值, +/// 不能在前端收到或重放时重新取当前时间。 +/// +/// `turn.started` / `turn.completed` 额外带可选的 `userItemId`:本轮开口用户条目的 **canonical +/// itemId**(与同轮那条用户条目事件同源,由宿主按 `clientTurnId` 现算,`direct-codex:{clientTurnId}:user`; +/// **不读盘回填**——开始事件发生在用户条目落盘之前,落盘本身也可能失败)。回合事件本身 +/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点 +/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明 +/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。 +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum ThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted { + /// 本轮开始的阶段时间(毫秒):**放行**那一刻的宿主毫秒钟(逻辑回合的起点,不是 + /// `turn/start` 的时刻)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId;缺失表示身份不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "turn.completed")] + TurnCompleted { + /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, + /// 此时必须带 `failure` 载荷。 + status: String, + /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + failure: Option, + /// 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + /// 本轮开口用户条目的 canonical itemId:与同一轮的 `turn.started` 同源;缺失表示不可证明。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + user_item_id: Option, + }, + #[serde(rename = "item.started")] + ItemStarted { + item: ThreadItem, + /// 条目开始执行的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.completed")] + ItemCompleted { + item: ThreadItem, + /// 条目结束的原生阶段时间(毫秒);缺失时是宿主观测到该阶段的时间。 + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: ThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: ThreadRequestKind, + #[serde(default)] + request_id: Option, + }, + /// 待发消息入队:数组顺序就是队首到队尾的顺序。 + /// + /// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者 + /// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。 + /// + /// 事件就是这条待发消息的**全部**事实:宿主不为它另存产物,prompt 与 canonical 形状都在放行时 + /// 从这条条目重投影。 + #[serde(rename = "queue.enqueued")] + QueueEnqueued { + /// 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。 + client_turn_id: String, + /// canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 + user_item: UserItem, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + creation_type: Option, + /// 入队那一刻的宿主毫秒钟。 + #[ts(as = "f64")] + at: u64, + }, + /// 待发消息离开队列:`reason` 是取消还是放行。 + #[serde(rename = "queue.removed")] + QueueRemoved { + client_turn_id: String, + reason: QueueRemovalReason, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional, as = "Option")] + at: Option, + }, +} + +impl ThreadEvent { + pub(crate) fn turn_started(at: u64) -> Self { + Self::TurnStarted { + at: Some(at), + user_item_id: None, + } + } + + pub(crate) fn turn_completed(status: String, at: u64) -> Self { + Self::TurnCompleted { + status, + failure: None, + at: Some(at), + user_item_id: None, + } + } + + /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 + pub(crate) fn turn_completed_failed(failure: TurnFailure, at: u64) -> Self { + Self::TurnCompleted { + status: "failed".to_string(), + failure: Some(failure), + at: Some(at), + user_item_id: None, + } + } + + /// 失败载荷:只有失败终态有。 + pub(crate) fn failure(&self) -> Option<&TurnFailure> { + match self { + Self::TurnCompleted { failure, .. } => failure.as_ref(), + _ => None, + } + } + + /// 附上本轮开口用户条目的 canonical itemId。 + /// + /// 只在构造之后补一次身份,避免 `turn.started` / `turn.completed` 的既有调用点(含各处兜底 + /// 终态)全部改签名。空串按缺失处理:宁可让前端隐藏未知用时,也不写一个假身份。 + pub(crate) fn with_user_item_id(self, user_item_id: Option<&str>) -> Self { + let user_item_id = user_item_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match self { + Self::TurnStarted { at, .. } => Self::TurnStarted { at, user_item_id }, + Self::TurnCompleted { + status, + failure, + at, + .. + } => Self::TurnCompleted { + status, + failure, + at, + user_item_id, + }, + other => other, + } + } + + /// 本轮开口用户条目的 canonical itemId:只有生命周期事件有,其余返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时重算——重放要用的就是原事件的身份。 + #[cfg(test)] + pub(crate) fn user_item_id(&self) -> Option<&str> { + match self { + Self::TurnStarted { user_item_id, .. } | Self::TurnCompleted { user_item_id, .. } => { + user_item_id.as_deref() + } + _ => None, + } + } + + pub(crate) fn item_started(item: ThreadItem, at: u64) -> Self { + Self::ItemStarted { item, at: Some(at) } + } + + pub(crate) fn item_completed(item: ThreadItem, at: u64) -> Self { + Self::ItemCompleted { item, at: Some(at) } + } + + pub(crate) fn item_delta(item_id: String, kind: ThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: ThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 待发消息入队事件。 + pub(crate) fn queue_enqueued( + client_turn_id: String, + user_item: UserItem, + creation_type: Option, + at: u64, + ) -> Self { + Self::QueueEnqueued { + client_turn_id, + user_item, + creation_type, + at, + } + } + + /// 待发消息离开队列事件。 + pub(crate) fn queue_removed( + client_turn_id: String, + reason: QueueRemovalReason, + at: u64, + ) -> Self { + Self::QueueRemoved { + client_turn_id, + reason, + at: Some(at), + } + } + + /// 这条事件属于哪条待发消息:只有队列事件有。 + pub(crate) fn queue_client_turn_id(&self) -> Option<&str> { + match self { + Self::QueueEnqueued { client_turn_id, .. } + | Self::QueueRemoved { client_turn_id, .. } => Some(client_turn_id), + _ => None, + } + } + + /// 待发消息离开队列的原因:只有 `queue.removed` 有。 + pub(crate) fn queue_removal_reason(&self) -> Option { + match self { + Self::QueueRemoved { reason, .. } => Some(*reason), + _ => None, + } + } + + /// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。 + /// + /// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。 + #[cfg(test)] + pub(crate) fn at(&self) -> Option { + match self { + Self::TurnStarted { at, .. } + | Self::TurnCompleted { at, .. } + | Self::ItemStarted { at, .. } + | Self::ItemCompleted { at, .. } => *at, + Self::QueueEnqueued { at, .. } => Some(*at), + Self::QueueRemoved { at, .. } => *at, + Self::ItemDelta { .. } | Self::Request { .. } => None, + } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct SubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct ConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) struct HistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 7450d5ad1..13fd24ccd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -781,7 +781,7 @@ pub(crate) async fn polish_local_project_prompt( pub(crate) fn cancel_direct_codex_turn( project_path: String, client_turn_id: Option, -) -> Result { +) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "agent.kill")?; cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index 42c8e687f..96d8b5c54 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -49,6 +49,7 @@ import { commitAuthenticatedPlatformSession, currentPlatformSessionGeneration, } from '../services/platformSession'; +import { expectNever } from './expectNever'; type ClientRuntimeErrorBoundaryProps = { children: ReactNode; @@ -73,14 +74,6 @@ const AUTH_CHECK_REQUEST_TIMEOUT_MS = 15_000; // 30s startup deadline. Keep the UI fence slightly above that worst case. const AUTH_CHECK_RUNNER_TIMEOUT_MS = 45_000; -/** - * 编译期穷尽检查:参数只能是 `never`,switch 漏掉任何一个变体这一行就编译不过。 - * 运行时不做事(`void`),所以 `default` 分支还要自己 `throw`。 - */ -function expectNever(value: never): void { - void value; -} - function withAuthCheckTimeout( promise: Promise, timeoutMs: number, diff --git a/apps/ai-game-creator-shell/src/app/expectNever.ts b/apps/ai-game-creator-shell/src/app/expectNever.ts new file mode 100644 index 000000000..ff92a6a0d --- /dev/null +++ b/apps/ai-game-creator-shell/src/app/expectNever.ts @@ -0,0 +1,10 @@ +/** + * 编译期穷尽检查:参数只能是 `never`,`switch` / `if` 链漏掉任何一个变体这一行就编译不过。 + * + * 运行时不做事(`void`),所以调用方的 `default` 分支仍需自己决定"漏网之鱼"怎么处理(通常 + * 是原样抛出交给既有捕获链路)。放在这里是因为它被认证命令与 Direct 命令两条链路共用,各自 + * 复制一份会在新增变体时漏掉其中一处。 + */ +export function expectNever(value: never): void { + void value; +} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 5d0c1e99a..1b370ca81 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -978,7 +978,7 @@ export interface GameCreatorDirectToolCall { } /** `cancel_direct_codex_turn` 的返回值。 */ -export interface DirectTurnCancelView { +export interface TurnCancelView { /** * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 6da0d8280..149e5f7f7 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -42,7 +42,7 @@ import { createPortal } from 'react-dom'; import RichTextInput from '../../components/RichTextInput'; import { captureClientError } from '../../services/errorReporting'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { chatPromptDraftKey, readChatPromptPolishReminderDisabled, @@ -81,7 +81,7 @@ const LEXICAL_EDITOR_CLIPBOARD_TYPE = 'application/x-lexical-editor'; type ResourceReferenceInputProps = { onChange?: (draft: ChatComposerDraft) => void; onEditorStateChange?: (editorState: EditorState) => void; - initialContent?: DirectCodexUserContentPart[]; + initialContent?: UserContentPart[]; /** * 宿主注入的引用来源,**按数组顺序取第一个非空回答**。 * @@ -153,7 +153,7 @@ class ReferenceMentionOption extends MenuOption { function collectDraftParts( node: LexicalNode, references: ChatReference[], - content: DirectCodexUserContentPart[], + content: UserContentPart[], ) { if ($isTextNode(node)) { const text = node.getTextContent(); @@ -184,13 +184,13 @@ function collectDraftParts( type DraftProjection = { references: ChatReference[]; - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; /** 仅供编辑器内部派生引用(重建文本草稿时用);对外只暴露 canonical content。 */ function readDraftProjectionFromNodes(): DraftProjection { const references: ChatReference[] = []; - const content: DirectCodexUserContentPart[] = []; + const content: UserContentPart[] = []; collectDraftParts($getRoot(), references, content); return { references: dedupeChatReferences(references), @@ -218,7 +218,7 @@ export function readResourceReferenceDraft( /** 第一个认得这个 part 的 provider 给出的引用;都不认得时返回 `null`。 */ function referenceFromPart( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): ChatReference | null { for (const provider of providers) { const reference = provider.toReference(part); @@ -230,7 +230,7 @@ function referenceFromPart( /** 第一个认得这个 part 的 provider 给出的正文 token;都不认得(例如文本 part)时返回 `null`。 */ function mentionTokenFromPart( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): string | null { for (const provider of providers) { const token = provider.mentionToken(part); @@ -248,7 +248,7 @@ function mentionTokenFromPart( */ function mentionTokenOrText( providers: readonly ReferenceProvider[], - part: DirectCodexUserContentPart, + part: UserContentPart, ): string | null { return mentionTokenFromPart(providers, part) ?? contentPartText(part); } @@ -261,7 +261,7 @@ function mentionTokenOrText( */ function promptTextFromContent( providers: readonly ReferenceProvider[], - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ): string { return joinMentionText(content, (part) => mentionTokenFromPart(providers, part), @@ -293,7 +293,7 @@ function applyPolishedTextToRoot( } function applyContentToRoot( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], providers: readonly ReferenceProvider[], ) { const root = $getRoot(); @@ -354,7 +354,7 @@ function $selectionOrRootEnd() { * 编辑器的默认粘贴,否则这段文字两边都不管。 */ function $insertContentAtSelection( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], providers: readonly ReferenceProvider[], ): boolean { // 每插一段都重新取一次选区:插入会移动光标,上一轮拿到的那个 RangeSelection 会过期。 @@ -493,12 +493,9 @@ function ResourceReferenceEditor({ [editor], ); - const applyContent = useCallback( - (content: readonly DirectCodexUserContentPart[]) => { - applyContentToRoot(content, providersRef.current); - }, - [], - ); + const applyContent = useCallback((content: readonly UserContentPart[]) => { + applyContentToRoot(content, providersRef.current); + }, []); useImperativeHandle( composerRef, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts index a35ca1a4c..2f77b58a9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/chatPromptPolish.ts @@ -1,5 +1,5 @@ import { resolveTauriInvoke } from '../../app/tauri'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { readWorkspacePreferences, setChatPromptPolishReminderDisabled as setChatPromptPolishReminderPreference, @@ -49,9 +49,7 @@ export function subscribeChatPromptPolishReminderDisabled( } /** 草稿指纹:用于判断「本轮草稿」是否已经被润色或确认过。 */ -export function chatPromptDraftKey( - content: readonly DirectCodexUserContentPart[], -) { +export function chatPromptDraftKey(content: readonly UserContentPart[]) { return JSON.stringify(content); } @@ -67,7 +65,7 @@ export function shouldRemindChatPromptPolish({ acknowledgedDraftKey, reminderDisabled, }: { - content: readonly DirectCodexUserContentPart[]; + content: readonly UserContentPart[]; prompt: string; acknowledgedDraftKey: string | null; reminderDisabled: boolean; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts index b0b9a0afb..27c0afc70 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/attachmentReferenceProvider.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { type ChatReference, normalizeMentionName, @@ -7,7 +7,7 @@ import type { ReferenceProvider } from './types'; /** canonical 附件 part → `ChatReference` 的附件成员(字段逐字对齐)。 */ export function attachmentReferenceFromPart( - part: DirectCodexUserContentPart & { type: 'agc_attachment_reference' }, + part: UserContentPart & { type: 'agc_attachment_reference' }, ): ChatReference { return { type: 'attachment', @@ -28,7 +28,7 @@ export function attachmentReferenceFromPart( export function createAttachmentReferenceProvider(): ReferenceProvider { return { trigger: null, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => + toReference: (part: UserContentPart): ChatReference | null => part.type === 'agc_attachment_reference' ? attachmentReferenceFromPart(part) : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts index 7f724c365..1e03fde19 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/resourceReferenceProvider.ts @@ -1,5 +1,5 @@ import type { GameCreationAppAssetManifestEntry } from '../../../../../../packages/shared/src/contracts/gameCreationApp'; -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { assetsSignature, type ChatReference, @@ -57,7 +57,7 @@ export function createResourceReferenceProvider({ .slice(0, MENTION_OPTION_LIMIT), // 精确查找用的全量候选:与菜单同一份「可提及」清单,只去掉模糊过滤与截断。 lookup: () => resourceProviderData(assets).references, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => { + toReference: (part: UserContentPart): ChatReference | null => { if (part.type !== 'agc_resource_reference') return null; const asset = resourceProviderData(assets).byId.get(part.resourceId); // 资产已不在 manifest(已删除):这里不合成引用,由调用方按同一份清单显式报缺口, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts index 1a662443a..080e3eb9d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/runtimeRegionReferenceProvider.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import type { ChatReference, RuntimeRegionReference, @@ -14,7 +14,7 @@ import type { ReferenceProvider } from './types'; export function createRuntimeRegionReferenceProvider(): ReferenceProvider { return { trigger: null, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => { + toReference: (part: UserContentPart): ChatReference | null => { if (part.type !== 'agc_runtime_region_reference') return null; return { type: 'runtime-region', diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts index e99648c42..a26721340 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/skillReferenceProvider.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../../app/tauri'; -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import { type ChatReference, normalizeMentionName, @@ -128,7 +128,7 @@ export function useSkillReferenceProvider(): ReferenceProvider { ) .slice(0, MENTION_OPTION_LIMIT); }, - toReference: (part: DirectCodexUserContentPart): ChatReference | null => + toReference: (part: UserContentPart): ChatReference | null => part.type === 'agc_skill_reference' ? { type: 'skill', name: normalizeMentionName(part.name) } : null, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts index bf78a4901..e65c04b1c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/reference-source/types.ts @@ -1,4 +1,4 @@ -import type { DirectCodexUserContentPart } from '../../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../../view/project-development/chat/generated/UserContentPart'; import type { ChatReference } from '../resourceReferences'; /** @@ -47,11 +47,11 @@ export type ReferenceProvider = { */ onMenuQueryChange?: (query: string | null) => void; /** canonical part → 引用;不属于本 provider 或暂时无法解析时返回 `null`。 */ - toReference: (part: DirectCodexUserContentPart) => ChatReference | null; + toReference: (part: UserContentPart) => ChatReference | null; /** 引用身份刷新(资源改名等);不属于本 provider 时返回 `null`,原样返回表示无需改写。 */ refresh: (reference: ChatReference) => ChatReference | null; /** part 在正文文本里的 token(`@显示名` / `$名称`);不属于本 provider 时返回 `null`。 */ - mentionToken: (part: DirectCodexUserContentPart) => string | null; + mentionToken: (part: UserContentPart) => string | null; /** * 数据是否已经到齐(例如 manifest 还没加载完时为 `false`);省略表示始终就绪。 * diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index adc78ecba..966f67a7a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -12,9 +12,9 @@ import { buildGameCreationAppAssetTagLibrary, type GameCreationAppAssetTagLibraryEntry, } from '../../../../../packages/shared/src/contracts/gameCreationAppAssetTagLibrary'; -import type { DirectCodexUserAttachmentReferencePart } from '../../view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; -import type { DirectCodexUserItem } from '../../view/project-development/chat/generated/DirectCodexUserItem'; +import type { UserAttachmentReferencePart } from '../../view/project-development/chat/generated/UserAttachmentReferencePart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; +import type { UserItem } from '../../view/project-development/chat/generated/UserItem'; export type ResourceReferenceSource = | 'asset-picker' @@ -61,7 +61,7 @@ export type SkillReference = { */ export type AttachmentReference = { type: 'attachment'; -} & DirectCodexUserAttachmentReferencePart; +} & UserAttachmentReferencePart; export type ChatReference = | ResourceReference @@ -71,7 +71,7 @@ export type ChatReference = export type ChatComposerDraft = { /** Lexical 节点按顺序投影出的 canonical user content,唯一事实源。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; /** 仅供仍未迁移的旧调用方消费的单向投影,不得回传给 ResourceReferenceInput。 */ @@ -183,8 +183,8 @@ export function normalizeMentionName(value: string) { * (`hasMeaningfulDirectCodexContent`)算「空」;需要首尾裁剪的调用方在自己的边界做。 */ export function joinMentionText( - content: readonly DirectCodexUserContentPart[], - tokenOf: (part: DirectCodexUserContentPart) => string | null, + content: readonly UserContentPart[], + tokenOf: (part: UserContentPart) => string | null, ): string { let text = ''; // 上一个 token 的后置空白尚未落地:下一个 part 决定它是被吃掉(本来就以空白开头) @@ -224,7 +224,7 @@ export function joinMentionText( * 这里的 token 拼接只是当前口径的落点。) */ export function directCodexContentToPromptText( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], resolveResourceLabel: ResourceLabelResolver, ) { return joinMentionText(content, (part) => @@ -240,7 +240,7 @@ export function directCodexContentToPromptText( * 什么时候需要留白由那里的上下文决定,token 的投影不替它决定。 */ function contentPartToken( - part: DirectCodexUserContentPart, + part: UserContentPart, resolveResourceLabel: ResourceLabelResolver, ): string | null { if (part.type === 'input_text') return null; @@ -261,15 +261,13 @@ function contentPartToken( * 只给「插入那一刻解析不出引用、也拿不到 provider 的 token」兜底:粘贴过来的字必须落下去, * 哪怕落成一段文本。它不参与解析,也不是解析依据——扫描一律用 `chatReferenceMentionToken`。 */ -export function contentPartText( - part: DirectCodexUserContentPart, -): string | null { +export function contentPartText(part: UserContentPart): string | null { return contentPartToken(part, () => undefined); } /** 只在整条 content 上判定有效性;单个纯空白文本 part 合法。 */ export function hasMeaningfulDirectCodexContent( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ) { return content.some( (part) => part.type !== 'input_text' || part.text.trim().length > 0, @@ -284,9 +282,9 @@ export function hasMeaningfulDirectCodexContent( * 无关化。逐字保留空白,不做裁剪。 */ export function directCodexContentKey( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], ): string { - const normalized: DirectCodexUserContentPart[] = []; + const normalized: UserContentPart[] = []; content.forEach((part) => { if (part.type !== 'input_text') { normalized.push(part); @@ -305,7 +303,7 @@ export function directCodexContentKey( export function chatReferenceToContentPart( reference: ChatReference, -): DirectCodexUserContentPart { +): UserContentPart { if (reference.type === 'resource') { return { type: 'agc_resource_reference', resourceId: reference.resourceId }; } @@ -365,12 +363,12 @@ export function chatReferenceDisplayHint(reference: ChatReference): string { /** 一条待恢复 part 与它在文本里的 token。 */ export type ContentTokenCandidate = { token: string; - part: DirectCodexUserContentPart; + part: UserContentPart; }; type ContentTokenSegment = | { kind: 'text'; text: string } - | { kind: 'part'; part: DirectCodexUserContentPart }; + | { kind: 'part'; part: UserContentPart }; function isMentionTokenBoundary(character: string | undefined) { return character === undefined || character === '' || /\s/u.test(character); @@ -408,7 +406,7 @@ function findMentionToken(line: string, token: string, from: number) { export function buildContentFromTextTokens( value: string, candidates: readonly ContentTokenCandidate[], -): DirectCodexUserContentPart[] { +): UserContentPart[] { const pending = candidates.map((candidate) => ({ ...candidate, used: false, @@ -454,7 +452,7 @@ export function buildContentFromTextTokens( lastLine.push(...orphans); } } - const content: DirectCodexUserContentPart[] = []; + const content: UserContentPart[] = []; lineSegments.forEach((segments, lineIndex) => { segments.forEach((segment) => { if (segment.kind === 'text') { @@ -509,7 +507,7 @@ function countMentionTokenOccurrences(value: string, token: string) { export function buildContentFromPastedText( value: string, references: readonly ChatReference[], -): DirectCodexUserContentPart[] | null { +): UserContentPart[] | null { const referencesByToken = new Map(); for (const reference of references) { const token = chatReferenceMentionToken(reference); @@ -552,7 +550,7 @@ export function buildContentFromPastedText( export function legacyContentDtoToContent(dto: { text: string; references: readonly ChatReference[]; -}): DirectCodexUserContentPart[] { +}): UserContentPart[] { return buildContentFromTextTokens(dto.text, [ ...dto.references.map((reference) => ({ token: chatReferenceMentionToken(reference), @@ -564,19 +562,19 @@ export function legacyContentDtoToContent(dto: { export function chatComposerDraftToDirectCodexUserItem( draft: ChatComposerDraft, id: string, -): DirectCodexUserItem { +): UserItem { return { type: 'message', role: 'user', content: draft.content, id, - } satisfies DirectCodexUserItem; + } satisfies UserItem; } export function directCodexUserItemFromContent( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], id: string, -): DirectCodexUserItem { +): UserItem { return chatComposerDraftToDirectCodexUserItem({ content: [...content] }, id); } @@ -609,7 +607,7 @@ function unresolvedResourceReference(resourceId: string): ResourceReference { * 仍然收 `assets`,只是把显示名展开交给 `resourceLabelResolver` 这一份注入的口径。 */ export function directCodexContentToLegacyContentDto( - content: readonly DirectCodexUserContentPart[], + content: readonly UserContentPart[], assets: readonly GameCreationAppAssetManifestEntry[], ): DirectCodexLegacyContentDto { const references: ChatReference[] = []; diff --git a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx index 70f91e38a..bd371362b 100644 --- a/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx +++ b/apps/ai-game-creator-shell/src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView.tsx @@ -20,7 +20,7 @@ import type { import { resolveEditorImageSizeLabel } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel'; import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../../../../src/services/image-editor/editorProjectClient'; import { ThemedModal } from '../../components/modal/ThemedModal'; -import type { DirectCodexUserContentPart } from '../../view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../view/project-development/chat/generated/UserContentPart'; import { resourceEditPromptMaxLength } from '../../view/project-development/resourceEditModel'; import { attachmentReferenceProvider } from '../project-workspace/reference-source/attachmentReferenceProvider'; import { createResourceReferenceProvider } from '../project-workspace/reference-source/resourceReferenceProvider'; @@ -65,7 +65,7 @@ export type ResourceCanvasAssetGenerationSubmitInput = { * 面板**不**在这里拆出 `prompt` / `references` 双轨——宿主在任务落账那一个出站边界上才用 * `directCodexContentToLegacyContentDto` 翻译出账本要的文本与参考身份。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; @@ -79,7 +79,7 @@ export type ResourceCanvasAssetGenerationSubmitInput = { * `prompt + references` 的 DTO。 */ export type ResourceCanvasAssetGenerationPanelDraft = { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; @@ -185,7 +185,7 @@ export function ResourceCanvasAssetGenerationPanelView({ * 都是它;提示词文本与引用列表只是渲染期的单向投影(下面的 * `directCodexContentToLegacyContentDto`),用来做字数、计数与失效参考的判据。 */ - const [content, setContent] = useState(() => + const [content, setContent] = useState(() => draft ? [...draft.content] : [], ); /** @@ -215,7 +215,7 @@ export function ResourceCanvasAssetGenerationPanelView({ * 取挂载那一帧的 `draft`:宿主重建重试草稿时用的就是账本里原始请求的输入。 */ const boundRequestRef = useRef<{ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx index e10829c5f..8a0387795 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/DirectProjectChatView.tsx @@ -33,7 +33,7 @@ import { type DirectProjectTurnInput, } from './conversation/directCodexConversation'; import { buildDirectChatTurns } from './conversation/directTurnPresentation'; -import type { DirectCodexUserContentPart } from './generated/DirectCodexUserContentPart'; +import type { UserContentPart } from './generated/UserContentPart'; /** * DirectProject 对外只有这一条命令式出口:工作台壳跑完运行/预览这类工作台级动作后, @@ -56,7 +56,7 @@ export type DirectProjectInitialTurn = { * 入口首轮需求的 canonical 内容:文本、`@` 素材引用和附件引用都内联在这一份 content * 里,和 Composer 提交的回合保持同一种形状。 */ - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; creationType?: DirectProjectTurnInput['creationType']; claimScope: string; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx index f151e8344..4092a9939 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/components/DirectProjectComposer/DirectProjectComposer.tsx @@ -24,7 +24,7 @@ import { import { assetsSignature } from '../../../../../features/project-workspace/resourceReferences'; import type { DirectCodexTurnAttachment } from '../../conversation/directCodexTurnAttachments'; import type { DirectPendingTurn } from '../../conversation/directPendingTurns'; -import type { DirectCodexUserContentPart } from '../../generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../../generated/UserContentPart'; import { ComposerAttachmentMenu, ComposerReasoningEffortSelect, @@ -34,9 +34,7 @@ import { } from './ComposerControls'; /** 草稿里已有的附件芯片数:附件上限按它算,不再另存一份待发列表。 */ -function draftAttachmentCount(draft: { - content: DirectCodexUserContentPart[]; -}) { +function draftAttachmentCount(draft: { content: UserContentPart[] }) { return draft.content.filter( (part) => part.type === 'agc_attachment_reference', ).length; @@ -100,7 +98,7 @@ export function DirectProjectComposer({ * 入队失败里用户自己能改的那些返回 `false`,草稿原样留在输入盒里,不让一条被拒的消息从 * 界面上凭空消失。 */ - onSubmit: (content: DirectCodexUserContentPart[]) => Promise; + onSubmit: (content: UserContentPart[]) => Promise; /** * 导入文件:`draftAttachmentCount` 是草稿里已有的附件芯片数(上限按它算)。 * 返回导入成功的附件——由这里插成正文芯片;失败的一条都不插入。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 8f97e9b3e..622268e8b 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -7,8 +7,8 @@ import { import { resolveTauriInvoke } from '../../../../app/tauri'; import type { ChatMessage, - DirectTurnCancelView, TauriInvoke, + TurnCancelView, } from '../../../../app/types'; import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; import { uploadLocalFilesAsAttachments } from '../../../../features/app-shell/useHomeProjectCreation'; @@ -23,19 +23,22 @@ import { directCodexConversationMessageId, directCodexPolicyRetryInput, type DirectProjectTurnInput, - directTurnEnqueueFailureNotice, - directTurnEnqueueFailureNoticeMessageId, - directTurnUnrecognizedEnqueueFailureNoticeText, - readDirectTurnEnqueueFailure, } from '../conversation/directCodexConversation'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, } from '../conversation/directCodexTurnAttachments'; -import type { DirectCodexUserContentPart } from '../generated/DirectCodexUserContentPart'; +import { + enqueueFailureNotice, + enqueueFailureNoticeMessageId, + EnqueueFailureWrapper, + enqueueSystemFailureNoticeText, + invokeTurnEnqueue, +} from '../conversation/directTurnEnqueueFailure'; import type { HistorySlice } from '../generated/HistorySlice'; import type { QueueRemovalOutcome } from '../generated/QueueRemovalOutcome'; import type { ThreadItem } from '../generated/ThreadItem'; +import type { UserContentPart } from '../generated/UserContentPart'; import { directHistoryAnchorGateToWaitFor } from '../history/directHistoryAnchorGate'; import { readDirectHistoryPages } from '../history/directHistoryPaging'; import { canStopDirectProjectTurn } from './useDirectProjectTurnStatus'; @@ -382,9 +385,7 @@ export function useDirectProjectChatController({ * 本文件不再有"回合运行中就先排队"的分支:队列在宿主侧,忙不忙都由宿主自己判(队满、重复入队 * 也由它回答)。返回值就是"草稿能不能清":入队失败里用户自己能改的那些返回 `false`,草稿留下。 */ - async function submit( - content: DirectCodexUserContentPart[], - ): Promise { + async function submit(content: UserContentPart[]): Promise { if (!hasMeaningfulDirectCodexContent(content)) return false; const clientTurnId = createDirectProjectTurnId(); const userItem = directCodexUserItemFromContent( @@ -476,69 +477,71 @@ export function useDirectProjectChatController({ // 这里的返回值只回答"草稿能不能清"——入队成立能清,用户自己能改的入队失败不能清。 let draftSafe = false; try { - await invoke('enqueue_direct_codex_turn', { + await invokeTurnEnqueue(invoke, { projectPath: nextProjectPath, clientTurnId: input.clientTurnId, userItem: input.userItem, - ...(input.creationType ? { creationType: input.creationType } : {}), + creationType: input.creationType, }); draftSafe = true; // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { // 命令的入队失败是**结构化的**:命令返回 `Ok` 只说明入队成立,所以这条 catch 从入队化之后 // 只剩"入队失败"一种输入(整轮结果由 `turn.completed` 事件回答,不再回到这里)。 - const rejection = readDirectTurnEnqueueFailure(error); - if (rejection) { - const notice = directTurnEnqueueFailureNotice(rejection); - if (notice) { - // 认得的前置条件 / 参数 / 队列已满类入队失败:写成与用户消息同级的提示,不占状态行、不写运行错误、 - // 也不上报(用户自己就能改,上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条,界面上 - // 那条同级提示说的是"为什么",用户改一改就能直接再按发送。 - if (projectPathRef.current === nextProjectPath) { - onRuntimeError(''); - appendLocalMessage({ - role: 'assistant', - text: notice, - runtimeOwned: true, - messageId: directTurnEnqueueFailureNoticeMessageId( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ), - updatedAt: Date.now(), - }); - } - return false; + // 分界只看 JS 载体(`instanceof`),不嗅探对象形状。 + const failure = + error instanceof EnqueueFailureWrapper ? error.error : null; + const notice = failure ? enqueueFailureNotice(failure) : null; + if (failure && notice) { + // 用户自己就能改的业务变体(前置条件 / 参数 / 队列已满 / 权限策略):写成与用户消息同级的提示, + // 不占状态行、不写运行错误、也不上报(上报只会变成噪声)。**草稿留着**——宿主拒绝了这一条, + // 提示说的是"为什么",用户改一改就能直接再按发送。 + if (projectPathRef.current === nextProjectPath) { + onRuntimeError(''); + appendLocalMessage({ + role: 'assistant', + text: notice, + runtimeOwned: true, + messageId: enqueueFailureNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); } + return false; } + // 宿主 / 环境事实变体的固定句;认不出形状(Tauri / 宿主缺陷、未来新变体)时没有这句话。 + const systemNotice = failure && enqueueSystemFailureNoticeText(failure); if (projectPathRef.current !== nextProjectPath) return true; - // 认不出的入队失败(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 + // 认不出的入队失败与其它非结构化错误走同一条通道:上报 + 横幅,**不写聊天**(说不清原因, + // 也不该把宿主原文回落给用户)。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 入队失败文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; - // 入队失败不带阶段标签(这一轮没有开始),所以走入队失败那一份映射。 - const rawMessage = error instanceof Error ? error.message : String(error); - const visibleMessage = rejection - ? directTurnUnrecognizedEnqueueFailureNoticeText(rejection) - : projectRuntimeVisibleError(rawMessage, '陶泥儿智能创作', true); - // 认不出的失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 + if (!systemNotice) { + onRuntimeError( + projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ), + ); + return true; + } + // 这类失败按"已交出"处理:它说不出用户改哪一处能变好(宿主 / 环境事实),留着草稿只会让 // 用户以为没发出去、再按一次。 draftSafe = true; // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 // 本轮最后一条条目(唯一来源)。**入队失败没有这条出口**——入队失败不产生回合事件,聊天里 // 那条乐观用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 - // 非结构化错误同样不写聊天:它可能发生在放行之后,说明由事件流负责。 - if (rejection) { - appendLocalMessage({ - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - messageId: directTurnEnqueueFailureNoticeMessageId( - directCodexConversationMessageId(input.clientTurnId, 'user'), - ), - updatedAt: Date.now(), - }); - } - // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 - // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 - onRuntimeError(visibleMessage); + appendLocalMessage({ + role: 'assistant', + text: systemNotice, + runtimeOwned: true, + messageId: enqueueFailureNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + onRuntimeError(systemNotice); } return draftSafe; } @@ -570,12 +573,9 @@ export function useDirectProjectChatController({ setTurnCancelling(true); setComposerNotice('正在终止当前回合'); try { - const result = await invoke( - 'cancel_direct_codex_turn', - { - projectPath, - }, - ); + const result = await invoke('cancel_direct_codex_turn', { + projectPath, + }); const message = result?.message?.trim(); if (result?.outcome === 'released') { // 本地只放掉"命令在飞"这一层;这一轮的**回合边界**不在这里收口——宿主的兜底终止 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index bb1231450..40b95d28a 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,8 +1,6 @@ import type { ChatMessage } from '../../../../app/types'; -import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; -import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; -import type { DirectTurnEnqueueFailure } from '../generated/DirectTurnEnqueueFailure'; +import type { UserItem } from '../generated/UserItem'; export const DIRECT_CODEX_AGENT_ID = 'direct-codex'; export const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; @@ -20,7 +18,7 @@ export type DirectProjectTurnInput = { * 这一轮的 canonical 用户条目:文本、`@` 素材引用、技能引用和附件引用都在同一份 * content 里内联,附件不再作为并排字段单独传递。 */ - userItem: DirectCodexUserItem; + userItem: UserItem; /** 本轮已经通过项目写权限检查:确认后重跑时不再二次确认。 */ directPolicyChecked?: boolean; }; @@ -61,80 +59,3 @@ export function directCodexConversationMessageId( ) { return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } - -/** - * 命令的**入队失败**载荷(Rust 侧 `DirectTurnEnqueueFailure`):结构化变体 + 宿主生成的文案。 - * - * `invoke` 拒绝时拿到的就是这份值(不是 `Error`)。这里只做一次形状读取,分流一律看 - * `error.type`——文案是给人看的,不参与任何判断。 - */ -export function readDirectTurnEnqueueFailure( - error: unknown, -): DirectTurnEnqueueFailure | null { - if (!error || typeof error !== 'object') return null; - const candidate = error as { error?: unknown; message?: unknown }; - const variant = candidate.error; - if (!variant || typeof variant !== 'object') return null; - const type = (variant as { type?: unknown }).type; - if (typeof type !== 'string' || !type) return null; - if (typeof candidate.message !== 'string') return null; - return candidate as DirectTurnEnqueueFailure; -} - -/** - * 认得的入队失败(前置条件不满足 / 用户参数无效 / 队列已满)→ 与用户消息同级的提示文案; - * 认不得的返回 `null`,由调用方原样抛出交给既有捕获链路(上报 + 横幅)。 - * - * 文案是宿主 `Display` 生成的**唯一一份**,界面原样显示:不套运行错误映射,也不在界面另写一份 - * ——那一套会把"聊天内容不能为空"这类前置条件压成"执行失败,请稍后重试"。 - * - * 名单只放"用户自己就能改、且不需要宿主诊断"的变体:`environmentNotReady` / - * `hostStateUnavailable` 这类是宿主 / 环境事实,必须走上报通道,所以不在这里。 - * - * 空文案按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`——宿主给不出可展示的文案 - * 时返回 `null`,而不是让调用方拿到一条空串(`''` 显示不出任何东西,却会被按 `!== null` 判据的 - * 调用方当成"有提示")。 - */ -export function directTurnEnqueueFailureNotice( - failure: DirectTurnEnqueueFailure, -): string | null { - switch (failure.error.type) { - case 'clientTurnIdMissing': - case 'clientTurnIdMalformed': - case 'queueFull': - case 'projectRootUnanchored': - case 'projectRootUnusable': - case 'permissionRejected': - case 'inputRejected': - case 'contentEmpty': - return failure.message.trim() || null; - default: - return null; - } -} - -/** - * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, - * 两条通道永远不会合并成一条。 - */ -export function directTurnEnqueueFailureNoticeMessageId(userItemId: string) { - return `${userItemId}:rejected`; -} - -/** - * **认不出的**入队失败(宿主 / 环境事实)在聊天区末尾自成一组提示的文案。 - * - * 这两类失败不产生 `turn.completed`(入队失败的这一轮从未成立),而本地也不再造用户气泡,所以 - * 这一轮在聊天区里本来什么都不剩——说明只能由命令边界补一条,否则用户只看得到一条会消失的横幅。 - * 它带自己的身份(`…:rejected`),投影据此自成一组,不挂进上一轮。上报与横幅照旧保留:两件事不是同一份 - * (一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 - * - * 文案不能原样用宿主给的 `message`:这类失败的 `message` 是宿主的收口文案(带 `stage=` / `code=` - * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, - * 绝不把内部字段塞进聊天。 - */ -export function directTurnUnrecognizedEnqueueFailureNoticeText( - failure: DirectTurnEnqueueFailure, -): string { - return projectRuntimeVisibleRejectionError(failure.message, '陶泥儿智能创作'); -} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts index 72fa4dfad..30e9bde82 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directPendingTurns.ts @@ -8,14 +8,14 @@ * * 列表顺序就是事件的到达顺序(FIFO,宿主侧的认领也认队首),所以这里没有排序、没有身份表。 */ -import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; +import type { UserItem } from '../generated/UserItem'; /** 一条还没放行的待发消息。 */ export type DirectPendingTurn = { /** 这条待发消息的回合身份;放行之后同一轮的 `turn.started` / `turn.completed` 用它。 */ clientTurnId: string; /** canonical 用户条目:chip 文案由它派生(与聊天正文同一个派生)。 */ - userItem: DirectCodexUserItem; + userItem: UserItem; /** 入队那一刻的宿主毫秒钟。 */ at: number; }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts index 1e799e3d3..1bb0c20d9 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directThreadChat.ts @@ -366,21 +366,18 @@ export function reduceDirectThreadEvent( return state; } // 失败终态带 `failure` 载荷:先把它落成本轮最后一条说明条目,再和正常终态走同一个收口 - // 函数。载荷在、原因非空才算一条说明;空原因不补一条空气泡(终态照样收口)。 + // 函数。载荷在才算一条说明(文案由 typed 变体拼,必非空);缺载荷不补一条空气泡。 const failure = event.failure; - // `message` 在生成类型里是必填 string,但跨 IPC 的载荷没有运行时校验:缺字段 / `null` - // 时直接 `.trim()` 会在 reducer 里抛错,把这一条订阅之后的全部事件一起打断。判据与兄弟 - // 函数 `directTurnFailureNoticeText` 保持一致,都是"不是非空字符串就当没有原因"。 - const failureText = - failure && typeof failure.message === 'string' - ? failure.message.trim() - : ''; + // 载荷在生成类型里是必填的,但跨 IPC 没有运行时校验:缺字段 / `null` 时当"没有原因"处理, + // 不在这里抛错——一条坏事件不该把这一条订阅之后的全部事件一起打断。文案只从 typed 变体拼, + // 宿主原文(`detail` / `cause` / `diagnostic`)不上屏。 + const failureText = failure ? directTurnFailureNoticeText(failure) : ''; const noticeItemId = directTurnFailureItemId(eventUserItemId, eventAt); const noticeOf = (): DirectChatEntry => ({ itemId: noticeItemId, kind: 'message', role: 'assistant', - text: directTurnFailureNoticeText(failureText), + text: failureText, at: eventAt, // 归属带上身份:本轮的开口用户条目可能还没到过界面(回合在宿主下发用户条目之前就失败、 // 或历史切片还没读回),那时只有身份能把这条说明归回自己那一轮,而不是按位置留给上一轮。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts new file mode 100644 index 000000000..1c0a0e74c --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnEnqueueFailure.ts @@ -0,0 +1,124 @@ +import { expectNever } from '../../../../app/expectNever'; +import type { TauriInvoke } from '../../../../app/types'; +import type { EnqueueError } from '../generated/EnqueueError'; + +/** 用户可见文案的主语:与既有运行错误横幅同一份口径。 */ +const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; + +/** + * 命令失败在 JS 侧的载体:`error` 就是 Rust 的结构化入队失败(ts-rs 生成的 + * `EnqueueError` 判别联合)。 + * + * 与认证命令的 `ClientAuthErrorWrapper` 同一套路:构造时把整份载荷 `JSON.stringify` 进 + * `Error.message`(结构化拒绝是普通对象,序列化后上报事件拿到的是机器事实,而不是 + * `[object Object]`),不读任何变体字段、不拼用户文案、不注入上报上下文。分流只看 + * `instanceof` 与 `error.type`,**不要用文案判断**。 + */ +export class EnqueueFailureWrapper extends Error { + readonly error: EnqueueError; + + constructor(error: EnqueueError) { + super(JSON.stringify(error)); + this.name = 'EnqueueFailureWrapper'; + this.error = error; + } +} + +/** + * 入队命令的统一入口:把 Tauri 的拒绝原样装进 `EnqueueFailureWrapper`。 + * + * **信任映射,不做运行时形状嗅探**:Rust 与 TS 同包发布,`enqueue_direct_codex_turn` 的拒绝 + * 就是 ts-rs 生成的判别联合;出现别的形状属于 Tauri / Rust 侧缺陷,调用方的 `instanceof` + * 分界会把真 `Error` 送回既有捕获链路。包装本身不读变体字段、不拼文案。 + */ +export async function invokeTurnEnqueue( + invoke: TauriInvoke, + input: { + projectPath: string; + clientTurnId: string; + userItem: unknown; + creationType?: string | null; + }, +): Promise { + try { + await invoke('enqueue_direct_codex_turn', { + projectPath: input.projectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + ...(input.creationType ? { creationType: input.creationType } : {}), + }); + } catch (error) { + throw new EnqueueFailureWrapper(error as EnqueueError); + } +} + +/** + * **用户自己就能改**的入队失败 → 与用户消息同级的提示文案;宿主 / 环境事实变体返回 `null`, + * 由调用方走上报通道。 + * + * 业务变体的句子与改造前宿主 `Display` 的输出逐字一致:文案换了生成方(Rust → 前端),不换 + * 用户看到的那句话。载荷字段(`minChars` / `maxChars` / `limit` / `policyDetail` / `detail`) + * 是拼句子的原料,判断"这是哪一类"只按变体。 + * + * 空 `policyDetail` / `detail` 按"没有提示"处理:这个函数要么给一条能显示的话,要么给 `null`。 + */ +export function enqueueFailureNotice(failure: EnqueueError): string | null { + switch (failure.type) { + case 'clientTurnIdMissing': + return 'Direct 客户端回合缺少稳定 clientTurnId,已拒绝创建可计费生成身份'; + case 'clientTurnIdMalformed': + return `clientTurnId 必须为 ${failure.minChars} 到 ${failure.maxChars} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字`; + case 'queueFull': + return `待发消息已达上限(最多 ${failure.limit} 条),请等前面几条发完再发送`; + case 'projectRootUnusable': + return '当前项目目录不存在或不是绝对路径'; + case 'permissionRejected': + return failure.policyDetail.trim() || null; + case 'inputRejected': + return failure.detail.trim() || null; + case 'contentEmpty': + return '聊天内容不能为空'; + // 宿主 / 环境事实:必须走上报通道,文案由 `enqueueSystemFailureNoticeText` 给固定句。 + case 'environmentNotReady': + case 'hostStateUnavailable': + return null; + default: { + expectNever(failure); + // 结构化映射外(Tauri 级 / 未来宿主的新变体):按"没有可展示的提示"处理,交给上报通道。 + return null; + } + } +} + +/** + * **宿主 / 环境事实**类入队失败在聊天区里的固定句;认不出形状时返回 `null`。 + * + * 这类失败的 `detail` 是宿主原文(`stage=` / `code=` / 路径这类机器字段都在里面),只用于 + * `.agent/runtime/errors` 与诊断,**不上屏**;所以这里只按变体给一句用户能看懂的话,不回落 + * 任何字段。 + * + * `null` 只给"根本不是这份结构化载荷"的抛出(Tauri / 宿主缺陷,或未来宿主新增的变体): + * 那时说不出用户改哪一处能变好,调用方只上报 + 横幅,不写聊天。 + */ +export function enqueueSystemFailureNoticeText( + failure: EnqueueError, +): string | null { + switch (failure.type) { + case 'environmentNotReady': + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试`; + case 'hostStateUnavailable': + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,这一轮没有开始;请稍后重试`; + // 业务变体由 `enqueueFailureNotice` 认领,这里不重复给句;认不出形状时同理。 + // 穷尽性由那个函数的 `default: expectNever` 保证——新增变体时那边会先编译失败。 + default: + return null; + } +} + +/** + * 入队失败提示在同一条用户消息里的展示身份:与失败说明(`:failure`)同一套派生规则但不同后缀, + * 两条通道永远不会合并成一条。 + */ +export function enqueueFailureNoticeMessageId(userItemId: string) { + return `${userItemId}:rejected`; +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index cccd824ba..90145da63 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -1,20 +1,127 @@ /** * 失败终态的展示口径:`turn.completed.failure` 载荷怎么变成聊天里那条说明。 * - * 只放两条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): + * 三条规则,别在这里做事件归并(那是 `directThreadChat.ts` 的事): * 1. 说明条目的展示身份怎么派生; - * 2. 事件里的原始原因怎么变成用户可见文案。 + * 2. 载荷里的 typed 事实怎么变成用户可见文案; + * 3. (没有第 3 条——失败原因的文案只从载荷变体拼,不回落宿主原文。) * - * 为什么值得单独一个文件:这两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) - * 区分开又保持可预期,文案映射要和运行错误横幅用同一份规则。把它们散在 reducer 里,读代码的人 - * 只能靠猜"这条失败说明是从哪冒出来的"。 + * 为什么值得单独一个文件:前两条是**跨侧约定**——身份要和前端自己造的说明(终止 / announce) + * 区分开又保持可预期;文案要按变体逐条写死,不能靠对宿主文本做子串匹配。 + * + * 载荷里的 `detail` / `cause` / `diagnostic` 是**宿主原文**(走 `stage=` / `exitStatus=` / + * 路径这类机器字段),只用于诊断与分流、**不上屏**:这里只读 `type` / `stage` / `deadline` / + * `kind` 这些枚举字段。 */ -import { projectRuntimeVisibleError } from '../../../../features/agent-runtime'; +import { expectNever } from '../../../../app/expectNever'; +import type { Deadline } from '../generated/Deadline'; +import type { FailureStage } from '../generated/FailureStage'; +import type { ModelCallKind } from '../generated/ModelCallKind'; +import type { NativeKind } from '../generated/NativeKind'; +import type { TurnFailure } from '../generated/TurnFailure'; + +/** 用户可见文案的主语:与运行错误横幅同一份口径。 */ +const DIRECT_TURN_SUBJECT = '陶泥儿智能创作'; /** 没有本轮开口条目身份时的兜底展示身份前缀(正常路径不会用到)。 */ const DIRECT_TURN_FAILURE_FALLBACK_ITEM_ID = 'direct-thread-turn-failure'; +/** 交付阶段标签:与诊断收口文案同一份标签,只有"回合失败"才带。 */ +const DIRECT_FAILURE_STAGE_LABELS: Record = { + 'art-preparation': '平台资源准备失败', + 'code-generation': '代码生成失败', + 'browser-validation': '真实试玩未通过', + 'version-registration': '项目版本登记失败', +}; + +/** + * app-server 原生分类的可见说明。 + * + * 与改造前 `projectRuntimeVisibleError` 里 `codex-app-server-error:` 那张字符串表同一份 + * 文案;改造后不再解析原因文本,直接按 typed 取值查表。新增原生分类时这里漏一条就会被 + * `expectNever` 拦下。 + */ +function directNativeKindText(kind: NativeKind): string { + switch (kind.type) { + case 'context-window-exceeded': + return '模型上下文已超限,请缩小任务范围后重试'; + case 'session-budget-exceeded': + return '本次会话预算已耗尽,请缩小任务范围或新建任务'; + case 'usage-limit-exceeded': + return '用量已达上限,请检查账户额度后重试'; + case 'request-too-large': + return '模型请求体过大,请减少参考图或上下文后重试'; + case 'stream-required': + return '本次执行需要流式回执,当前通道不支持;请重试'; + case 'cyber-policy': + return '安全策略拒绝了本次请求,请调整任务内容'; + case 'sandbox-error': + return '工作区隔离启动失败,请检查项目目录后重试'; + case 'thread-rollback-failed': + return '会话恢复失败,请新建任务后重试'; + case 'bad-request': + return '请求无效,请稍后重试'; + case 'unauthorized': + return '鉴权失败,请重新登录后重试'; + case 'active-turn-not-steerable': + return '当前任务无法追加指令,请等待结束后重试'; + case 'other': + return '未完成本次执行,请查看项目文件是否已修改后再重试'; + default: { + expectNever(kind); + return '未完成本次执行,请查看项目文件是否已修改后再重试'; + } + } +} + +/** 模型调用失败的可见说明:有原生分类看原生分类,没有就看平台层那一层的分类。 */ +function directModelCallText(kind: ModelCallKind): string { + switch (kind.type) { + case 'responseTimedOut': + return '等待模型回执超时,本轮未完成;请稍后重试'; + case 'connectionFailed': + return '执行通道未能建立或已断开,本轮未完成;请重试,若持续失败请检查项目诊断'; + case 'transportBroken': + case 'streamUnavailable': + return '执行通道中断,本轮未完成;请重试,若持续失败请检查项目诊断'; + case 'requestRejected': + return kind.native + ? directNativeKindText(kind.native) + : '智能创作请求无效,请稍后重试'; + case 'upstreamFailed': + return ( + (kind.native ? directNativeKindText(kind.native) : null) ?? + (kind.statusCode >= 500 + ? '智能服务暂时不可用,请稍后重试' + : '智能创作请求无效,请稍后重试') + ); + case 'paidCreditsInsufficient': + return '泥点余额不足,本轮游戏生成已中断。请充值后发送“继续”,系统会从当前项目进度接着完成。'; + case 'emptyResponse': + return '模型未返回内容,请重试;如持续失败请检查项目诊断'; + case 'payloadInvalid': + return '模型回执无法解析,请重试;如持续失败请检查项目诊断'; + default: { + expectNever(kind); + return '智能服务执行失败,请稍后重试'; + } + } +} + +function directTurnDeadlineText(deadline: Deadline): string { + switch (deadline) { + case 'response-idle': + return '等待模型执行回执超时,本轮未完成;请稍后重试'; + case 'turn-hard-limit': + return '本轮执行超出时间上限,已停止;请核对项目文件后重试'; + default: { + expectNever(deadline); + return '等待模型执行回执超时,本轮未完成;请稍后重试'; + } + } +} + /** * 失败说明条目的**展示身份**:本轮开口用户条目的 canonical identity + `:failure` 后缀。 * @@ -40,17 +147,41 @@ export function directTurnFailureItemId( } /** - * 失败原因的可见文案:与运行错误横幅共用同一份映射(`projectRuntimeVisibleError`)。 + * 失败原因的可见文案:按载荷变体逐条写死,不再把宿主原文送进字符串映射。 * - * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 - * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + * 改造前这里读的是宿主 `Display` 生成的整句原因,再靠 `projectRuntimeVisibleError` 做子串匹配; + * 宿主换一句事实句而前端没跟着加模式时,用户拿到的就是通用兜底。现在载荷直接给 typed 事实, + * 文案由前端在这里按变体拼:**新增变体 / 新增原生分类时这里漏一条,`expectNever` 会让编译失败**。 * - * **不加模式就只会看到通用文案**:`projectRuntimeVisibleError` 只认它自己那份模式表,宿主换一句 - * 新的 `Display` 事实句而这边没跟着加模式时,用户拿到的就是"…执行失败,请稍后重试"。这是有意的 - * 取舍——宁可给通用文案,也不回落宿主原文(原文可能带 `exitStatus=` / `stderrClass=` 这类内部 - * 字段)。加了新模式就补一条 `agentRuntimeModel.test.ts` 的用例。 + * `detail` / `cause` / `diagnostic` 一律不上屏:它们是宿主原文,可能带 `exitStatus=` / + * `stderrClass=` 这类内部字段,只用于 `.agent/runtime/errors` 与诊断。 */ -export function directTurnFailureNoticeText(message: string): string { - const raw = typeof message === 'string' ? message.trim() : ''; - return projectRuntimeVisibleError(raw, '陶泥儿智能创作', true); +export function directTurnFailureNoticeText(failure: TurnFailure): string { + switch (failure.type) { + case 'projectRootUnanchored': + return `${DIRECT_TURN_SUBJECT}:无法确定项目目录,本轮未能开始;请检查项目路径后重试`; + case 'environmentNotReady': + return `${DIRECT_TURN_SUBJECT}:当前环境未就绪,本轮未完成;请检查本机运行环境后重试`; + case 'hostStateUnavailable': + return `${DIRECT_TURN_SUBJECT}:宿主执行状态不可用,本轮未完成;请稍后重试`; + case 'modelCallFailed': + return `${DIRECT_TURN_SUBJECT} ${directModelCallText(failure.kind)}`; + case 'transportClosed': + return `${DIRECT_TURN_SUBJECT} 服务连接已断开,请稍后重试`; + case 'timedOut': + return `${DIRECT_TURN_SUBJECT} ${directTurnDeadlineText(failure.deadline)}`; + case 'turnInterrupted': + return `${DIRECT_TURN_SUBJECT} 本轮执行被中断,请重试`; + case 'turnFailed': + return `${DIRECT_TURN_SUBJECT}:${DIRECT_FAILURE_STAGE_LABELS[failure.stage]},请检查项目诊断后重试`; + case 'turnFailedUnclassified': + return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + case 'hostDropped': + return '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。'; + default: { + expectNever(failure); + // 结构化映射外(未来宿主的新变体):给一句通用话,绝不能把原文回落给用户。 + return `${DIRECT_TURN_SUBJECT} 执行失败,请稍后重试`; + } + } } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts new file mode 100644 index 000000000..7a09357f8 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ClientTurnIdMalformed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `clientTurnId` 的长度约束:上下限随载荷带出去,界面不自己数一份。 + */ +export type ClientTurnIdMalformed = { minChars: number; maxChars: number }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts similarity index 80% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts index 83f2fe79b..25e71989c 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnDeadline.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/Deadline.ts @@ -5,4 +5,4 @@ * * 跟着入队失败 / 回合失败载荷一起给前端,界面不靠文案区分这两条。 */ -export type DirectTurnDeadline = 'response-idle' | 'turn-hard-limit'; +export type Deadline = 'response-idle' | 'turn-hard-limit'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageEnvelope.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts deleted file mode 100644 index a1101c8cb..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserMessageItem.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; -import type { DirectCodexUserRole } from './DirectCodexUserRole'; - -export type DirectCodexUserMessageItem = { - role: DirectCodexUserRole; - content: Array; - id: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts deleted file mode 100644 index 3e3496ab8..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnEnqueueFailure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectTurnError } from './DirectTurnError'; - -/** - * 入队失败载荷:命令边界交给前端的**结构化入队失败**。 - * - * 为什么不是只给一句话:界面要按变体分流——认得的"前置条件不满足 / 用户参数无效"给一条与用户 - * 消息同级的提示且不上报,认不得的原样抛出交给既有捕获链路。文案只是给人看的最后一步,仍由 - * `Display` 在这一处生成一次,前端不拼文案、不改写任何字段。 - */ -export type DirectTurnEnqueueFailure = { - /** - * 结构化变体:界面按 `error.type` 分流,不解析文案。 - */ - error: DirectTurnError; - /** - * 可展示文案(`Display` 的唯一出口)。 - */ - message: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts deleted file mode 100644 index 22c445d40..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnError.ts +++ /dev/null @@ -1,27 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexFailureStage } from './DirectCodexFailureStage'; -import type { DirectModelCallKind } from './DirectModelCallKind'; -import type { DirectTurnDeadline } from './DirectTurnDeadline'; - -/** - * 变体名就是线上的分流键(`type`):前端只按它选通道,不解析任何文案。 - */ -export type DirectTurnError = - | { type: 'clientTurnIdMissing' } - | { type: 'clientTurnIdMalformed'; minChars: number; maxChars: number } - | { type: 'queueFull'; limit: number } - | { type: 'projectRootUnanchored'; cause: string } - | { type: 'projectRootUnusable' } - | { type: 'permissionRejected'; policyDetail: string } - | { type: 'inputRejected'; detail: string } - | { type: 'contentEmpty' } - | { type: 'environmentNotReady'; detail: string } - | { type: 'hostStateUnavailable'; detail: string } - | { type: 'modelCallFailed'; kind: DirectModelCallKind; detail: string } - | { type: 'transportClosed'; diagnostic: string } - | { type: 'timedOut'; deadline: DirectTurnDeadline } - | { type: 'turnInterrupted'; detail: string } - | { type: 'reviewRequired'; detail: string } - | { type: 'repairRequired'; detail: string } - | { type: 'turnFailed'; stage: DirectCodexFailureStage; detail: string } - | { type: 'turnFailedUnclassified'; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts deleted file mode 100644 index 5b343123c..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailure.ts +++ /dev/null @@ -1,20 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectTurnFailureKind } from './DirectTurnFailureKind'; - -/** - * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 - * - * `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的 - * 可展示原因——失败原因只走这一条通道,前端不再从命令返回或另一条 IPC 里另造文案。 - */ -export type DirectTurnFailure = { - /** - * 稳定失败分类;取值表就是 [`DirectTurnFailureKind`],投影只走 - * [`DirectTurnError::wire_kind`]。 - */ - kind: DirectTurnFailureKind; - /** - * 脱敏 + 截断后的失败原因。 - */ - message: string; -}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts deleted file mode 100644 index 11275386d..000000000 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectTurnFailureKind.ts +++ /dev/null @@ -1,17 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * 失败载荷 `DirectTurnFailure.kind` 的唯一取值表。 - * - * 只给界面选语气,不参与流程分支(宿主与前端两侧都不得按它分流);载荷里的 `kind` 只能从这里 - * 投影(见 [`DirectTurnError::wire_kind`]),别在别处再拼字符串。线上取值由 `kebab-case` 给出, - * 枚举成员名与线上取值一一对应,改名即改协议。 - */ -export type DirectTurnFailureKind = - | 'timeout' - | 'model-failed' - | 'transport-failed' - | 'request-rejected' - | 'environment-not-ready' - | 'turn-interrupted' - | 'host-dropped'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts new file mode 100644 index 000000000..011bcd996 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnqueueError.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientTurnIdMalformed } from './ClientTurnIdMalformed'; +import type { EnvironmentNotReady } from './EnvironmentNotReady'; +import type { HostStateUnavailable } from './HostStateUnavailable'; +import type { InputRejected } from './InputRejected'; +import type { PermissionRejected } from './PermissionRejected'; +import type { QueueFull } from './QueueFull'; + +/** + * 命令边界的 typed 入队失败:变体名就是线上的分流键(`type`),前端只按它选通道,不解析文案。 + * + * 可达集合由 `direct_runtime::user_input::enqueue_direct_codex_turn_typed` 固定: + * `clientTurnId` 校验 → 工作流恢复 → 用户条目冻结 → 前置条件 → 容量预判 → 工程准备 → 入队。 + * 放行之后发生的失败不在这张表里(见 [`TurnError`])。 + */ +export type EnqueueError = + | { type: 'clientTurnIdMissing' } + | ({ type: 'clientTurnIdMalformed' } & ClientTurnIdMalformed) + | ({ type: 'queueFull' } & QueueFull) + | { type: 'projectRootUnusable' } + | ({ type: 'permissionRejected' } & PermissionRejected) + | ({ type: 'inputRejected' } & InputRejected) + | { type: 'contentEmpty' } + | ({ type: 'environmentNotReady' } & EnvironmentNotReady) + | ({ type: 'hostStateUnavailable' } & HostStateUnavailable); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts new file mode 100644 index 000000000..176eeee06 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/EnvironmentNotReady.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 环境 / 凭据 / 脚手架未就绪的原始解释。 + * + * **两张表共用**:入队侧是入队失败,回合侧是回合失败。 + */ +export type EnvironmentNotReady = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts similarity index 92% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts index a7040d769..2a534a956 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexFailureStage.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/FailureStage.ts @@ -5,7 +5,7 @@ * * 线上取值跟着入队失败 / 回合失败载荷一起给前端(`art-preparation` 这类),所以也要导出。 */ -export type DirectCodexFailureStage = +export type FailureStage = | 'art-preparation' | 'code-generation' | 'browser-validation' diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts new file mode 100644 index 000000000..a7204cb86 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/HostStateUnavailable.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 宿主执行账本取不到的原始解释。 + */ +export type HostStateUnavailable = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts new file mode 100644 index 000000000..ddb603f08 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/InputRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 用户条目 / 创建类型不合法的原始解释。 + */ +export type InputRejected = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts new file mode 100644 index 000000000..0665facb6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallFailed.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelCallKind } from './ModelCallKind'; + +/** + * 模型调用失败的分类与平台层原文。 + */ +export type ModelCallFailed = { kind: ModelCallKind; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts similarity index 51% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts index 1c39e2058..da4bdc291 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectModelCallKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ModelCallKind.ts @@ -1,24 +1,19 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexNativeKind } from './DirectCodexNativeKind'; +import type { NativeKind } from './NativeKind'; /** * 模型调用失败(app-server 一次 `turn` 的结果)的分类,跟着入队失败 / 回合失败载荷一起给前端。 * - * 每个变体对应平台层 `LlmError` 的一个分支,于是 [`DirectTurnError::wire_kind`] 的取值与改造前 - * 完全一致:事件的 `failure.kind` 就是这一份取值,界面按它选语气,不拿它做流程分支。 + * 每个变体对应平台层 `LlmError` 的一个分支;前端拿它选语气,不拿它做流程分支。 * `native` 字段是原因文本里带出来的原生分类:有它时决策看原生分类,没有时看这个变体本身。 */ -export type DirectModelCallKind = +export type ModelCallKind = | { type: 'responseTimedOut'; attempts: number } | { type: 'connectionFailed'; attempts: number } | { type: 'transportBroken' } | { type: 'streamUnavailable' } - | { type: 'requestRejected'; native: DirectCodexNativeKind | null } - | { - type: 'upstreamFailed'; - statusCode: number; - native: DirectCodexNativeKind | null; - } + | { type: 'requestRejected'; native: NativeKind | null } + | { type: 'upstreamFailed'; statusCode: number; native: NativeKind | null } | { type: 'paidCreditsInsufficient' } | { type: 'emptyResponse' } - | { type: 'payloadInvalid'; native: DirectCodexNativeKind | null }; + | { type: 'payloadInvalid'; native: NativeKind | null }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts similarity index 96% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts index 1347200b1..97849af52 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexNativeKind.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/NativeKind.ts @@ -9,7 +9,7 @@ * * 线上取值只给界面选语气用,前端不得拿它做流程分支。 */ -export type DirectCodexNativeKind = +export type NativeKind = | { type: 'context-window-exceeded' } | { type: 'session-budget-exceeded' } | { type: 'usage-limit-exceeded' } diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts new file mode 100644 index 000000000..9d69d6a08 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/PermissionRejected.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 项目权限策略的原文(带被拒的点位)。 + */ +export type PermissionRejected = { policyDetail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts new file mode 100644 index 000000000..7820a1528 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ProjectRootUnanchored.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 项目目录锚不定的原始原因。 + */ +export type ProjectRootUnanchored = { cause: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts new file mode 100644 index 000000000..59eea063e --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/QueueFull.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 待发消息队列上限:只落在宿主这一处(`MAX_PENDING_TURNS`)。 + */ +export type QueueFull = { limit: number }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts index 184fe739b..40ce33131 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts @@ -1,10 +1,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserItem } from './DirectCodexUserItem'; -import type { DirectTurnFailure } from './DirectTurnFailure'; import type { QueueRemovalReason } from './QueueRemovalReason'; import type { ThreadDeltaKind } from './ThreadDeltaKind'; import type { ThreadItem } from './ThreadItem'; import type { ThreadRequestKind } from './ThreadRequestKind'; +import type { TurnFailure } from './TurnFailure'; +import type { UserItem } from './UserItem'; /** * Thread Manager 下发的运行态事件。 @@ -54,7 +54,7 @@ export type ThreadEvent = /** * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 */ - failure?: DirectTurnFailure; + failure?: TurnFailure; /** * 本轮终态的阶段时间(毫秒):宿主写下终态的毫秒钟,或 `durationMs` + 高精度起点的派生值。 */ @@ -91,7 +91,7 @@ export type ThreadEvent = /** * canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。 */ - userItem: DirectCodexUserItem; + userItem: UserItem; creationType?: string; /** * 入队那一刻的宿主毫秒钟。 diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts new file mode 100644 index 000000000..08e1e9c8f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TimedOut.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Deadline } from './Deadline'; + +/** + * 撞上的那条上限。 + */ +export type TimedOut = { deadline: Deadline }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts new file mode 100644 index 000000000..4320e7c5f --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TransportClosed.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 通道断开时那份宿主诊断(进程退出 / stderr 摘要)。 + */ +export type TransportClosed = { diagnostic: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailed.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailed.ts new file mode 100644 index 000000000..74cc50464 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailed.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FailureStage } from './FailureStage'; + +/** + * 已写成诊断记录的回合失败:阶段 + 诊断正文。 + */ +export type TurnFailed = { stage: FailureStage; detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailedUnclassified.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailedUnclassified.ts new file mode 100644 index 000000000..475bcafe9 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailedUnclassified.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 深层尚未 typed 的字符串错误。 + */ +export type TurnFailedUnclassified = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts new file mode 100644 index 000000000..2bbc47f29 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnFailure.ts @@ -0,0 +1,32 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { EnvironmentNotReady } from './EnvironmentNotReady'; +import type { HostStateUnavailable } from './HostStateUnavailable'; +import type { ModelCallFailed } from './ModelCallFailed'; +import type { ProjectRootUnanchored } from './ProjectRootUnanchored'; +import type { TimedOut } from './TimedOut'; +import type { TransportClosed } from './TransportClosed'; +import type { TurnFailed } from './TurnFailed'; +import type { TurnFailedUnclassified } from './TurnFailedUnclassified'; +import type { TurnInterrupted } from './TurnInterrupted'; + +/** + * 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。 + * + * 载荷直接携带**typed 变体**:没有 `kind` 粗分类、也没有预拼的 `message`。前端按变体选语气、 + * 按变体拼文案;宿主原始事实(`detail` / `cause` / `diagnostic`)留在字段里,只用于分流与诊断、 + * 不直接上屏。 + * + * 唯一投影点是 [`crate::agent::TurnError::terminal_failure`]:控制流(返修要求)返回 + * `None`,所以控制流既不会出现在这里,前端也不需要为它写分支。 + */ +export type TurnFailure = + | ({ type: 'projectRootUnanchored' } & ProjectRootUnanchored) + | ({ type: 'environmentNotReady' } & EnvironmentNotReady) + | ({ type: 'hostStateUnavailable' } & HostStateUnavailable) + | ({ type: 'modelCallFailed' } & ModelCallFailed) + | ({ type: 'transportClosed' } & TransportClosed) + | ({ type: 'timedOut' } & TimedOut) + | ({ type: 'turnInterrupted' } & TurnInterrupted) + | ({ type: 'turnFailed' } & TurnFailed) + | ({ type: 'turnFailedUnclassified' } & TurnFailedUnclassified) + | { type: 'hostDropped' }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts new file mode 100644 index 000000000..e48e70469 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnInterrupted.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * app-server 判成中断的原始说明。 + */ +export type TurnInterrupted = { detail: string }; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts similarity index 78% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts index 7394ae07d..07af7b9c6 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserAttachmentReferencePart.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserAttachmentReferencePart.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DirectCodexUserAttachmentReferencePart = { +export type UserAttachmentReferencePart = { name: string; mediaType: string; size: number; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts similarity index 61% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts index d92fdb7fa..67bbed055 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserContentPart.ts @@ -1,8 +1,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserAttachmentReferencePart } from './DirectCodexUserAttachmentReferencePart'; -import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +import type { UserAttachmentReferencePart } from './UserAttachmentReferencePart'; +import type { UserRuntimeRegionPart } from './UserRuntimeRegionPart'; -export type DirectCodexUserContentPart = +export type UserContentPart = | { type: 'input_text'; text: string } | { type: 'agc_resource_reference'; @@ -15,9 +15,5 @@ export type DirectCodexUserContentPart = resolvedText?: string | null; } | { type: 'agc_skill_reference'; name: string } - | ({ - type: 'agc_runtime_region_reference'; - } & DirectCodexUserRuntimeRegionPart) - | ({ - type: 'agc_attachment_reference'; - } & DirectCodexUserAttachmentReferencePart); + | ({ type: 'agc_runtime_region_reference' } & UserRuntimeRegionPart) + | ({ type: 'agc_attachment_reference' } & UserAttachmentReferencePart); diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts similarity index 52% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts index aa1ee88dd..d5d9664ea 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserItem.ts @@ -1,9 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; +import type { UserMessageItem } from './UserMessageItem'; /** * DirectProject 本轮 user input 的唯一结构化入口。 */ -export type DirectCodexUserItem = { - type: 'message'; -} & DirectCodexUserMessageItem; +export type UserItem = { type: 'message' } & UserMessageItem; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts new file mode 100644 index 000000000..945342a78 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserMessageItem.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UserContentPart } from './UserContentPart'; +import type { UserRole } from './UserRole'; + +export type UserMessageItem = { + role: UserRole; + content: Array; + id: string; +}; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts similarity index 72% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts index 0c2540122..06205c566 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRole.ts @@ -1,3 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DirectCodexUserRole = 'user'; +export type UserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts similarity index 87% rename from apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts rename to apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts index b134fb7bc..f4316ad4f 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/UserRuntimeRegionPart.ts @@ -1,6 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type DirectCodexUserRuntimeRegionPart = { +export type UserRuntimeRegionPart = { label: string; runId: string | null; versionId: string | null; diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index a430609b4..078dc0547 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -378,10 +378,8 @@ export function registerChatComposerControlTests() { // 提示,只把 typed 入队失败显示出来——**草稿留着**,用户等前面几条发完就能直接再按发送。 const { invoke, surface } = await openDirectCodexSurface({ enqueue_direct_codex_turn: () => { - throw { - error: { type: 'queueFull', limit: 5 }, - message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - }; + // 命令拒绝就是 Rust 的结构化入队失败本身(JS 侧只包一层载体)。 + throw { type: 'queueFull', limit: 5 }; }, }); const composer = within(surface).getByLabelText('陶泥儿对话内容'); @@ -537,12 +535,8 @@ export function registerChatComposerControlTests() { const { surface } = await openDirectCodexSurface({ enqueue_direct_codex_turn: () => { throw { - error: { - type: 'environmentNotReady', - detail: 'Codex app-server 启动失败:找不到可执行文件', - }, - message: - 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', }; }, }); @@ -552,11 +546,14 @@ export function registerChatComposerControlTests() { await waitFor(() => { expect( within(conversation).getByText( - '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', ), ).not.toBeNull(); }); - // 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。 + // 宿主原文(`detail` 里的可执行文件路径、机器字段)只留在诊断里,不进聊天。 + expect(conversation.textContent ?? '').not.toContain( + 'Codex app-server 启动失败', + ); expect(conversation.textContent ?? '').not.toContain( 'direct-codex-failure', ); @@ -767,8 +764,12 @@ export function registerChatComposerControlTests() { type: 'turn.completed', status: 'failed', failure: { - kind: 'request-rejected', - message: 'codex-app-server-error:context-window-exceeded', + type: 'modelCallFailed', + kind: { + type: 'requestRejected', + native: { type: 'context-window-exceeded' }, + }, + detail: 'codex-app-server-error:context-window-exceeded', }, at: 6_000, userItemId, @@ -830,8 +831,8 @@ export function registerChatComposerControlTests() { type: 'turn.completed', status: 'failed', failure: { - kind: 'environment-not-ready', - message: '写入本项目对话历史失败:项目对话历史追加写失败', + type: 'environmentNotReady', + detail: '写入本项目对话历史失败:项目对话历史追加写失败', }, at: 5_100, userItemId, @@ -848,11 +849,11 @@ export function registerChatComposerControlTests() { const composer = within(surface).getByLabelText('陶泥儿对话内容'); await submitDirectTurn(surface, composer, '落盘失败的那条'); const conversation = await within(surface).findByLabelText('陶泥儿消息'); - // 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。 + // 说明来自 `turn.completed.failure`,按载荷变体拼;命令返回 `Ok` 不再写第二条。 await waitFor(() => { expect( within(conversation).getAllByText( - '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + '陶泥儿智能创作:当前环境未就绪,本轮未完成;请检查本机运行环境后重试', ), ).toHaveLength(1); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 3f5131976..e7c72556e 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -2544,7 +2544,6 @@ export function registerHomeProjectCreationTests() { 'failed-direct-project', '直连失败项目', ); - const persistedMessages: Array> = []; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'get_local_game_manifest') { @@ -2553,11 +2552,7 @@ export function registerHomeProjectCreationTests() { } if (command === 'read_direct_project_history_slice') { expect(args).toEqual({ projectPath, limit: 20 }); - return { - items: [...persistedMessages], - hasMore: false, - firstItemId: null, - }; + return { items: [], hasMore: false, firstItemId: null }; } if (command === 'append_local_permission_log') { return {}; @@ -2580,24 +2575,12 @@ export function registerHomeProjectCreationTests() { content: [{ type: 'input_text', text: '生成一个游戏' }], }, }); - // Rust 落盘的原始条目在回读时已经是投影后的形状:身份只有一个 itemId。 - persistedMessages.push( - { - itemType: 'message', - itemId: `direct-codex:${clientTurnId}:user`, - role: 'user', - text: '生成一个游戏', - at: 9_000, - }, - { - itemType: 'message', - itemId: `direct-codex:${clientTurnId}:assistant`, - role: 'assistant', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - at: 9_001, - }, - ); - throw new Error('codex-app-server-error:unauthorized'); + // 命令拒绝就是 Rust 的结构化入队失败本身(JS 侧只包一层载体):不再有字符串错误、 + // 也不再有前端对错误文本做子串映射。 + throw { + type: 'permissionRejected', + policyDetail: '项目权限策略拒绝了本次调用', + }; } throw new Error(`unexpected invoke ${command}`); }, @@ -2612,48 +2595,19 @@ export function registerHomeProjectCreationTests() { }), ); + // 拒绝原因由前端按 typed 变体拼成与用户消息同级的提示;宿主原文(`type=` 这类机器字段) + // 不上屏,也不落成伪造的历史条目。 expect( - await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), - ).not.toBeNull(); - await waitFor(() => { - expect(persistedMessages).toHaveLength(2); - }); - expect(persistedMessages).toEqual([ - { - itemType: 'message', - itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), - role: 'user', - text: '生成一个游戏', - at: 9_000, - }, - { - itemType: 'message', - itemId: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:assistant$/), - role: 'assistant', - text: '陶泥儿智能创作 鉴权失败,请重新登录后重试', - at: 9_001, - }, - ]); - expect(JSON.stringify(persistedMessages)).not.toContain( - 'codex-app-server-error:unauthorized', - ); - cleanup(); - - render( - React.createElement(App, { - initialProjectPath: projectPath, - initialProjectManifest: manifest, - }), - ); - expect(await screen.findByText('生成一个游戏')).not.toBeNull(); - expect( - await screen.findAllByText('陶泥儿智能创作 鉴权失败,请重新登录后重试'), + await screen.findAllByText('项目权限策略拒绝了本次调用'), ).not.toBeNull(); + expect(screen.queryAllByText(/permissionRejected/)).toHaveLength(0); + // 入队没成立:只发一次命令,失败之后不自动重试、也不重放。 expect( invoke.mock.calls.filter( ([command]) => command === 'enqueue_direct_codex_turn', ), ).toHaveLength(1); + cleanup(); }); } diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index 18ce02538..0b0c0e024 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -1,10 +1,10 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import { directCodexPolicyRetryInput } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { - directCodexPolicyRetryInput, - directTurnEnqueueFailureNotice, - directTurnUnrecognizedEnqueueFailureNoticeText, - readDirectTurnEnqueueFailure, -} from '../../src/view/project-development/chat/conversation/directCodexConversation'; + enqueueFailureNotice, + EnqueueFailureWrapper, + enqueueSystemFailureNoticeText, +} from '../../src/view/project-development/chat/conversation/directTurnEnqueueFailure'; import { createGameCreationAppManifest, emptyProjectPolicy, @@ -22,66 +22,78 @@ import { export function registerProjectConversationTests() { it('splits structured rejections by variant and never by copy', () => { - // 入队失败是**结构化**载荷:分流只看 `error.type`,文案不参与任何判断。 - const queueFull = { - error: { type: 'queueFull' as const, limit: 5 }, - message: '待发消息已达上限(最多 5 条),请等前面几条发完再发送', - }; - expect(readDirectTurnEnqueueFailure(queueFull)?.error.type).toBe( - 'queueFull', - ); - expect(directTurnEnqueueFailureNotice(queueFull)).toBe( + // 入队失败是**结构化**载荷:分流只看 `instanceof` 与 `error.type`,文案不参与任何判断, + // 文案也不再由宿主生成——业务变体由前端按变体拼,句子与改造前宿主 `Display` 逐字一致。 + const queueFull = { type: 'queueFull' as const, limit: 5 }; + expect(enqueueFailureNotice(queueFull)).toBe( '待发消息已达上限(最多 5 条),请等前面几条发完再发送', ); - // 认得的参数 / 前置条件类都给同级提示。 + // 载荷字段是拼句子的原料:上下限 / 上限都来自 Rust,界面不自己数一份。 expect( - directTurnEnqueueFailureNotice({ - error: { type: 'contentEmpty' }, - message: '聊天内容不能为空', - }), - ).toBe('聊天内容不能为空'); - // 宿主 / 环境事实不在这里认领:它们必须走上报通道(原样抛出)。 - expect( - directTurnEnqueueFailureNotice({ - error: { type: 'environmentNotReady', detail: '连不上 app-server' }, - message: '环境未就绪', - }), - ).toBeNull(); - expect( - directTurnEnqueueFailureNotice({ - error: { type: 'hostStateUnavailable', detail: '账本损坏' }, - message: '宿主状态取不到', - }), - ).toBeNull(); - // 认不出的入队失败在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / - // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 - expect( - directTurnUnrecognizedEnqueueFailureNoticeText({ - error: { type: 'environmentNotReady', detail: '连不上 app-server' }, - message: - 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + enqueueFailureNotice({ + type: 'clientTurnIdMalformed', + minChars: 6, + maxChars: 160, }), ).toBe( - '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + 'clientTurnId 必须为 6 到 160 位 ASCII 字母、数字或连字符,且首位必须为字母或数字', + ); + expect(enqueueFailureNotice({ type: 'contentEmpty' })).toBe( + '聊天内容不能为空', ); - // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 expect( - directTurnUnrecognizedEnqueueFailureNoticeText({ - error: { type: 'hostStateUnavailable', detail: '账本损坏' }, - message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', + enqueueFailureNotice({ + type: 'permissionRejected', + policyDetail: '项目权限策略拒绝执行:conversation.write', }), - ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); - // 不是这份结构(旧字符串、Error、裸对象)一律不认。 + ).toBe('项目权限策略拒绝执行:conversation.write'); expect( - readDirectTurnEnqueueFailure('codex-app-server-error:unauthorized'), + enqueueFailureNotice({ + type: 'inputRejected', + detail: ' 用户条目不合法 ', + }), + ).toBe('用户条目不合法'); + // 空原文按"没有提示"处理:要么给一条能显示的话,要么给 null。 + expect( + enqueueFailureNotice({ type: 'permissionRejected', policyDetail: ' ' }), ).toBeNull(); - expect(readDirectTurnEnqueueFailure(new Error('boom'))).toBeNull(); + + // 宿主 / 环境事实不在这里认领:它们必须走上报通道,聊天里只给固定句,不回落 `detail`。 expect( - readDirectTurnEnqueueFailure({ error: {}, message: 'x' }), + enqueueFailureNotice({ + type: 'environmentNotReady', + detail: '连不上 app-server', + }), ).toBeNull(); expect( - readDirectTurnEnqueueFailure({ error: { type: 'contentEmpty' } }), + enqueueFailureNotice({ + type: 'hostStateUnavailable', + detail: '账本损坏', + }), ).toBeNull(); + expect( + enqueueSystemFailureNoticeText({ + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', + }), + ).toBe( + '陶泥儿智能创作:当前环境未就绪,这一轮没有开始;请检查本机运行环境后重试', + ); + expect( + enqueueSystemFailureNoticeText({ + type: 'hostStateUnavailable', + detail: '账本损坏:exitStatus=signal: 9 (SIGKILL)', + }), + ).toBe('陶泥儿智能创作:宿主执行状态不可用,这一轮没有开始;请稍后重试'); + + // JS 侧载体:结构化载荷装进 Error,`error` 与 `message`(序列化的机器事实)都带出去。 + const wrapper = new EnqueueFailureWrapper({ + type: 'queueFull', + limit: 5, + }); + expect(wrapper instanceof Error).toBe(true); + expect(wrapper.error.type).toBe('queueFull'); + expect(wrapper.message).toBe('{"type":"queueFull","limit":5}'); }); it('carries the whole direct turn input, including @ references, into the policy-confirmation retry', () => { diff --git a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx index 3d7f2998f..c7b29ab60 100644 --- a/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx +++ b/apps/ai-game-creator-shell/tests/chatPromptPolish.test.tsx @@ -32,7 +32,7 @@ import { hasMeaningfulDirectCodexContent, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; const storage = new Map(); Object.defineProperty(window, 'localStorage', { @@ -88,7 +88,7 @@ async function composerText() { } /** 纯文本草稿的 canonical content。 */ -function textContent(text: string): DirectCodexUserContentPart[] { +function textContent(text: string): UserContentPart[] { return text ? [{ type: 'input_text', text }] : []; } @@ -102,7 +102,7 @@ function ControlledChatComposer({ onSubmitDraft: (draft: ChatComposerDraft) => void; }) { const composerRef = useRef(null); - const initialContent: DirectCodexUserContentPart[] = [ + const initialContent: UserContentPart[] = [ ...textContent(initialText), ...initialReferences.map(chatReferenceToContentPart), ]; diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index c5a7bffc9..706f35e65 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -449,8 +449,8 @@ describe('DirectProject 聊天 reducer', () => { type: 'turn.completed', status: 'failed', failure: { - kind: 'transport-failed', - message: 'DirectProject 收尾历史失败:未确认历史完整落盘', + type: 'transportClosed', + diagnostic: 'DirectProject 收尾历史失败:未确认历史完整落盘', }, at: 1_000_900, }), @@ -464,9 +464,7 @@ describe('DirectProject 聊天 reducer', () => { const notice = failed.history.at(-1); expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); expect(notice?.role).toBe('assistant'); - expect(notice?.text).toBe( - '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', - ); + expect(notice?.text).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000); @@ -482,8 +480,12 @@ describe('DirectProject 聊天 reducer', () => { type: 'turn.completed', status: 'failed', failure: { - kind: 'request-rejected', - message: 'codex-app-server-error:context-window-exceeded', + type: 'modelCallFailed', + kind: { + type: 'requestRejected', + native: { type: 'context-window-exceeded' }, + }, + detail: 'codex-app-server-error:context-window-exceeded', }, at: 2_000, }), @@ -503,7 +505,10 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + failure: { + type: 'turnFailedUnclassified', + detail: '模型服务暂不可用', + }, at: 1_000_900, }), 'direct-codex:turn-1:user', @@ -515,7 +520,10 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '模型服务暂不可用' }, + failure: { + type: 'turnFailedUnclassified', + detail: '模型服务暂不可用', + }, at: 9_900_000, }), 'direct-codex:turn-1:user', @@ -539,7 +547,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '上一轮的失败' }, + failure: { type: 'turnFailedUnclassified', detail: '上一轮的失败' }, at: 2_000_900, userItemId: 'direct-codex:turn-1:user', }), @@ -550,19 +558,14 @@ describe('DirectProject 聊天 reducer', () => { expect(untouched.live).toHaveLength(0); }); - it('空原因不落说明条目,但终态照样收口', () => { + it('没有失败载荷时不落说明条目,但终态照样收口', () => { const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ withUserItemId( event({ type: 'turn.started', at: 1_000 }), 'direct-codex:turn-1:user', ), withUserItemId( - event({ - type: 'turn.completed', - status: 'failed', - failure: { kind: 'host-dropped', message: ' ' }, - at: 2_000, - }), + event({ type: 'turn.completed', status: 'failed', at: 2_000 }), 'direct-codex:turn-1:user', ), ]); @@ -571,27 +574,27 @@ describe('DirectProject 聊天 reducer', () => { expect(failed.history).toHaveLength(0); }); - it('载荷的 message 缺失或为 null 时不抛错,按"没有原因"收口', () => { - // 跨 IPC 的载荷没有运行时校验:字段缺失 / `null` 都到得了 reducer。这里只要求 - // "不抛错 + 不补空气泡",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 - for (const message of [undefined, null]) { - const malformed = { - type: 'turn.completed', - status: 'failed', - at: 2_000, - failure: { kind: 'host-dropped', message }, - } as unknown as ThreadEvent; - const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - withUserItemId( - event({ type: 'turn.started', at: 1_000 }), - 'direct-codex:turn-1:user', - ), - malformed, - ]); - expect(failed.turnRunning).toBe(false); - expect(failed.turnEndedAt).toBe(2_000); - expect(failed.history).toHaveLength(0); - } + it('未知失败变体不抛错,按通用文案收口', () => { + // 跨 IPC 的载荷没有运行时校验:未来宿主的新变体也到得了 reducer。这里只要求 + // "不抛错 + 不把宿主原文当文案",终态照样收口——抛错会连带打断这条订阅之后的所有事件。 + const malformed = { + type: 'turn.completed', + status: 'failed', + at: 2_000, + failure: { type: 'futureVariant', detail: '宿主原文不得上屏' }, + } as unknown as ThreadEvent; + const failed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + withUserItemId( + event({ type: 'turn.started', at: 1_000 }), + 'direct-codex:turn-1:user', + ), + malformed, + ]); + expect(failed.turnRunning).toBe(false); + expect(failed.turnEndedAt).toBe(2_000); + expect(failed.history.at(-1)?.text).toBe( + '陶泥儿智能创作 执行失败,请稍后重试', + ); }); it('失败说明带上它所属回合的身份,开口用户条目没到时投影层也能归位', () => { @@ -604,7 +607,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 2_000, }), 'direct-codex:turn-1:user', @@ -624,7 +627,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第一轮失败' }, + failure: { type: 'turnFailedUnclassified', detail: '第一轮失败' }, at: 2_000, }), ]); @@ -651,7 +654,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 3_000, }), 'direct-codex:turn-1:user', @@ -668,7 +671,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'transport-failed', message: '连接失败' }, + failure: { type: 'transportClosed', diagnostic: '连接失败' }, at: 3_000, }), 'direct-codex:turn-1:user', @@ -684,7 +687,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第一轮失败' }, + failure: { type: 'turnFailedUnclassified', detail: '第一轮失败' }, at: 2_000, }), ]); @@ -693,7 +696,7 @@ describe('DirectProject 聊天 reducer', () => { event({ type: 'turn.completed', status: 'failed', - failure: { kind: 'model-failed', message: '第二轮失败' }, + failure: { type: 'turnFailedUnclassified', detail: '第二轮失败' }, at: 4_000, }), ]); diff --git a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts index 6cfb4cd75..05a19b7b4 100644 --- a/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts +++ b/apps/ai-game-creator-shell/tests/referenceSourceProviders.test.ts @@ -18,7 +18,7 @@ import { type ResourceReference, type RuntimeRegionReference, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; function asset( id: string, @@ -42,7 +42,7 @@ const heroAsset = asset( 'assets/hero-idle.png', ); -const runtimeRegionPart: DirectCodexUserContentPart = { +const runtimeRegionPart: UserContentPart = { type: 'agc_runtime_region_reference', label: '主画面', runId: 'run-1', @@ -55,7 +55,7 @@ const runtimeRegionPart: DirectCodexUserContentPart = { resourceIds: ['asset-hero'], }; -const attachmentPart: DirectCodexUserContentPart = { +const attachmentPart: UserContentPart = { type: 'agc_attachment_reference', name: 'brief.md', mediaType: 'text/markdown', diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx index 552683625..cb7c5f6f2 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasBottomToolbar.test.tsx @@ -26,7 +26,7 @@ import { resourceCanvasBottomToolActions, } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; import { ResourceCanvasBottomToolbarView } from '../src/features/resource-canvas/ResourceCanvasBottomToolbarView'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; import { generationPromptText, typeGenerationPrompt, @@ -441,7 +441,7 @@ describe('ResourceCanvasAssetGenerationPanelView', () => { // 即时失败重开:草稿与原因都带回来,用户改完就能重试(同一份草稿就是同一个请求)。 const first = onSubmit.mock.calls[0]?.[0] as { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; assetName: string; aspectRatio: string; imageSize: string; diff --git a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx index f2d1b33e3..071f8299e 100644 --- a/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx +++ b/apps/ai-game-creator-shell/tests/resourceCanvasGenerationFloatingPanel.test.tsx @@ -16,7 +16,7 @@ import { } from '../src/features/project-workspace/resourceReferences'; import { ResourceCanvasAssetGenerationPanelView } from '../src/features/resource-canvas/ResourceCanvasAssetGenerationPanelView'; import type { ResourceCanvasAssetToolAction } from '../src/features/resource-canvas/resourceCanvasBottomToolbarModel'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; import { typeGenerationPrompt } from './resourceGenerationPromptTestUtils'; afterEach(cleanup); @@ -106,7 +106,7 @@ describe('生成浮层里的引用选择(真实组件,不做 props mock)', await user.click(within(panel).getByRole('button', { name: '生成图片' })); expect(onSubmit).toHaveBeenCalledTimes(1); const submitted = onSubmit.mock.calls[0]?.[0] as { - content: DirectCodexUserContentPart[]; + content: UserContentPart[]; }; // 提交载荷只有一份 canonical content:正文逐字,引用仍是稳定 resourceId。 expect( diff --git a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts index 12c1c1abb..6b133521d 100644 --- a/apps/ai-game-creator-shell/tests/resourceReferences.test.ts +++ b/apps/ai-game-creator-shell/tests/resourceReferences.test.ts @@ -15,7 +15,7 @@ import { resourceLabelResolver, resourceReferenceFromAsset, } from '../src/features/project-workspace/resourceReferences'; -import type { DirectCodexUserContentPart } from '../src/view/project-development/chat/generated/DirectCodexUserContentPart'; +import type { UserContentPart } from '../src/view/project-development/chat/generated/UserContentPart'; describe('DirectProject user Response item', () => { it('保留 Lexical content 的文本与引用交错顺序', () => { @@ -278,7 +278,7 @@ describe('粘贴文本反解析', () => { it('单 part 文本形态:解析不出引用时也有一段正常文本可落,不留空', () => { // 插入那一刻 provider 认不出这个 part 时,`$insertContentAtSelection` 落的就是这一份。 - const runtimeRegionPart = (label: string): DirectCodexUserContentPart => ({ + const runtimeRegionPart = (label: string): UserContentPart => ({ type: 'agc_runtime_region_reference', label, runId: null, @@ -312,7 +312,7 @@ describe('粘贴文本反解析', () => { }); it('与展示口径互为逆运算:用户气泡文本再粘贴回来得到同一份 content', () => { - const content: DirectCodexUserContentPart[] = [ + const content: UserContentPart[] = [ { type: 'input_text', text: '看 ' }, { type: 'agc_resource_reference', resourceId: 'hero' }, { type: 'input_text', text: ' 这一版,再用 ' },