diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 0a0250fb4..d6aa7a7d0 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -131,7 +131,9 @@ export type DirectProjectChatControllerProps = { * 4. `item.completed`(本轮用户条目回显):同身份条目已在历史里就合并进去,否则进 `live`;本地气泡此时被去重。 * 5. `item.delta` / `item.started` / `item.completed`:正文追加、工具卡片 upsert(先到定形、后到只补空)。 * 6. `turn.completed`:`live` 并入 `history` 后清空,`turnEndedAt` 冻结,边界按身份盖到本轮开口条目上。 - * 7. 命令收尾(`finally`):`endTurnCommand()` 清掉忙态与在途身份,刷新清单,按边沿推进队列出队。 + * 7. 命令收尾(`finally`):刷新清单 → `endTurnCommand()` 清掉忙态与在途身份 → 出队下一轮。 + * **顺序是契约**:出队会同步开始下一轮并设上它自己的忙态,所以清忙态必须早于出队; + * 权限被拒那种「本轮从未发出但要继续出队」的情况,也只标记 `queueAdvance`、由这里统一收口。 * * 状态变量归属:reducer 的三个回合字段与 `history` / `live` 只由 `directThreadChat.ts` 写; * 本文件的 `turnBusy` / `pendingUserItemId`(同生共死,唯一入口 `beginTurnCommand` / @@ -457,6 +459,9 @@ export function useDirectProjectChatController({ ); void (async () => { let invoked = false; + // 权限被拒也要继续出队(见下),但出队必须发生在 finally 的 endTurnCommand() 之后: + // 在这里出队的话,下一轮刚设上的忙态会被紧接着的 finally 清掉。 + let queueAdvance = false; try { onRuntimeError(''); if (!input.directPolicyChecked) { @@ -470,10 +475,9 @@ export function useDirectProjectChatController({ }); if (!allowed) { // 写权限门返回 false 且没有调用 onConfirmed(被策略拒绝、或读策略失败), - // 说明这一轮不会重跑;它已经被 dispatchNextQueuedTurn 出队,必须自己把 - // 忙态放下并继续出队,否则后面的排队消息会永久卡住。 - endTurnCommand(); - dispatchNextQueuedTurn(); + // 说明这一轮不会重跑;它已经被出队,必须继续出队,否则后面的排队消息会 + // 永久卡住。放忙态与出队都交给 finally 收口,这里只做标记。 + queueAdvance = true; return; } } @@ -495,8 +499,15 @@ export function useDirectProjectChatController({ if (invoked) { await refreshDirectManifest(nextProjectPath); } + // 忙态与在途身份每轮只在这里放一次,且必须早于出队:出队会同步开始下一轮并设上 + // 它自己的忙态,清在它后面就等于把下一轮的忙态抹掉(composer 会以为可以并发发送, + // 下一轮的三态也会因为身份被清空而掉回 finished)。 endTurnCommand(); - if (invoked && projectPathRef.current === nextProjectPath) { + // 出队条件保持原样:真发出过的一轮要求项目没被换掉;权限被拒的一轮从未发出, + // 不受项目切换影响,照旧出队。 + const invokedInSameProject = + invoked && projectPathRef.current === nextProjectPath; + if (queueAdvance || invokedInSameProject) { dispatchNextQueuedTurn(); } } 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 72914b130..f093acb43 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 @@ -21,6 +21,7 @@ import { act, createGameCreationAppManifest, createProjectChatRuntimeHarness, + emptyProjectPolicy, expect, fireEvent, it, @@ -502,6 +503,63 @@ export function registerChatComposerControlTests() { }); }); + 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> = []; + let policyAllowsWrite = false; + const { invoke, surface } = await openDirectCodexSurface({ + read_project_permission_policy: () => { + if (policyAllowsWrite) return Promise.resolve(emptyProjectPolicy()); + return new Promise((resolve) => { + deferredPolicies.push(resolve); + }); + }, + chat_with_game_creator_direct_codex: () => + new Promise((resolve) => { + pending.push({ resolve }); + }), + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '被拒的那条'); + + // 权限门还没回,先把第二条排进队列:后面那条要等被拒的这一轮出队才会发出去。 + await setComposerText(composer, '后面那条'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + expect(within(queue).getByText('后面那条')).not.toBeNull(); + + // 写权限门拒绝这一轮(策略要求确认,且此刻没有 onConfirmed):这一轮不会重跑, + // 队列必须继续走,而它出队后那一轮仍要算「命令在飞」。 + policyAllowsWrite = true; + await act(async () => { + for (const resolve of deferredPolicies.splice(0)) { + resolve({ + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.write'], + }, + }); + } + }); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'chat_with_game_creator_direct_codex', + expectDirectTurnWithText('后面那条'), + ); + }); + // 被出队的那一轮还在飞:上一轮的收尾不得把它刚设上的忙态清掉。 + expect(within(surface).queryByRole('button', { name: '发送' })).toBeNull(); + expect( + within(surface).getByRole('button', { name: '终止' }), + ).not.toBeNull(); + + await act(async () => { + pending[0]?.resolve('回复'); + }); + }); + 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(