diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index b8ad876d8..13624b2be 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -754,6 +754,12 @@ export function App({ /** 界面忙碌判定:本地正在跑这次 invoke,或订阅告诉还有一条回合没结束。 */ const supervisorChatBusy = chatAgentBusy || (directCodexProductRuntime && directTurnRunning); + const chatAgentBusyRef = useRef(chatAgentBusy); + const directTurnRunningRef = useRef(directTurnRunning); + chatAgentBusyRef.current = chatAgentBusy; + directTurnRunningRef.current = directTurnRunning; + const previousDirectTurnRunningRef = useRef(directTurnRunning); + const directTurnCompletionPendingRef = useRef(false); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -6691,9 +6697,14 @@ export function App({ } } finally { if (localProjectPathRef.current === directProjectPath) { + setDirectThreadChat((state) => + state.turnRunning ? { ...state, turnRunning: false } : state, + ); setChatAgentBusy(false); setDirectCodexTurnCancelling(false); - // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + directTurnRunningRef.current = false; + chatAgentBusyRef.current = false; + // 本地 invoke 正常收尾仍可直接推进队列;恢复场景则由 turn.completed effect 推进。 dispatchNextQueuedChatTurn(); } } @@ -12102,8 +12113,12 @@ export function App({ } } - /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + /** 队首出队并立即发出:只在 DirectProject 收到 `turn.completed` 后调用。 */ function dispatchNextQueuedChatTurn() { + if (chatAgentBusyRef.current || directTurnRunningRef.current) { + return; + } + directTurnCompletionPendingRef.current = false; const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); if (!next) { return; @@ -12121,6 +12136,32 @@ export function App({ }); } + /** + * 回合事件可能先于本地 invoke 的 finally 到达,因此先记 pending,等本地忙碌态复位后再出队。 + */ + useEffect(() => { + if (!directCodexProductRuntime) { + directTurnCompletionPendingRef.current = false; + previousDirectTurnRunningRef.current = directTurnRunning; + return; + } + const wasRunning = previousDirectTurnRunningRef.current; + previousDirectTurnRunningRef.current = directTurnRunning; + if (wasRunning && !directTurnRunning) { + directTurnCompletionPendingRef.current = true; + } + if ( + directTurnCompletionPendingRef.current && + !chatAgentBusy && + !directTurnRunning + ) { + directTurnCompletionPendingRef.current = false; + dispatchNextQueuedChatTurn(); + } + // 队列出队函数读取本轮 render 的项目输入;这里只由三个生命周期信号触发。 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatAgentBusy, directCodexProductRuntime, directTurnRunning]); + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ async function handleCancelDirectCodexTurn() { if (directCodexTurnCancelling) { @@ -12195,7 +12236,7 @@ export function App({ ) { return; } - if (chatAgentBusy) { + if (supervisorChatBusy) { // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 if (directCodexProductRuntime) { @@ -12521,7 +12562,9 @@ export function App({ runtime={projectSupervisorRuntime} error={projectSupervisorRuntimeError} runtimeByAgentId={agentRuntimeById} - controlBusy={chatAgentBusy} + controlBusy={ + directCodexProductRuntime ? supervisorChatBusy : chatAgentBusy + } readOnly={directCodexProductRuntime} versions={chatProjectVersions} professionalResultsByAgentId={professionalAgentResultsById} 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 ccaae80aa..e58872d54 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 @@ -69,11 +69,17 @@ function gameCreatorConfigView(reasoningEffort: string) { } /** 打开一个 direct-codex 项目对话面板(右侧输入盒就是被测对象)。 */ -async function openDirectCodexSurface(overrides: InvokeOverrides = {}) { +async function openDirectCodexSurface( + overrides: InvokeOverrides = {}, + beforeOpen?: ( + harness: ReturnType, + ) => void, +) { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath: DYNAMIC_GAME_PROJECT_PATH, initialSessionExists: false, }); + beforeOpen?.(supervisorHarness); const manifest = createGameCreationAppManifest( 'local-project-draft', '输入盒控件项目', @@ -479,6 +485,59 @@ export function registerChatComposerControlTests() { }); }); + it('restores a running DirectProject turn, queues the next message, and dispatches it on turn.completed', async () => { + const pending: Array<{ resolve: (value: string) => void }> = []; + const { invoke, surface, harness } = await openDirectCodexSurface( + { + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }, + (directHarness) => { + directHarness.emitDirectThreadEvents({ type: 'turn.started' }); + }, + ); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + + expect( + await within(surface).findByRole('button', { name: '终止' }), + ).not.toBeNull(); + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + + await setComposerText(composer, '恢复后排队的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('恢复后排队的消息')).not.toBeNull(); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(0); + + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'completed', + }); + }); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expect.objectContaining({ prompt: '恢复后排队的消息' }), + ); + }); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ), + ).toHaveLength(1); + + await act(async () => { + pending[0]?.resolve('排队回合回复'); + }); + }); + it('terminates the running turn and returns the composer to the idle state', async () => { const pending: Array<{ resolve: (value: string) => void;