运行态事件:待发消息队列的入队/移除事件与队列条目规则
- 新增 `DirectThreadEvent::QueueEnqueued`(`queue.enqueued`,带 canonical 用户条目与可选 `creationType`,不带 prompt)与 `QueueRemoved`(`queue.removed`,`reason` 为 typed 枚举 `cancelled | dispatched`) - 新增 typed 枚举 `DirectQueueRemovalReason` 与 `DirectQueueRemovalOutcome`,并重跑 ts-rs 绑定 - 新增 `agent/direct_thread_queue.rs`:`PendingDirectTurn`(入队时冻结 canonical 形状与 prompt)、上限 `MAX_PENDING_DIRECT_TURNS = 5`、`EnqueueOutcome` / `EnqueueRejection` - Thread Manager 的 `observe_event` 认队列事件:在队期间的 `queue.enqueued` 不可回收,`queue.removed` 把它转成可回收,新订阅者的 bootstrap 因此天然看得见当前队列 - `DirectCodexUserItem` 及其子类型补 `PartialEq`,`DirectThreadEvent` 不再需要 `Eq`
This commit is contained in:
@@ -29,6 +29,7 @@ mod direct_project_history;
|
||||
mod direct_project_turn_history;
|
||||
mod direct_runtime;
|
||||
mod direct_thread_manager;
|
||||
mod direct_thread_queue;
|
||||
mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
@@ -65,6 +66,7 @@ pub(crate) use direct_project_history::*;
|
||||
pub(crate) use direct_project_turn_history::*;
|
||||
pub(crate) use direct_runtime::*;
|
||||
pub(crate) use direct_thread_manager::*;
|
||||
pub(crate) use direct_thread_queue::*;
|
||||
pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, 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 {
|
||||
@@ -10,7 +10,7 @@ pub(crate) enum DirectCodexUserItem {
|
||||
Message(DirectCodexUserMessageItem),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[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 {
|
||||
@@ -19,14 +19,14 @@ pub(crate) struct DirectCodexUserMessageItem {
|
||||
pub(crate) id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[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 {
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[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 {
|
||||
@@ -43,7 +43,7 @@ pub(crate) enum DirectCodexUserContentPart {
|
||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[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 {
|
||||
@@ -55,7 +55,7 @@ pub(crate) struct DirectCodexUserAttachmentReferencePart {
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[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 {
|
||||
|
||||
@@ -428,6 +428,14 @@ impl DirectThreadManager {
|
||||
true
|
||||
}
|
||||
DirectThreadEvent::ItemDelta { .. } => true,
|
||||
// 待发消息在队期间不可回收:队列的成员与顺序**就是**这条事件本身
|
||||
// (见 `direct_thread_queue`)。bootstrap 因此天然看得见当前队列。
|
||||
DirectThreadEvent::QueueEnqueued { .. } => false,
|
||||
// 离开队列(取消 / 放行)才把同一条待发消息的入队事件一起转成可回收。
|
||||
DirectThreadEvent::QueueRemoved { client_turn_id, .. } => {
|
||||
Self::mark_queue_events_cleanable(thread, client_turn_id);
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +479,15 @@ impl DirectThreadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待发消息离开队列:它和它对应的入队事件一起变成可回收。
|
||||
fn mark_queue_events_cleanable(thread: &mut ThreadState, client_turn_id: &str) {
|
||||
for stored in &mut thread.events {
|
||||
if stored.event.queue_client_turn_id() == Some(client_turn_id) {
|
||||
stored.cleanable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_prefix(thread: &mut ThreadState) {
|
||||
let min_cursor = thread
|
||||
.subscribers
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
//! DirectProject 待发消息队列的条目与规则。
|
||||
//!
|
||||
//! 队列的成员与顺序**就是 Thread Manager 的事件列表本身**:一条待发消息在队期间,它的
|
||||
//! `queue.enqueued` 事件不可回收;离开队列(取消或放行)时才转成可回收。新订阅者的 bootstrap 因此
|
||||
//! 天然看得见当前队列,不需要第二张队列表,也不会有"事件与队列不一致"的窗口。
|
||||
//!
|
||||
//! 这个模块只放三件事:一条待发消息带走什么([`PendingDirectTurn`])、容量规则
|
||||
//! ([`MAX_PENDING_DIRECT_TURNS`])、以及它在线上长什么样([`PendingDirectTurn::enqueued_event`])。
|
||||
//! 它不碰锁、不碰 Tauri、不写盘:入队检查在命令侧,放行顺序在 Thread Manager。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::agent::{
|
||||
direct_codex_user_item_id_for_client_turn_id, DirectCodexUserItem, DirectThreadEvent,
|
||||
};
|
||||
|
||||
/// 一个项目最多能同时排队的待发消息条数。
|
||||
///
|
||||
/// 只数**在队**条目,不算正在跑的那一轮。上限只落在宿主这一处:前端不再自己数,满队由命令返回
|
||||
/// typed 入队失败。
|
||||
pub(crate) const MAX_PENDING_DIRECT_TURNS: usize = 5;
|
||||
|
||||
/// 一条已经通过入队检查、正在等放行的用户消息。
|
||||
///
|
||||
/// 只在内存里,进程重启即消失(与 ADR 记的边界一致)。它同时是**放行时要用的全部输入**:放行
|
||||
/// 没有失败出口,所以检查产物在入队时就地冻结,放行只搬运、不重算。
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PendingDirectTurn {
|
||||
/// 这条消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。
|
||||
pub(crate) client_turn_id: String,
|
||||
/// canonical 用户条目:事件与界面 chip 都读它,Rust 不渲染展示形状。
|
||||
pub(crate) user_item: DirectCodexUserItem,
|
||||
/// canonical 用户条目的 JSON 形状:入队时算好,放行时直接落盘。
|
||||
pub(crate) canonical_user_item: Value,
|
||||
/// 入队检查产出的 prompt:放行不重算。
|
||||
pub(crate) prompt: String,
|
||||
pub(crate) creation_type: Option<String>,
|
||||
/// 入队那一刻的宿主毫秒钟。
|
||||
pub(crate) at: u64,
|
||||
}
|
||||
|
||||
impl PendingDirectTurn {
|
||||
/// 组一条待发消息:入队检查已经全部通过,这里只把放行要用的产物冻结下来。
|
||||
///
|
||||
/// 冻结是刻意的:放行没有失败出口,所以任何可能在放行时才失败的计算都必须提前到这里
|
||||
/// (canonical 形状与 prompt 都是)。
|
||||
pub(crate) fn prepare(
|
||||
client_turn_id: String,
|
||||
user_item: DirectCodexUserItem,
|
||||
prompt: String,
|
||||
creation_type: Option<String>,
|
||||
at: u64,
|
||||
) -> Result<Self, serde_json::Error> {
|
||||
let canonical_user_item = serde_json::to_value(&user_item)?;
|
||||
Ok(Self {
|
||||
client_turn_id,
|
||||
user_item,
|
||||
canonical_user_item,
|
||||
prompt,
|
||||
creation_type,
|
||||
at,
|
||||
})
|
||||
}
|
||||
|
||||
/// 入队事件的投影:带 canonical 用户条目与 `creationType`,**不带 prompt**(prompt 只留在宿主的
|
||||
/// 队列条目里,它不是要下发的展示形状)。
|
||||
pub(crate) fn enqueued_event(&self) -> DirectThreadEvent {
|
||||
DirectThreadEvent::queue_enqueued(
|
||||
self.client_turn_id.clone(),
|
||||
self.user_item.clone(),
|
||||
self.creation_type.clone(),
|
||||
self.at,
|
||||
)
|
||||
}
|
||||
|
||||
/// 这条待发消息在历史里的用户条目 id:前端用它把 chip 与回合边界对上。
|
||||
pub(crate) fn user_item_id(&self) -> Option<String> {
|
||||
direct_codex_user_item_id_for_client_turn_id(&self.client_turn_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 入队的结果。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum EnqueueOutcome {
|
||||
/// 这次真的排进队尾了。
|
||||
Enqueued,
|
||||
/// 同一 `clientTurnId` 已经在队(或正在跑):按幂等返回成功,不排第二条、不发事件。
|
||||
AlreadyKnown,
|
||||
}
|
||||
|
||||
/// 入队被检查挡下来的原因。满队之外的原因由入队半自己的 typed 错误表达。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum EnqueueRejection {
|
||||
/// 在队条目已达 [`MAX_PENDING_DIRECT_TURNS`]。
|
||||
QueueFull,
|
||||
}
|
||||
|
||||
/// 队列还有没有位置。`pending_count` 只数在队条目。
|
||||
pub(crate) fn queue_has_room(pending_count: usize) -> Result<(), EnqueueRejection> {
|
||||
if pending_count >= MAX_PENDING_DIRECT_TURNS {
|
||||
return Err(EnqueueRejection::QueueFull);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn user_item(text: &str, id: &str) -> DirectCodexUserItem {
|
||||
serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": text}],
|
||||
"id": id,
|
||||
}))
|
||||
.expect("canonical user item")
|
||||
}
|
||||
|
||||
fn pending(client_turn_id: &str, creation_type: Option<&str>) -> PendingDirectTurn {
|
||||
PendingDirectTurn::prepare(
|
||||
client_turn_id.to_string(),
|
||||
user_item("生成一个游戏", "direct-codex:turn-1:user"),
|
||||
"生成一个游戏".to_string(),
|
||||
creation_type.map(str::to_string),
|
||||
1_700_000_000_123,
|
||||
)
|
||||
.expect("prepare pending turn")
|
||||
}
|
||||
|
||||
/// 入队事件只带 canonical 用户条目与 `creationType`:prompt 是宿主的入队检查产物,
|
||||
/// 不许顺着事件下发。
|
||||
#[test]
|
||||
fn enqueued_event_carries_the_canonical_item_and_no_prompt() {
|
||||
let event = pending("turn-1", Some("web-game")).enqueued_event();
|
||||
let value = serde_json::to_value(&event).expect("serialize queue.enqueued");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({
|
||||
"type": "queue.enqueued",
|
||||
"clientTurnId": "turn-1",
|
||||
"userItem": {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "生成一个游戏"}],
|
||||
"id": "direct-codex:turn-1:user",
|
||||
},
|
||||
"creationType": "web-game",
|
||||
"at": 1_700_000_000_123u64,
|
||||
})
|
||||
);
|
||||
assert!(value.get("prompt").is_none(), "{value}");
|
||||
|
||||
// 没有创建类型时不写字段,也不补 `null`。
|
||||
let bare = serde_json::to_value(pending("turn-2", None).enqueued_event())
|
||||
.expect("serialize queue.enqueued without creation type");
|
||||
assert!(bare.get("creationType").is_none(), "{bare}");
|
||||
|
||||
// 事件读得出自己的待发消息身份。
|
||||
assert_eq!(event.queue_client_turn_id(), Some("turn-1"));
|
||||
assert_eq!(event.queue_removal_reason(), None);
|
||||
}
|
||||
|
||||
/// canonical 形状在入队时就冻结:放行时落盘的就是这一份,不再重算。
|
||||
#[test]
|
||||
fn prepare_freezes_the_canonical_item() {
|
||||
let turn = pending("turn-1", None);
|
||||
assert_eq!(
|
||||
turn.canonical_user_item["id"],
|
||||
json!("direct-codex:turn-1:user")
|
||||
);
|
||||
assert_eq!(
|
||||
turn.canonical_user_item,
|
||||
serde_json::to_value(&turn.user_item).expect("serialize user item")
|
||||
);
|
||||
}
|
||||
|
||||
/// 用户条目身份由 `clientTurnId` 派生,与落盘 / 下发用的是同一个函数。
|
||||
#[test]
|
||||
fn user_item_id_derives_from_the_client_turn_id() {
|
||||
assert_eq!(
|
||||
pending("turn-1", None).user_item_id().as_deref(),
|
||||
Some("direct-codex:turn-1:user")
|
||||
);
|
||||
assert_eq!(pending(" ", None).user_item_id(), None);
|
||||
}
|
||||
|
||||
/// 容量只数在队条目,5 条封口;在跑的那一轮不算进去。
|
||||
#[test]
|
||||
fn capacity_closes_at_five_pending_turns() {
|
||||
for count in 0..MAX_PENDING_DIRECT_TURNS {
|
||||
assert_eq!(queue_has_room(count), Ok(()), "{count} 条时仍该有位置");
|
||||
}
|
||||
assert_eq!(
|
||||
queue_has_room(MAX_PENDING_DIRECT_TURNS),
|
||||
Err(EnqueueRejection::QueueFull)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
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};
|
||||
@@ -212,6 +213,33 @@ impl DirectThreadRequestKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一条待发消息离开队列的原因。
|
||||
///
|
||||
/// 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 DirectQueueRemovalReason {
|
||||
/// 用户取消了这条待发消息。
|
||||
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 DirectQueueRemovalOutcome {
|
||||
/// 已从队列移除。
|
||||
Removed,
|
||||
/// 这条消息已经被放行(正在跑的那一轮就是它),不能按待发消息取消。
|
||||
AlreadyDispatched,
|
||||
/// 队列里没有这个身份,也没有在跑的一轮是它。
|
||||
NotFound,
|
||||
}
|
||||
|
||||
/// 失败终态的可下发载荷(`turn.completed.status == "failed"` 时必有,其余终态没有)。
|
||||
///
|
||||
/// `kind` 是稳定分类,只给界面选语气,不参与流程分支;`message` 是**已在宿主侧脱敏并截断**的
|
||||
@@ -259,7 +287,7 @@ impl DirectTurnFailure {
|
||||
/// 不带回合身份,这个字段只用来把"这一轮的边界属于哪条用户消息"讲清楚:前端在只有生命周期锚点
|
||||
/// + 历史切片、运行态一直为空时也能按身份认领开口条目,不必靠时间戳猜。缺失表示身份不可证明
|
||||
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[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 DirectThreadEvent {
|
||||
@@ -321,6 +349,34 @@ pub(crate) enum DirectThreadEvent {
|
||||
#[serde(default)]
|
||||
request_id: Option<String>,
|
||||
},
|
||||
/// 待发消息入队:数组顺序就是队首到队尾的顺序。
|
||||
///
|
||||
/// 这条事件在条目仍在队期间**不可回收**,离开队列(取消或放行)时才转成可回收——新订阅者
|
||||
/// 靠这一点在 bootstrap 里看到当前队列,`is_bootstrap_event` 不需要为它加特例。
|
||||
///
|
||||
/// 事件不带 prompt:prompt 是入队检查的产物,只留在宿主的队列条目里。
|
||||
#[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<String>,
|
||||
/// 入队那一刻的宿主毫秒钟。
|
||||
#[ts(as = "f64")]
|
||||
at: u64,
|
||||
},
|
||||
/// 待发消息离开队列:`reason` 是取消还是放行。
|
||||
#[serde(rename = "queue.removed")]
|
||||
QueueRemoved {
|
||||
client_turn_id: String,
|
||||
reason: DirectQueueRemovalReason,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[ts(optional, as = "Option<f64>")]
|
||||
at: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DirectThreadEvent {
|
||||
@@ -417,6 +473,51 @@ impl DirectThreadEvent {
|
||||
Self::Request { kind, request_id }
|
||||
}
|
||||
|
||||
/// 待发消息入队事件。
|
||||
pub(crate) fn queue_enqueued(
|
||||
client_turn_id: String,
|
||||
user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
at: u64,
|
||||
) -> Self {
|
||||
Self::QueueEnqueued {
|
||||
client_turn_id,
|
||||
user_item,
|
||||
creation_type,
|
||||
at,
|
||||
}
|
||||
}
|
||||
|
||||
/// 待发消息离开队列事件。
|
||||
pub(crate) fn queue_removed(
|
||||
client_turn_id: String,
|
||||
reason: DirectQueueRemovalReason,
|
||||
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<DirectQueueRemovalReason> {
|
||||
match self {
|
||||
Self::QueueRemoved { reason, .. } => Some(*reason),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件级阶段时间(毫秒):只有四种生命周期事件有,其余事件返回 `None`。
|
||||
///
|
||||
/// 只读已存入事件的值,不在读取时取钟——重放要用的就是原事件的时间。
|
||||
@@ -427,6 +528,8 @@ impl DirectThreadEvent {
|
||||
| Self::TurnCompleted { at, .. }
|
||||
| Self::ItemStarted { at, .. }
|
||||
| Self::ItemCompleted { at, .. } => *at,
|
||||
Self::QueueEnqueued { at, .. } => Some(*at),
|
||||
Self::QueueRemoved { at, .. } => *at,
|
||||
Self::ItemDelta { .. } | Self::Request { .. } => None,
|
||||
}
|
||||
}
|
||||
@@ -456,7 +559,7 @@ impl DirectThreadEvent {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[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 DirectThreadSubscriptionBootstrap {
|
||||
@@ -468,7 +571,7 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
|
||||
pub(crate) events: Vec<DirectThreadEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[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 DirectThreadConsumeResult {
|
||||
@@ -1466,4 +1569,61 @@ mod tests {
|
||||
.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 = DirectThreadEvent::queue_removed(
|
||||
"turn-1".to_string(),
|
||||
DirectQueueRemovalReason::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::<DirectThreadEvent>(
|
||||
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(DirectQueueRemovalReason::Dispatched)
|
||||
);
|
||||
|
||||
// 未识别的取值必须失败关闭:队列归属是宿主事实,不能让界面猜。
|
||||
serde_json::from_value::<DirectThreadEvent>(json!({
|
||||
"type": "queue.removed",
|
||||
"clientTurnId": "turn-1",
|
||||
"reason": "timeout",
|
||||
}))
|
||||
.expect_err("unknown queue removal reason must fail closed");
|
||||
|
||||
// 没有 `at` 的老形状仍能反序列化,回写不补 `null`。
|
||||
let legacy: DirectThreadEvent = 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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 取消一条待发消息的结果。
|
||||
*/
|
||||
export type DirectQueueRemovalOutcome =
|
||||
| 'removed'
|
||||
| 'alreadyDispatched'
|
||||
| 'notFound';
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* 一条待发消息离开队列的原因。
|
||||
*
|
||||
* typed 枚举,取值即语义:取消是用户在输入盒上撤掉这条消息,放行是它已经接单并成为回合
|
||||
* (同一临界区里另有 `turn.started`)。界面按它分流,不解析字符串。
|
||||
*/
|
||||
export type DirectQueueRemovalReason = 'cancelled' | 'dispatched';
|
||||
+23
-3
@@ -1,4 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { DirectCodexUserItem } from './DirectCodexUserItem';
|
||||
import type { DirectQueueRemovalReason } from './DirectQueueRemovalReason';
|
||||
import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind';
|
||||
import type { DirectThreadItem } from './DirectThreadItem';
|
||||
import type { DirectThreadRequestKind } from './DirectThreadRequestKind';
|
||||
@@ -84,8 +86,26 @@ export type DirectThreadEvent =
|
||||
kind: DirectThreadDeltaKind;
|
||||
delta: string;
|
||||
}
|
||||
| { type: 'request'; kind: DirectThreadRequestKind; requestId: string | null }
|
||||
| {
|
||||
type: 'request';
|
||||
kind: DirectThreadRequestKind;
|
||||
requestId: string | null;
|
||||
type: 'queue.enqueued';
|
||||
/**
|
||||
* 这条待发消息的回合身份;放行后同一轮的 `turn.started` / `turn.completed` 用它。
|
||||
*/
|
||||
clientTurnId: string;
|
||||
/**
|
||||
* canonical 用户条目:前端据此派生 chip 文案,Rust 不渲染展示形状。
|
||||
*/
|
||||
userItem: DirectCodexUserItem;
|
||||
creationType?: string;
|
||||
/**
|
||||
* 入队那一刻的宿主毫秒钟。
|
||||
*/
|
||||
at: number;
|
||||
}
|
||||
| {
|
||||
type: 'queue.removed';
|
||||
clientTurnId: string;
|
||||
reason: DirectQueueRemovalReason;
|
||||
at?: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user