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