From 85d69a29a96eefcfc4c7ed33d41e5f39f787176a 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 16:21:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E8=AE=A4=E4=B8=8D?= =?UTF-8?q?=E5=87=BA=E7=9A=84=E6=8B=92=E5=8D=95=E4=B9=9F=E5=9C=A8=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E9=87=8C=E8=A1=A5=E4=B8=80=E6=9D=A1=E5=90=8C=E7=BA=A7?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `directTurnUnrecognizedRejectionNoticeText`:宿主 / 环境事实的拒单在聊天里的文案取宿主收口文案的脱敏摘要与建议(`projectRuntimeVisibleRejectionError`),不是收口形状时只给一句通用兜底,机器字段不进聊天 - 控制器在「认不出的拒单」分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份):拒单不产生 `turn.completed`,这条乐观用户气泡后面不会再有事件来解释它;上报与横幅照旧保留 - 修正该处注释「聊天里的失败说明不由这里写」——那条只对回合失败成立,拒单没有终态出口;同时把非结构化错误继续只走横幅的理由写清楚 - 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉);`project-conversation.suite.ts` 补该文案函数的单元断言 --- .../useDirectProjectChatController.ts | 37 ++++++++++++------ .../conversation/directCodexConversation.ts | 21 ++++++++++ .../tests/appSurface/chat-composer.suite.ts | 38 +++++++++++++++++++ .../appSurface/project-conversation.suite.ts | 19 ++++++++++ 4 files changed, 104 insertions(+), 11 deletions(-) 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 7a3423117..5feb91e9f 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 @@ -41,6 +41,7 @@ import { type DirectProjectTurnInput, directTurnRejectionNotice, directTurnRejectionNoticeMessageId, + directTurnUnrecognizedRejectionNoticeText, readDirectTurnRejection, } from '../conversation/directCodexConversation'; import { DIRECT_CODEX_SESSION_KEEPALIVE_MS } from '../conversation/directCodexSessionKeepalive'; @@ -701,19 +702,33 @@ export function useDirectProjectChatController({ if (projectPathRef.current !== nextProjectPath) return false; // 认不出的拒单(宿主 / 环境事实)与其它非结构化错误走同一条通道:上报 + 横幅。 void captureAgentRuntimeError(error, DIRECT_CODEX_AGENT_ID); - // 拒单文案优先:它是宿主 `Display` 生成的唯一一份,比 `Error` 的形状更可信。 - let message = error instanceof Error ? error.message : String(error); - if (rejection) message = rejection.message; + // 拒单文案优先:它是宿主生成的唯一一份(`Display` 或脱敏收口文案),比 `Error` 的形状更可信; + // 拒单不带阶段标签(这一轮没有开始),所以走拒单那一份映射。 + const visibleMessage = rejection + ? directTurnUnrecognizedRejectionNoticeText(rejection) + : projectRuntimeVisibleError( + error instanceof Error ? error.message : String(error), + '陶泥儿智能创作', + true, + ); + if (projectPathRef.current !== nextProjectPath) return false; + // 回合失败的说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它落成 + // 本轮最后一条条目(唯一来源)。**拒单没有这条出口**——拒单不产生回合事件,聊天里那条乐观 + // 用户气泡后面永远不会再有任何说明,所以这里必须补一条同级提示;上报与横幅照旧保留。 + // 非结构化错误同样不写聊天:它可能发生在接单之后,说明由事件流负责。 + if (rejection) { + appendLocalMessage({ + role: 'assistant', + text: visibleMessage, + runtimeOwned: true, + messageId: directTurnRejectionNoticeMessageId( + directCodexConversationMessageId(input.clientTurnId, 'user'), + ), + updatedAt: Date.now(), + }); + } // 不再展开诊断详情:文案里没有引用,前端也不去读那份文件。线索留在 // `.agent/runtime/errors`、应用日志与错误上报池里,界面只显示这一句话。 - const visibleMessage = projectRuntimeVisibleError( - message, - '陶泥儿智能创作', - true, - ); - if (projectPathRef.current !== nextProjectPath) return false; - // 聊天里的失败说明不由这里写:宿主已经把它放进了 `turn.completed.failure`,reducer 会把它 - // 落成本轮最后一条条目(唯一来源)。这里只保留运行错误横幅与诊断留痕。 onRuntimeError(visibleMessage); } return turnAccepted; diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts index 0285a1692..10c347789 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directCodexConversation.ts @@ -1,4 +1,5 @@ import type { ChatMessage } from '../../../../app/types'; +import { projectRuntimeVisibleRejectionError } from '../../../../features/agent-runtime'; import type { HomeCreationType } from '../../../home'; import type { DirectCodexUserItem } from '../generated/DirectCodexUserItem'; import type { DirectTurnRejection } from '../generated/DirectTurnRejection'; @@ -121,3 +122,23 @@ export function directTurnRejectionNotice( export function directTurnRejectionNoticeMessageId(userItemId: string) { return `${userItemId}:rejected`; } + +/** + * **认不出的**拒单(宿主 / 环境事实)在聊天里与用户消息同级的提示文案。 + * + * 这两类拒单不产生 `turn.completed`(拒单没有接单),所以聊天里那条乐观用户气泡后面不会再有任何 + * 事件来解释它——说明只能在这里补一条,否则用户只看得到一条会消失的横幅。上报与横幅照旧保留: + * 两件事不是同一份(一个是给用户看的话,一个是把现场送进上报池与 `.agent/runtime/errors`)。 + * + * 文案不能原样用宿主给的 `message`:这类拒单的 `message` 是宿主的收口文案(带 `stage=` / `code=` + * 这类机器字段),先过与失败说明同一份可见文案映射再进聊天;映射认不出形状时给一句通用兜底, + * 绝不把内部字段塞进聊天。 + */ +export function directTurnUnrecognizedRejectionNoticeText( + rejection: DirectTurnRejection, +): string { + return projectRuntimeVisibleRejectionError( + rejection.message, + '陶泥儿智能创作', + ); +} 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 d84899d39..3fca5fa4c 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 @@ -440,6 +440,44 @@ export function registerChatComposerControlTests() { } }); + it('writes a same-level chat notice when the host rejects a turn without a turn event', async () => { + // 宿主 / 环境事实的拒单没有接单、也就不产生 `turn.completed`:聊天里那条乐观用户气泡后面不会 + // 再有任何事件来解释它,说明必须由命令边界补一条;上报与横幅照旧保留。 + const { surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: () => { + throw { + error: { + type: 'environmentNotReady', + detail: 'Codex app-server 启动失败:找不到可执行文件', + }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }; + }, + }); + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '起不来也要说一声'); + const conversation = await within(surface).findByLabelText('陶泥儿消息'); + await waitFor(() => { + expect( + within(conversation).getByText( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ), + ).not.toBeNull(); + }); + // 机器字段(`direct-codex-failure:v2` / `stage=` / `code=`)只留在宿主侧。 + expect(conversation.textContent ?? '').not.toContain( + 'direct-codex-failure', + ); + expect(conversation.textContent ?? '').not.toContain('stage='); + // 拒单没接单:忙碌态要放掉,用户能直接重发。 + await waitFor(() => { + expect( + within(surface).getByRole('button', { name: '发送' }), + ).not.toBeNull(); + }); + }); + it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const { invoke, surface, harness } = await openDirectCodexSurface({ // 命令只接单(接单时 Thread Manager 已经发过开始事件),随后立刻返回。 diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts index d4f1bde87..36050745d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-conversation.suite.ts @@ -2,6 +2,7 @@ import { directCodexUserItemFromContent } from '../../src/features/project-works import { directCodexPolicyRetryInput, directTurnRejectionNotice, + directTurnUnrecognizedRejectionNoticeText, readDirectTurnRejection, } from '../../src/view/project-development/chat/conversation/directCodexConversation'; import { @@ -54,6 +55,24 @@ export function registerProjectConversationTests() { message: '宿主状态取不到', }), ).toBeNull(); + // 认不出的拒单在聊天里也要有一条同级提示:它们的 `message` 是宿主的收口文案(带 stage= / + // code= 这类机器字段),进聊天前先取脱敏摘要与建议,机器字段不进聊天。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'environmentNotReady', detail: '连不上 app-server' }, + message: + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + }), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断', + ); + // 不是收口形状时也只给一句可读的话,绝不回落带机器字段的原文。 + expect( + directTurnUnrecognizedRejectionNoticeText({ + error: { type: 'hostStateUnavailable', detail: '账本损坏' }, + message: '宿主状态取不到:exitStatus=signal: 9 (SIGKILL)', + }), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); // 不是这份结构(旧字符串、Error、裸对象)一律不认。 expect( readDirectTurnRejection('codex-app-server-error:unauthorized'),