From f19bd8de8c26259ac6d2357b32f8e6f13d32c435 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Thu, 24 Sep 2026 13:49:38 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=BF=E4=B8=BB=EF=BC=9A=E7=BB=88=E6=80=81?= =?UTF-8?q?=E5=86=99=E7=82=B9=E6=8C=AA=E5=88=B0=E6=95=B4=E8=BD=AE=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E4=B9=8B=E5=90=8E=EF=BC=8C=E8=A7=A3=E6=9E=90=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E4=B9=9F=E8=83=BD=E8=90=BD=E8=BF=9B=E7=BB=88=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Direct 回合的终态判定事实改成先固定上下文,写点留到解析与线程释放之后 - structured output 解析折进同一个收尾结果:解析失败不再"终态写完才失败",改走失败载荷 - 收尾结果拆成 `DirectTurnReport`(报告正文 + 解析结果),终态兜底文案仍取被解析的那份文本 - 新增 `DirectTurnTerminalContext::write` 作为唯一终态出口,占用解除与 `turn.completed` 一起走 - `direct_turn_terminal_write` 改成"报告 / 失败"两个入参,便于单测覆盖三种投影 - 补单测:解析失败投影成 `model-failed` 载荷,正常收尾不带失败载荷 --- .../src/agent/codex_app_server/mod.rs | 190 +++++++++++++----- 1 file changed, 135 insertions(+), 55 deletions(-) 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 6810c4d4f..ebc1a9b76 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 @@ -4091,6 +4091,8 @@ impl CodexAppServerConnection { } }; let mut collect_result: Result = collect.await; + // Direct 回合的终态上下文:判定事实在这里固定,**写点**在整轮结束之后。 + let mut direct_terminal: Option = None; // 早退要先取得宿主收尾事实,但**只对真失败**:`RepairRequired`(封口复核要求继续当前返修 // 批次)是控制流——这一轮还没结束,不能被这里中断成一次收束失败。它的产生点(封口复核) // 一定先收束适配器,所以它也落不进下面的 `is_settled` 判据。 @@ -4142,43 +4144,63 @@ impl CodexAppServerConnection { .map(|(_, at)| *at) .unwrap_or_else(direct_tool_call_now_ms) }; - // 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主 - // 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。 - // 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能 - // 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。 - // 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的 - // `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。 - let turn_failure = approval_adapter - .as_ref() - .and_then(|adapter| adapter.turn_failure()); - // 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来; - // `None` 表示这一轮不该写终态(返修要求是控制流,见 `direct_turn_terminal_write`)。 - let collect_outcome = direct_turn_terminal_write(&collect_result); - if let Some(collect_outcome) = collect_outcome { - let terminal = direct_turn_terminal( - &status, - collect_outcome, - turn_failure.as_ref(), - history_root, - ); - // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 - complete_direct_thread_turn( - &direct_thread_id, - terminal.event(completed_at, direct_turn_user_item_id.as_deref()), - ); - } + // 终态判定的**事实**在这里固定,写点留到整轮真正结束之后(见下面的 + // `turn_result`):终态只有 `turn.completed` 一种事件,失败时同一个事件带 `failure` + // 载荷(原因由宿主脱敏 + 截断后写进去),其余(`completed` / `interrupted` / + // `aborted`)不带载荷。失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上 + // 长得一样,前端只能另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流 + // 讲不清一轮怎么结束。判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败), + // 不是收尾阶段推出来的 `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把 + // 已经失败的回合讲成"已结束"。 + direct_terminal = Some(DirectTurnTerminalContext { + status, + completed_at, + host_failure: approval_adapter + .as_ref() + .and_then(|adapter| adapter.turn_failure()), + thread_id: direct_thread_id.clone(), + user_item_id: direct_turn_user_item_id.clone(), + }); } - let text = collect_result?; - guard.armed = false; - self.inner.turns.lock().await.remove(&turn_id); - let response = parse_game_creator_codex_app_server_text(&text, &thread_id, &request)?; - if matches!( - snapshot.request_kind.as_str(), - "final-reply" | "steer-decision" - ) { + // 收集成功就等于这一轮不再改动连接:解除守卫、注销回合(与改动前是同一刻)。 + // 收集失败时守卫保持 armed,自有连接交给 `CodexTurnGuard` 回收。 + if collect_result.is_ok() { + guard.armed = false; + self.inner.turns.lock().await.remove(&turn_id); + } + // 解析是这一轮的一部分,而且排在终态之前:structured output 非法同样是这一轮的失败, + // 必须落进终态载荷。以前终态先写、再解析,于是这条 `Err` 谁都不接——终态已经是 + // `completed`,兜底的 `finish_if_unfinished` 变成空操作,用户看到的是"本轮结束、没有 + // 回复、没有任何解释"。 + 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)), + }, + Err(failure) => Err(failure), + }; + // 线程释放也排在终态之前:只有真的拿到响应才释放(与改动前一致)。 + if turn_result.is_ok() + && matches!( + snapshot.request_kind.as_str(), + "final-reply" | "steer-decision" + ) + { self.release_thread(snapshot, &thread_id).await; } - Ok(response) + // 终态的**唯一**写点:解析与线程释放都定型之后才写,成功失败都从这里出去。 + // `RepairRequired`(封口复核要求继续当前返修批次)是控制流:这一轮还没结束,不写终态。 + if let Some(context) = direct_terminal.as_ref() { + let (report, failure) = match &turn_result { + Ok(report) => (Some(report.text.as_str()), None), + Err(failure) => (None, Some(failure)), + }; + if let Some(collect_outcome) = direct_turn_terminal_write(report, failure) { + context.write(collect_outcome, history_root); + } + } + turn_result.map(|report| report.response) } } @@ -4214,19 +4236,65 @@ impl DirectTurnRunFailure { /// 这一轮要不要写终态、写什么内容。 /// -/// - `Ok(text)`:正常终态(文本交给 `direct_turn_terminal` 判定); -/// - `Err(Failed)`:失败终态,载荷从这条 typed 错误投影; -/// - `Err(RepairRequired)`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 `failed` -/// 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 -fn direct_turn_terminal_write( - collect_result: &Result, -) -> Option> { - match collect_result { - Ok(report) => Some(Ok(report.as_str())), - Err(DirectTurnRunFailure::RepairRequired { .. }) => None, - Err(DirectTurnRunFailure::Failed(error)) => { +/// - `report` 有值:正常终态(报告正文交给 `direct_turn_terminal` 兜底判定); +/// - `failure` 是 `Failed`:失败终态,载荷从这条 typed 错误投影; +/// - `failure` 是 `RepairRequired`:**不写**——"继续当前返修批次"是控制流,这一轮还没结束。写成 +/// `failed` 会让界面收到一条假失败,而且同一个逻辑回合稍后还会再写一条终态。 +fn direct_turn_terminal_write<'a>( + report: Option<&'a str>, + failure: Option<&DirectTurnRunFailure>, +) -> Option> { + match failure { + Some(DirectTurnRunFailure::Failed(error)) => { Some(Err(DirectTurnError::from_model_call(error))) } + Some(DirectTurnRunFailure::RepairRequired { .. }) => None, + None => report.map(Ok), + } +} + +/// 一轮真正结束时的收尾结果:终态要的**报告正文**和交给调用方的**解析结果**。 +/// +/// 两者一起带出来是刻意的:账本读不出来时终态兜底要用报告正文,而报告正文就是被解析的那份 +/// 文本;分开持有会让"写终态"重新跑到解析之前(正是这次要改掉的顺序)。 +struct DirectTurnReport { + /// 可展示的回复 / 交付报告正文。 + text: String, + /// 解析后的响应。 + response: platform_llm::LlmRunResponse, +} + +/// Direct 回合终态的上下文:判定所需的**事实**在收尾时固定,写点留到整轮结束之后。 +/// +/// 分两步是刻意的:终态必须在解析 / 线程释放都定型之后才写,否则"终态写完又失败"的回合在协议上 +/// 无解——前端只会看到一次没有解释的"已结束"。 +struct DirectTurnTerminalContext { + /// 收尾阶段按 ledger 阶段推出来的 `status`,只作兜底(失败判定由事实决定)。 + status: String, + /// 终态事件的宿主观测时刻。 + completed_at: u64, + /// 宿主自己记下的失败(执行通道断开 / 等待超时 / app-server 单方面中断)。 + host_failure: Option, + /// 逻辑回合身份与开口的用户条目身份。 + thread_id: String, + user_item_id: Option, +} + +impl DirectTurnTerminalContext { + /// 写下这一轮的终态。`collect_outcome` 是 [`direct_turn_terminal_write`] 的投影结果: + /// `Ok(报告)` 正常结束,`Err(失败)` 带失败载荷。 + fn write(&self, collect_outcome: Result<&str, DirectTurnError>, history_root: &Path) { + let terminal = direct_turn_terminal( + &self.status, + collect_outcome, + self.host_failure.as_ref(), + history_root, + ); + // 终态走 Thread Manager 的深出口:解除这一轮的占用并写下 `turn.completed`。 + complete_direct_thread_turn( + &self.thread_id, + terminal.event(self.completed_at, self.user_item_id.as_deref()), + ); } } @@ -6140,24 +6208,36 @@ mod tests { /// `validation-source-changed:` 那条 `InvalidRequest`,于是"继续返修"会被讲成一次用户可见的失败。 #[test] fn terminal_write_skips_the_repair_request_and_projects_real_failures() { - let repair: Result = - Err(DirectTurnRunFailure::RepairRequired { - detail: "继续当前返修批次".into(), - }); + let repair = DirectTurnRunFailure::RepairRequired { + detail: "继续当前返修批次".into(), + }; assert!( - direct_turn_terminal_write(&repair).is_none(), + direct_turn_terminal_write(None, Some(&repair)).is_none(), "返修要求是控制流,不允许写终态" ); - let failed: Result = Err(DirectTurnRunFailure::Failed( - platform_llm::LlmError::Transport("执行通道已断开".into()), + let failed = DirectTurnRunFailure::Failed(platform_llm::LlmError::Transport( + "执行通道已断开".into(), )); - let payload = direct_turn_terminal_write(&failed).expect("真失败必须写终态"); + let payload = direct_turn_terminal_write(None, Some(&failed)).expect("真失败必须写终态"); let error = payload.expect_err("失败终态必须带载荷"); assert_eq!(error.wire_kind(), Some("transport-failed")); - let report: Result = Ok("本轮交付已完成".into()); - let payload = direct_turn_terminal_write(&report).expect("正常收尾要写终态"); + // 解析失败(structured output 非法)也走这条投影:以前解析排在终态**之后**, + // 于是这条 Err 谁都不接——终态已经是 `completed`,兜底的 `finish_if_unfinished` + // 变成空操作,用户只看到"本轮结束、没有回复、没有任何解释"。 + let parse_failed = DirectTurnRunFailure::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("model-failed") + ); + + let payload = + direct_turn_terminal_write(Some("本轮交付已完成"), None).expect("正常收尾要写终态"); assert_eq!(payload.expect("正常收尾不带失败载荷"), "本轮交付已完成"); }