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 685e54a26..161a6f114 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 @@ -114,6 +114,11 @@ async fn chat_with_game_creator_direct_codex_typed( let reservation = DirectTurnReservation::accept(&thread_id, &turn_id, user_item_id.as_deref())?; // 落盘即接单:接单成功就必须在历史里留下这条用户消息,哪怕这一轮随后失败。 if let Err(error) = append_direct_project_user_message_at(root, &canonical_user_item) { + // 这一轮**已经接单**,所以收口只能走占用对象:写出失败终态(事件流里的那条失败说明就是 + // 界面唯一一份解释),然后返回 `Ok`——命令的 `Err` 只表示**拒单**,回到那里会让同一个失败 + // 同时从事件与横幅两条通道下发,也会让前端把"已经开始的回合"读成"没开始"。 + // 不继续起整轮:历史是这条对话的单一事实源,用户消息没落盘时继续跑只会得到一条没有开口 + // 用户消息的助手回复,而且失败会被静默掉。 let failure = DirectTurnError::EnvironmentNotReady { detail: redact_agent_runtime_error( root, @@ -122,7 +127,7 @@ async fn chat_with_game_creator_direct_codex_typed( ), }; reservation.finish_if_unfinished(DirectTurnTerminal::failed(root, &failure)); - return Err(failure); + return Ok(()); } let capture = crate::analytics::gui::capture_writer_context(); let root = root.to_path_buf(); @@ -257,4 +262,81 @@ mod tests { ); assert_eq!(user_item_id.as_deref(), Some("direct-codex:turn-1:user")); } + + /// 接单之后的落盘失败:**只走占用对象的失败终态**,命令返回 `Ok`。 + /// + /// 这条路径的 `turn.started` 已经发过,命令再回一个 `Err` 就等于同一个失败下发两次(事件一条 + /// 说明、横幅又一份),而且 `Err` 的含义是**拒单**——前端会把它读成"这一轮没开始"。历史追加写 + /// 有一条测试注入(`.agent/runtime/test-fail-next-direct-project-history-append`),用它把这条 + /// 路径钉成确定性:恰好一条失败终态、命令 `Ok`、占用释放(下一轮还能接单)。 + #[tokio::test] + async fn a_history_write_failure_after_accept_closes_the_turn_instead_of_rejecting() { + let temp = tempfile::tempdir().expect("temp dir"); + let root = temp.path().join("direct-history-write-failure"); + crate::init_local_game_project_at(&root, "direct-history-write", "落盘失败") + .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); + // 接下来这次追加写的两次尝试都按"争用失败"返回:确定性地走到落盘失败分支。 + std::fs::write( + root.join(".agent/runtime/test-fail-next-direct-project-history-append"), + "9", + ) + .expect("write history contention injection"); + 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": "生成一个游戏" }], + })) + .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; + let terminals = events + .iter() + .filter_map(|event| match event { + DirectThreadEvent::TurnCompleted { + status, failure, .. + } => Some((status, failure)), + _ => None, + }) + .collect::>(); + assert_eq!(terminals.len(), 1, "一轮只许有一条终态:{events:?}"); + let (status, failure) = terminals[0]; + assert_eq!(status, "failed"); + let failure = failure.as_ref().expect("失败终态必须带载荷"); + assert!( + failure.message.contains("写入本项目对话历史失败"), + "{}", + failure.message + ); + // 这一轮已经接单,所以走的是**回合失败**:拒单那套 `direct-codex-failure:v2` 收口文案 + // 不许出现在这里(它只属于可留痕的拒单)。 + assert!( + !failure.message.contains("direct-codex-failure"), + "{}", + failure.message + ); + // 占用已释放:下一轮还能接单。 + assert!(!crate::agent::direct_thread_turn_is_active(&thread_id)); + assert!(DirectTurnReservation::accept( + &thread_id, + "turn-2", + Some("direct-codex:turn-2:user") + ) + .is_ok()); + } } diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 3fca5fa4c..ae7447270 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -650,6 +650,65 @@ export function registerChatComposerControlTests() { ).toHaveLength(0); }); + it('keeps the composer moving when the host fails the turn right after accepting it', async () => { + // 落盘失败发生在**接单之后**:终态事件先写进队列,命令随后返回 `Ok`(不再用 `Err` 下发同一个 + // 失败)。这里钉住这条时序的界面结果:说明只来自事件、恰好一条,忙态被放掉,下一条还能发。 + const seen: string[] = []; + let harness: ReturnType | null = + null; + const { surface } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: ( + args: Record | undefined, + ) => { + const text = directTurnInputText(args); + seen.push(text); + if (text === '落盘失败的那条') { + const userItemId = `direct-codex:${String(args?.clientTurnId ?? '')}:user`; + harness?.emitDirectThreadEvents( + { type: 'turn.started', at: 5_000, userItemId }, + { + type: 'turn.completed', + status: 'failed', + failure: { + kind: 'environment-not-ready', + message: '写入本项目对话历史失败:项目对话历史追加写失败', + }, + at: 5_100, + userItemId, + }, + ); + } + return Promise.resolve(null); + }, + }, + (directHarness) => { + harness = directHarness; + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '落盘失败的那条'); + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + // 说明来自 `turn.completed.failure`,经同一份可见文案映射;命令返回 `Ok` 不再写第二条。 + await waitFor(() => { + expect( + within(conversation).getAllByText( + '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + ), + ).toHaveLength(1); + }); + // 忙态已放掉、也没卡成忙碌:下一条能直接发出去。 + await waitFor(() => { + expect( + within(conversation).queryAllByText('陶泥儿正在处理'), + ).toHaveLength(0); + }); + await submitDirectTurn(surface, composer, '后面这条'); + await waitFor(() => { + expect(seen).toEqual(['落盘失败的那条', '后面这条']); + }); + }); + it('keeps the next queued turn busy when the write gate refuses the running one', async () => { const pending: Array<{ resolve: (value: string) => void }> = []; const deferredPolicies: Array<(value: unknown) => void> = [];