重构:turn.completed 的 status 由自由字符串收成 typed 枚举

- 新增 TurnCompletedStatus(completed / interrupted / aborted / failed),作为 turn.rs 里 status 字段的类型
- 新增只作构造参数的 TurnOutcome(completed / interrupted / aborted):turn_completed 拿不到 Failed,"failed 但没有失败载荷"在构造层就写不出来
- turn_completed_failed 固定写 TurnCompletedStatus::Failed,载荷与状态在同一条构造路径上
- 内部状态比较与用例断言改为比较枚举取值;线上 JSON 形状、字段名与前端契约不变
- 重新生成绑定:新增 TurnCompletedStatus.ts,ThreadEvent.ts 的 status 由 string 收成字面量联合
This commit is contained in:
2026-10-03 10:55:04 +08:00
parent adcf3b9730
commit 40b79bb1ec
10 changed files with 88 additions and 35 deletions
@@ -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
);
@@ -5324,7 +5324,7 @@ mod tests {
assert!(
matches!(
terminal,
ThreadEvent::TurnCompleted { status, .. } if status == "aborted"
ThreadEvent::TurnCompleted { status, .. } if *status == TurnCompletedStatus::Aborted
),
"{terminal:?}"
);
@@ -206,7 +206,7 @@ mod tests {
.collect::<Vec<_>>();
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::<Vec<_>>();
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}");
@@ -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:?}"
);
@@ -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(),
@@ -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
));
}
@@ -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"),
@@ -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<TurnOutcome> 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,
@@ -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"` 才有;失败原因只从这里下发一次。
*/
@@ -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';