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 429c71343..91f86394e 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 @@ -335,6 +335,12 @@ export function useDirectProjectChatController({ { projectPath: nextProjectPath, clientTurnId }, ); if (projectPathRef.current !== nextProjectPath) return; + if (outcome === 'removed') { + // 这一条不会再被放行,也就不会再有 `turn.completed` 来结算它:句柄只能在这里清掉。 + // 留着不但泄漏,还会挤进"身份缺失"的兜底结算的候选里(`settlePendingRunAnalytics` + // 在没有对得上身份的候选时挑最早那一条)——取消一条消息不该改变别人的结算是谁。 + pendingRunAnalyticsRef.current.delete(clientTurnId); + } setComposerNotice( outcome === 'notFound' ? '这条待发消息已经不在队列里' 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 3e6545351..5f653440e 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 @@ -647,6 +647,61 @@ export function registerChatComposerControlTests() { ).toEqual(['第一条消息', '第二条消息', '第三条消息']); }); + it('不再认领被取消那一条的埋点句柄:身份缺失的兜底结算只落到还能收口的那一条', async () => { + // 取消掉的待发消息永远不会被放行,也就永远不会有它自己的 `turn.completed`:句柄必须在取消时清掉。 + // 留着的话,"身份缺失"的兜底结算(认领表里**最早**那一条)会先认出它——取消一条消息等于把下一次 + // 结算算到一条根本没跑过的回合头上,真正跑着的那条反而没人结算。 + let host: ReturnType | null = null; + const { invoke, surface, harness } = await openDirectCodexSurface({ + enqueue_direct_codex_turn: (args) => host?.enqueue(args), + remove_direct_project_pending_turn: (args) => host?.cancel(args), + }); + // 线程上已有一条(恢复出来的)回合在跑:发出去的两条都只会排队,事件流里也不会走 `turn.started`。 + host = hostQueue(harness, { running: true }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await setComposerText(composer, '会被取消的消息'); + submitComposerForm(composer); + await setComposerText(composer, '留下来的消息'); + submitComposerForm(composer); + const queue = await within(surface).findByLabelText('待发送消息队列'); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(2); + }); + + fireEvent.click( + within(queue).getByRole('button', { + name: '取消待发消息 会被取消的消息', + }), + ); + await waitFor(() => { + expect(within(queue).getAllByRole('listitem')).toHaveLength(1); + }); + const attempts = invoke.mock.calls + .filter(([command]) => command === 'enqueue_direct_codex_turn') + .map(([, args]) => String(args?.analyticsAttemptId ?? '')); + expect(attempts).toHaveLength(2); + const settledAttemptIds = () => + invoke.mock.calls + .filter(([command]) => command === 'settle_direct_run_analytics') + .map(([, args]) => String(args?.attemptId ?? '')); + + // 回放一条**没有身份**的失败终态:订阅重建时的生命周期锚点就是这个形状(订阅在新回合开始前 + // 断过,开局那条 `turn.started` 不在手里)。兜底结算此刻只能按"认领表里最早那一条"认人—— + // 被取消的那条若还留在表里,就会顶替掉真正该结算的那一条。 + act(() => { + harness.emitDirectThreadEvents({ + type: 'turn.completed', + status: 'failed', + failure: { message: '重连之前那一轮已经失败' }, + at: 4, + }); + }); + await waitFor(() => { + expect(settledAttemptIds()).toHaveLength(1); + }); + expect(settledAttemptIds()).toEqual([attempts[1]]); + }); + it('tells the user when the chip they tried to cancel has already been dispatched', async () => { // 已放行的那一条不能按待发消息取消:宿主的 typed 结果把这件事讲清楚,界面照说,不自作主张。 const { invoke, surface, harness } = await openDirectCodexSurface({