宿主:开口用户条目的发点提前到接单之后,失败说明才能挂回自己那一轮
- 本轮开口用户条目(item_completed,direct-codex:{clientTurnId}:user)原来在 app-server turn/start 应答之后才下发;接单到 turn/start 之间的失败(连不上 app-server、执行器未通过验收、历史注入失败)走不到那一步,事件流里只有逻辑回合的一对事件,没有开口条目
- 把那段内联下发抽成 emit_direct_thread_user_item,发点提前到「接单成立、用户条目落盘成功、起 codex 之前」(direct_runtime/user_input.rs 的命令主体),并删掉 turn/start 之后那一处:线上仍只有一处下发,不变式变成「接单 → 开口用户条目 → 整轮里其余一切」
- 新增回归用例 the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn:断言行首两条事件是带身份的 turn.started 与开口用户条目,终态只能在它们之后
- 回显过滤用例补上同一发点的模拟步骤(生产入口的两个动作:落盘 + 下发)
This commit is contained in:
@@ -831,6 +831,30 @@ fn direct_thread_visible_item(
|
||||
direct_thread_event_item(root, item)
|
||||
}
|
||||
|
||||
/// 下发本轮的开口用户条目:接单之后、起 codex 之前的第一条运行态条目。
|
||||
///
|
||||
/// **顺序是这条通道的全部意义**:整轮里任何失败说明都靠"属于哪一轮"归位,而归属只认这一轮的
|
||||
/// 开口用户条目。发点在 `turn/start` 之后时,"接单到 `turn/start` 之间"的失败(连不上
|
||||
/// app-server、执行器未通过验收、历史注入失败)没有用户条目可以挂,说明会按位置落进**上一轮**
|
||||
/// 的分区里:界面显示成"错误在用户消息上面",上一轮还顶替本轮显示耗时,本轮的用户气泡再自成
|
||||
/// 一个 0.0 秒的假回合。
|
||||
///
|
||||
/// 调用点必须是"用户条目落盘成功之后"(`agent/direct_runtime/user_input.rs` 的命令主体):
|
||||
/// 条目身份取自落盘的那条条目,不在这里重造。投影不出条目时返回 `None`,不下发半条。
|
||||
pub(crate) fn emit_direct_thread_user_item(
|
||||
root: &std::path::Path,
|
||||
item: &serde_json::Value,
|
||||
) -> Option<DirectThreadItem> {
|
||||
let entry_item = direct_thread_event_item(root, item)?;
|
||||
// 条目时间是落盘 / 观测时间;前端按同一个身份保留更早的真实发送时间,不用此时间覆盖它。
|
||||
let at = entry_item.at();
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id_for_project(root),
|
||||
DirectThreadEvent::item_completed(entry_item.clone(), at),
|
||||
);
|
||||
Some(entry_item)
|
||||
}
|
||||
|
||||
/// AGC 预写的 canonical 用户条目 id:`direct-codex:{clientTurnId}:user`。
|
||||
///
|
||||
/// 与 `direct_project_history::is_direct_project_codex_user_item` 的判据同一份口径(前缀 +
|
||||
@@ -3545,27 +3569,14 @@ impl CodexAppServerConnection {
|
||||
.as_ref()
|
||||
.and_then(direct_thread_item_identity);
|
||||
// 逻辑回合的**边界**不在这里:开始事件由接单动作发出、兜底由接单占用对象持有
|
||||
// (`direct_turn_accept.rs`)。这里只把本轮的用户条目作为第一条运行态条目下发,
|
||||
// 于是顺序天然是"逻辑回合开始 → 用户消息 → 起 codex"。
|
||||
// (`direct_turn_accept.rs`)。本轮的用户条目也不在这里下发——发点在接单之后、
|
||||
// 起 codex 之前(`emit_direct_thread_user_item`),见那条注释。
|
||||
//
|
||||
// 下面这个毫秒钟与逻辑回合无关,只服务模型终态的**完成时刻**:上游 Turn 的
|
||||
// `startedAt` / `completedAt` 只有秒级,秒级截断撑不起前端 0.1 秒粒度的展示,也可能
|
||||
// 让完成时刻落进该轮用户消息的同一秒。因此这里在进入模型往返前取一次宿主毫秒钟,与
|
||||
// `durationMs` 相加得到终态时刻;拿不到 `durationMs` 时退回观察时刻。
|
||||
let direct_turn_started_at_ms = direct_tool_call_now_ms();
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if let Some(user_item) = direct_turn_user_item.as_ref() {
|
||||
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
|
||||
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
|
||||
// 保留更早的真实发送时间,不用此事件时间覆盖它。
|
||||
let user_item_at = entry_item.at();
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::item_completed(entry_item, user_item_at),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut receiver = self.register_turn(&turn_id).await;
|
||||
let mut direct_project_history = DirectProjectHistoryAccumulator::default();
|
||||
let mut guard = CodexTurnGuard {
|
||||
@@ -8044,6 +8055,10 @@ done
|
||||
.expect("accept logical turn");
|
||||
crate::agent::append_direct_project_user_message_at(&project, &user_item)
|
||||
.expect("persist opener user item");
|
||||
// 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`):
|
||||
// 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。
|
||||
crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item)
|
||||
.expect("emit opener user item");
|
||||
let execution = super::super::direct_execution::open_at(
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
@@ -8207,6 +8222,10 @@ done
|
||||
.expect("accept logical turn");
|
||||
crate::agent::append_direct_project_user_message_at(&project, &user_item)
|
||||
.expect("persist opener user item");
|
||||
// 生产入口在落盘成功、起 codex 之前就把本轮的用户条目下发(`emit_direct_thread_user_item`):
|
||||
// 这里补上同一步,于是"开口用户条目一定在整轮里最先到"这条不变式在用例里也成立。
|
||||
crate::agent::codex_app_server::emit_direct_thread_user_item(&project, &user_item)
|
||||
.expect("emit opener user item");
|
||||
let execution = super::super::direct_execution::open_at(
|
||||
&temp.path().join("host"),
|
||||
&project,
|
||||
|
||||
@@ -129,6 +129,10 @@ async fn chat_with_game_creator_direct_codex_typed(
|
||||
reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure));
|
||||
return Ok(());
|
||||
}
|
||||
// 用户条目落盘成功即下发:这一轮从"接单"到"起 codex"之间的一切失败(连不上
|
||||
// app-server、执行器未通过验收、历史注入失败)都靠它把失败说明挂回自己那一轮;晚到
|
||||
// `turn/start` 之后才发,这些失败就没有用户条目可挂,界面会把说明显示在用户消息上面。
|
||||
crate::agent::codex_app_server::emit_direct_thread_user_item(root, &canonical_user_item);
|
||||
let capture = crate::analytics::gui::capture_writer_context();
|
||||
let root = root.to_path_buf();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -263,6 +267,77 @@ mod tests {
|
||||
assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user"));
|
||||
}
|
||||
|
||||
/// 本轮的开口用户条目必须先于整轮里任何可能失败的东西下发。
|
||||
///
|
||||
/// 现场(用户可见的坏体验):命令接单、用户条目落盘之后,整轮在 `turn/start` 之前就失败
|
||||
/// (连不上 app-server 一类)。这时如果用户条目还没下发,界面就只剩一条失败说明——它按位置
|
||||
/// 落进**上一轮**的分区里,于是"错误显示在用户消息上面"、上一轮顶替本轮显示耗时,本轮的用户
|
||||
/// 气泡再自成一个 0.0 秒的假回合。
|
||||
///
|
||||
/// 判据取事件流的前两条:命令体是顺序执行的,后台整轮是它之后才起的,所以"开始 → 用户条目"
|
||||
/// 一定在最前面,之后才可能有失败终态。
|
||||
#[tokio::test]
|
||||
async fn the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn() {
|
||||
let temp = tempfile::tempdir().expect("temp dir");
|
||||
let root = temp.path().join("direct-user-item-first");
|
||||
crate::init_local_game_project_at(&root, "direct-user-item-first", "用户条目先下发")
|
||||
.expect("init project");
|
||||
let thread_id = direct_thread_id_for_project(&root);
|
||||
let subscription = subscribe_direct_thread(&thread_id);
|
||||
let _ = consume_direct_thread(&subscription.subscription_id);
|
||||
let user_item: DirectCodexUserItem = serde_json::from_value(serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "direct-codex:turn-1:user",
|
||||
"content": [{ "type": "input_text", "text": "hello" }],
|
||||
}))
|
||||
.expect("canonical user item");
|
||||
|
||||
chat_with_game_creator_direct_codex_typed(
|
||||
&root,
|
||||
user_item,
|
||||
None,
|
||||
Some("turn-1".to_string()),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("接单成立:命令只回报接单");
|
||||
|
||||
let events = consume_direct_thread(&subscription.subscription_id)
|
||||
.expect("consume logical turn")
|
||||
.events;
|
||||
assert!(
|
||||
matches!(
|
||||
events.first(),
|
||||
Some(DirectThreadEvent::TurnStarted { user_item_id, .. })
|
||||
if user_item_id.as_deref() == Some("direct-codex:turn-1:user")
|
||||
),
|
||||
"第一条必须是带身份的回合开始:{events:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
events.get(1),
|
||||
Some(DirectThreadEvent::ItemCompleted { item, .. })
|
||||
if item.item_id() == "direct-codex:turn-1:user"
|
||||
),
|
||||
"第二条必须是本轮的开口用户条目:{events:?}"
|
||||
);
|
||||
let terminal = events
|
||||
.iter()
|
||||
.position(|event| matches!(event, DirectThreadEvent::TurnCompleted { .. }));
|
||||
assert!(
|
||||
terminal.is_none_or(|index| index > 1),
|
||||
"终态只能在用户条目之后:{events:?}"
|
||||
);
|
||||
// 落盘与下发同一份身份:历史里的条目 id 就是事件里的 itemId。
|
||||
let persisted = std::fs::read_to_string(root.join(".agent/conversations/project.jsonl"))
|
||||
.expect("read project history");
|
||||
assert!(
|
||||
persisted.contains("direct-codex:turn-1:user"),
|
||||
"用户条目必须已经落盘:{persisted}"
|
||||
);
|
||||
}
|
||||
|
||||
/// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。
|
||||
///
|
||||
/// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条
|
||||
|
||||
Reference in New Issue
Block a user