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 b8f0316ba..b6f22d0f8 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 @@ -5699,7 +5699,7 @@ mod tests { failure: None, at: Some(_), user_item_id: Some(user_item_id), - }] if status == "aborted" && user_item_id == "direct-codex:client-turn-exited:user" + }] if *status == TurnCompletedStatus::Aborted && user_item_id == "direct-codex:client-turn-exited:user" )); let next = TurnReservation::accept_for_test(&thread_id, "client-turn-next"); @@ -8263,12 +8263,13 @@ done .find_map(|event| match event { ThreadEvent::TurnCompleted { status, failure, .. - } => Some((status.clone(), failure.clone())), + } => Some((*status, failure.clone())), _ => None, }) .expect("连接死亡之后逻辑回合必须有终态"); assert_eq!( - terminal.0, "failed", + terminal.0, + TurnCompletedStatus::Failed, "失败事实必须先落地:{:?}", consumed.events ); 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 a97a3834c..b915c5ece 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 @@ -5324,7 +5324,7 @@ mod tests { assert!( matches!( terminal, - ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { status, .. } if *status == TurnCompletedStatus::Aborted ), "{terminal:?}" ); 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 1b09fd929..2fbc2d2f6 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 @@ -206,7 +206,7 @@ mod tests { .collect::>(); assert_eq!(terminal.len(), 1, "一轮只许有一条终态:{events:?}"); let (status, failure, user_item_id) = terminal[0]; - assert_eq!(status, "failed"); + assert_eq!(*status, TurnCompletedStatus::Failed); let failure = failure.as_ref().expect("失败终态必须带载荷"); assert!( !failure_detail(failure).trim().is_empty(), @@ -333,7 +333,7 @@ mod tests { .collect::>(); assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}"); let (status, failure) = terminals[0]; - assert_eq!(status, "failed"); + assert_eq!(*status, TurnCompletedStatus::Failed); let failure = failure.as_ref().expect("失败终态必须带载荷"); let detail = failure_detail(failure); assert!(detail.contains("写入本项目对话历史失败"), "{detail}"); 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 37098a5f8..77e9ed922 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 @@ -291,7 +291,7 @@ mod tests { use super::*; use crate::agent::{ consume_thread, enqueue_pending_turn, subscribe_thread, thread_turn_is_active, Deadline, - PendingTurn, ThreadEvent, TimedOut, TurnFailure, + PendingTurn, ThreadEvent, TimedOut, TurnCompletedStatus, TurnFailure, }; use uuid::Uuid; @@ -426,7 +426,7 @@ mod tests { matches!( events.get(terminal), Some(ThreadEvent::TurnCompleted { status, failure: Some(failure), .. }) - if status == "failed" && matches!(failure, TurnFailure::HostDropped) + if *status == TurnCompletedStatus::Failed && matches!(failure, TurnFailure::HostDropped) ), "{events:?}" ); @@ -489,7 +489,7 @@ mod tests { assert!( events.iter().any(|event| matches!( event, - ThreadEvent::TurnCompleted { status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { status, .. } if *status == TurnCompletedStatus::Aborted )), "{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 a5889f166..da0fdff89 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 @@ -1241,13 +1241,13 @@ mod tests { let mut manager = ThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - ThreadEvent::turn_completed("completed".to_string(), FIXED_AT_MS), + ThreadEvent::turn_completed(TurnOutcome::Completed, FIXED_AT_MS), ); let bootstrap = manager.subscribe("thread-1"); assert!(matches!( bootstrap.events.as_slice(), [ThreadEvent::TurnCompleted { status, at, .. }] - if status == "completed" && *at == Some(FIXED_AT_MS) + if *status == TurnCompletedStatus::Completed && *at == Some(FIXED_AT_MS) )); } @@ -1266,7 +1266,7 @@ mod tests { assert!(matches!( bootstrap.events.as_slice(), [ThreadEvent::TurnCompleted { status, failure, at, .. }] - if status == "failed" + if *status == TurnCompletedStatus::Failed && failure.as_ref().is_some_and(|failure| matches!( failure, crate::agent::TurnFailure::HostDropped @@ -1350,7 +1350,7 @@ mod tests { ); manager.append( "thread-1", - ThreadEvent::turn_completed("completed".to_string(), 3_000) + ThreadEvent::turn_completed(TurnOutcome::Completed, 3_000) .with_user_item_id(Some("direct-codex:turn-1:user")), ); let second = manager.subscribe("thread-1"); @@ -1439,7 +1439,7 @@ mod tests { manager.complete_turn( thread_id, - ThreadEvent::turn_completed("completed".to_string(), 5_000), + ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000), ); assert!(snapshot_of(&manager, thread_id).is_none()); } @@ -1733,7 +1733,7 @@ mod tests { assert!( matches!( events[0], - ThreadEvent::TurnCompleted { ref status, .. } if status == "aborted" + ThreadEvent::TurnCompleted { ref status, .. } if *status == TurnCompletedStatus::Aborted ), "{events:?}" ); @@ -1832,7 +1832,7 @@ mod tests { complete_turn( &thread_id, - ThreadEvent::turn_completed("completed".to_string(), 5_000), + ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000), ); assert_eq!( crate::agent::direct_active_turns_event_test_count(), 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 index 4d243a87e..b660d2f55 100644 --- 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 @@ -16,7 +16,7 @@ use std::path::Path; -use crate::agent::{ThreadEvent, TurnError, Unclassified}; +use crate::agent::{ThreadEvent, TurnError, TurnOutcome, Unclassified}; use super::wire::TurnFailure; @@ -40,11 +40,11 @@ impl TurnCompletion { 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::Completed => ThreadEvent::turn_completed(TurnOutcome::Completed, completed_at), Self::Interrupted => { - ThreadEvent::turn_completed("interrupted".to_string(), completed_at) + ThreadEvent::turn_completed(TurnOutcome::Interrupted, completed_at) } - Self::Aborted => ThreadEvent::turn_completed("aborted".to_string(), completed_at), + Self::Aborted => ThreadEvent::turn_completed(TurnOutcome::Aborted, completed_at), }; event.with_user_item_id(user_item_id) } @@ -142,7 +142,8 @@ impl SessionOutcome { mod tests { use super::*; use crate::agent::{ - ModelCallKind, ThreadEvent, TransportClosed, TurnError, TurnInterrupted, Unclassified, + ModelCallKind, ThreadEvent, TransportClosed, TurnCompletedStatus, TurnError, + TurnInterrupted, Unclassified, }; use platform_llm::LlmError; @@ -288,7 +289,7 @@ stderrClass=nonempty;stderrBytes=1000"; assert!(event.failure().is_none()); assert!(matches!( event, - ThreadEvent::TurnCompleted { ref status, .. } if status == "completed" + ThreadEvent::TurnCompleted { ref status, .. } if *status == TurnCompletedStatus::Completed )); } 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 index 757d8b0b9..37fd9a3f7 100644 --- 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 @@ -366,7 +366,7 @@ fn lifecycle_events_serialize_event_level_at_as_optional_number() { ); let completed = - serde_json::to_value(ThreadEvent::turn_completed("completed".to_string(), 2_000)) + serde_json::to_value(ThreadEvent::turn_completed(TurnOutcome::Completed, 2_000)) .expect("serialize turn.completed"); assert_eq!( completed, @@ -437,7 +437,7 @@ fn lifecycle_events_carry_optional_opener_user_item_id() { started ); - let completed = ThreadEvent::turn_completed("interrupted".to_string(), 2_000) + let completed = ThreadEvent::turn_completed(TurnOutcome::Interrupted, 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!( @@ -526,7 +526,7 @@ fn turn_completed_carries_failure_payload_only_when_failed() { ); // 正常终态不带载荷,也不回写 `failure: null`。 - let completed = ThreadEvent::turn_completed("completed".to_string(), 5_000); + let completed = ThreadEvent::turn_completed(TurnOutcome::Completed, 5_000); assert_eq!(completed.failure(), None); assert_eq!( serde_json::to_value(&completed).expect("serialize completed turn"), 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 index ca053d12f..be73d909f 100644 --- 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 @@ -31,6 +31,41 @@ pub(crate) enum QueueRemovalOutcome { NotFound, } +/// `turn.completed` 的终态取值。 +/// +/// 正常收场是前三档、不带载荷;`Failed` **一定**带 [`TurnFailure`] 载荷,只由 +/// [`ThreadEvent::turn_completed_failed`] 写出。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))] +pub(crate) enum TurnCompletedStatus { + Completed, + Interrupted, + Aborted, + Failed, +} + +/// 正常收场的三档(不含失败):只作 [`ThreadEvent::turn_completed`] 的构造参数,不进线上形状。 +/// +/// 失败必须带载荷,所以这里没有 `Failed`——这就是 [`ThreadEvent::turn_completed`] 写不出 +/// "failed 但没有载荷"的原因。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum TurnOutcome { + Completed, + Interrupted, + Aborted, +} + +impl From for TurnCompletedStatus { + fn from(outcome: TurnOutcome) -> Self { + match outcome { + TurnOutcome::Completed => Self::Completed, + TurnOutcome::Interrupted => Self::Interrupted, + TurnOutcome::Aborted => Self::Aborted, + } + } +} + /// Thread Manager 下发的运行态事件。 /// /// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: @@ -72,9 +107,10 @@ pub(crate) enum ThreadEvent { }, #[serde(rename = "turn.completed")] TurnCompleted { - /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, - /// 此时必须带 `failure` 载荷。 - status: String, + /// 终态语义:`completed` / `interrupted` / `aborted` 是正常收场,不带载荷;`failed` 是 + /// **失败**,一定带 `failure` 载荷。正常收场的构造参数是 [`TurnOutcome`](不含 `Failed`), + /// [`ThreadEvent::turn_completed`] 因此写不出"failed 但没有载荷"。 + status: TurnCompletedStatus, /// 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -155,19 +191,19 @@ impl ThreadEvent { } } - pub(crate) fn turn_completed(status: String, at: u64) -> Self { + pub(crate) fn turn_completed(status: TurnOutcome, at: u64) -> Self { Self::TurnCompleted { - status, + status: status.into(), failure: None, at: Some(at), user_item_id: None, } } - /// 失败终态:`status` 固定 `"failed"`,原因必须随事件一起带出去。 + /// 失败终态:`status` 固定 `failed`,原因必须随事件一起带出去。 pub(crate) fn turn_completed_failed(failure: TurnFailure, at: u64) -> Self { Self::TurnCompleted { - status: "failed".to_string(), + status: TurnCompletedStatus::Failed, failure: Some(failure), at: Some(at), user_item_id: None, diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts index 40ce33131..6f3d2ec95 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/ThreadEvent.ts @@ -3,6 +3,7 @@ import type { QueueRemovalReason } from './QueueRemovalReason'; import type { ThreadDeltaKind } from './ThreadDeltaKind'; import type { ThreadItem } from './ThreadItem'; import type { ThreadRequestKind } from './ThreadRequestKind'; +import type { TurnCompletedStatus } from './TurnCompletedStatus'; import type { TurnFailure } from './TurnFailure'; import type { UserItem } from './UserItem'; @@ -47,10 +48,11 @@ export type ThreadEvent = | { type: 'turn.completed'; /** - * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场;`failed` 是**失败**, - * 此时必须带 `failure` 载荷。 + * 终态语义:`completed` / `interrupted` / `aborted` 是正常收场,不带载荷;`failed` 是 + * **失败**,一定带 `failure` 载荷。正常收场的构造参数是 [`TurnOutcome`](不含 `Failed`), + * [`ThreadEvent::turn_completed`] 因此写不出"failed 但没有载荷"。 */ - status: string; + status: TurnCompletedStatus; /** * 失败载荷:只有 `status == "failed"` 才有;失败原因只从这里下发一次。 */ diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts new file mode 100644 index 000000000..3b9c3c203 --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/generated/TurnCompletedStatus.ts @@ -0,0 +1,13 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * `turn.completed` 的终态取值。 + * + * 正常收场是前三档、不带载荷;`Failed` **一定**带 [`TurnFailure`] 载荷,只由 + * [`ThreadEvent::turn_completed_failed`] 写出。 + */ +export type TurnCompletedStatus = + | 'completed' + | 'interrupted' + | 'aborted' + | 'failed';