diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 246b9ab14..f0c1c847a 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -888,10 +888,25 @@ function redactDirectFailureMarkers(value: string) { .replace(/\[redacted sensitive context\]/gi, '[已隐藏敏感信息]'); } -function directCodexDiagnosticFailureDetail(message: string) { +/** + * 宿主收口文案(`direct-codex-failure:v1|v2 …`)解析出的可展示部分。 + * + * 两版都要认:v1 是 `stage=… retryable=… summary=…`,v2 在 `retryable` 前多一段 `code=…` + * (宿主现在发的是 v2)。只认 v1 时这一路永远命中不了,宿主的脱敏摘要等于白写。 + */ +type DirectDiagnosticParts = { + stageLabel: string; + summary: string; + hint: string; + retryable: boolean; +}; + +function directCodexDiagnosticFailureParts( + message: string, +): DirectDiagnosticParts | null { const trimmed = message.trim(); const match = - /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( + /^direct-codex-failure:v[12] stage=(request|art-preparation|code-generation|browser-validation|version-registration)(?: code=[a-z0-9-]+)? retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( trimmed, ); if (!match) { @@ -925,17 +940,44 @@ function directCodexDiagnosticFailureDetail(message: string) { 'browser-validation': '真实试玩未通过', 'version-registration': '项目版本登记失败', }[stage]; - if (!stageLabel) { + if (!stageLabel || !summary || !hint) { return null; } - if (!summary || !hint) { - return null; - } - return `${stageLabel}:${summary}。${hint}${ - retryable === 'true' ? '(可直接重试)' : '' + return { stageLabel, summary, hint, retryable: retryable === 'true' }; +} + +/** 收口文案的一句话:阶段标签只有"回合失败"才成立,拒单那边传 `null`(见下面的导出函数)。 */ +function directDiagnosticSentence( + parts: DirectDiagnosticParts, + stageLabel: string | null, +) { + return `${stageLabel ? `${stageLabel}:` : ''}${parts.summary}。${parts.hint}${ + parts.retryable ? '(可直接重试)' : '' }`; } +function directCodexDiagnosticFailureDetail(message: string) { + const parts = directCodexDiagnosticFailureParts(message); + return parts ? directDiagnosticSentence(parts, parts.stageLabel) : null; +} + +/** + * **拒单**的可见文案:宿主收口文案里那段已脱敏的摘要与建议。 + * + * 与 [`projectRuntimeVisibleError`] 的差别只有一处——**不带阶段标签**:阶段说的是"失败发生在交付的 + * 哪一步",而拒单是"这一轮没有开始",阶段只会是默认值(`code-generation`),套上去会把没发生的 + * 事讲成发生了。宿主的文案不是收口形状时退回同一份运行错误映射。 + */ +export function projectRuntimeVisibleRejectionError( + message: string, + subject: string, +) { + const parts = directCodexDiagnosticFailureParts(message); + return parts + ? `${subject}:${directDiagnosticSentence(parts, null)}` + : projectRuntimeVisibleError(message, subject, true); +} + function directPlatformFailureDetail(message: string) { const trimmed = message.trim(); if (!trimmed) { @@ -1197,10 +1239,27 @@ export function projectRuntimeVisibleError( if ( normalized.includes('落盘失败') || normalized.includes('持久化失败') || - normalized.includes('写入失败') + normalized.includes('写入失败') || + // 宿主"历史落盘"的事实句不带"失败"两个字:`TurnFailed` 的原因就是 + // "DirectProject 收尾历史失败:未确认历史完整落盘"这一类。 + normalized.includes('收尾历史失败') || + normalized.includes('未确认历史完整落盘') || + normalized.includes('写入本项目对话历史失败') ) { return `${subject} 保存运行记录失败,请检查项目目录后重试`; } + // 宿主 `Display` 的其余事实句:它们不含上面任何关键字,**不加模式就只会看到最后那句通用文案**。 + // 这里只认宿主写死的句首短语,不回落原文——原文里带 `exitStatus=` / `stderrClass=` 这类内部字段 + // (`TransportClosed` 就是这种)。 + if (visibleMessage.includes('执行通道已断开')) { + return `${subject} 服务连接已断开,请稍后重试`; + } + if (visibleMessage.includes('等待模型回合结束达到硬上限')) { + return `${subject} 响应超时,请稍后重试`; + } + if (visibleMessage.includes('宿主任务提前结束')) { + return `${subject} 本轮执行已中断,请重试`; + } const containsInternalDiagnostics = normalized.includes('agentllm.') || /(?:^|[\s::])kind=/.test(normalized) || diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts index 1fe7ed136..cccd824ba 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/conversation/directTurnFailure.ts @@ -44,6 +44,11 @@ export function directTurnFailureItemId( * * 事件里的 `message` 是宿主已脱敏 + 截断的原始原因,这里只做"给人看"的那一步,不再另开文案 * 规则,也不在这里判断"这算不算失败"(那由事件载荷的有没有决定)。 + * + * **不加模式就只会看到通用文案**:`projectRuntimeVisibleError` 只认它自己那份模式表,宿主换一句 + * 新的 `Display` 事实句而这边没跟着加模式时,用户拿到的就是"…执行失败,请稍后重试"。这是有意的 + * 取舍——宁可给通用文案,也不回落宿主原文(原文可能带 `exitStatus=` / `stderrClass=` 这类内部 + * 字段)。加了新模式就补一条 `agentRuntimeModel.test.ts` 的用例。 */ export function directTurnFailureNoticeText(message: string): string { const raw = typeof message === 'string' ? message.trim() : ''; diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index ea2a7e96b..c1a1ef1a1 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -15,6 +15,7 @@ import { MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE, projectRuntimeVisibleCurrentWork, projectRuntimeVisibleError, + projectRuntimeVisibleRejectionError, } from '../src/features/agent-runtime/model'; import { deriveAgentStatusCards, @@ -431,6 +432,95 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连阶段收口文案的 v2 形状也要认出脱敏摘要', () => { + // 宿主发的是 v2(比 v1 多一段 `code=`):只认 v1 时这一路永远命中不了,脱敏摘要等于白写。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=false summary=DirectProject 收尾历史失败:未确认历史完整落盘;建议:请检查项目目录后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:代码生成失败:DirectProject 收尾历史失败:未确认历史完整落盘。请检查项目目录后重试;如持续失败请检查项目诊断', + ); + // 脱敏标记照旧要换成中文标记,带路径 / 凭据的摘要照样拒收。 + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取陶泥儿画布资源失败:;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接]。请稍后重试(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v2 stage=art-preparation code=runtime-failure retryable=true summary=读取失败:https://provider.example/private;建议:请稍后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + }); + + test('拒单文案只取收口文案里的脱敏摘要与建议,不套阶段标签', () => { + // 阶段说的是"失败发生在交付的哪一步",而拒单是"这一轮没有开始":阶段只会是默认值, + // 套上去会把没发生的事讲成发生了。 + expect( + projectRuntimeVisibleRejectionError( + 'direct-codex-failure:v2 stage=code-generation code=runtime-failure retryable=true summary=Codex app-server 启动失败:找不到可执行文件;建议:请重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + ), + ).toBe( + '陶泥儿智能创作:Codex app-server 启动失败:找不到可执行文件。请重试;如持续失败请检查项目诊断(可直接重试)', + ); + // 不是收口形状时退回同一份运行错误映射(宿主 `Display` 的事实句仍然只给一句可读的话)。 + expect( + projectRuntimeVisibleRejectionError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + // 已知边界(本轮不动):映射里"拒绝"那条子串分支会先认领"拒绝访问"这类文件系统事实; + // 目录锚不定的拒单在聊天里走 `Display` 原样显示(它不在上报名单里),所以摸不到这句。 + expect( + projectRuntimeVisibleRejectionError( + '无法锚定 Direct 调用项目目录:拒绝访问', + '陶泥儿智能创作', + ), + ).toBe('陶泥儿智能创作 被项目权限或安全策略阻止,请检查审批配置'); + }); + + test('宿主 `Display` 的事实句不再掉进通用兜底', () => { + expect( + projectRuntimeVisibleError( + '执行通道已断开,不能自动重放未确认操作:Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL) stderrClass=crash', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 服务连接已断开,请稍后重试'); + expect( + projectRuntimeVisibleError( + '等待模型回合结束达到硬上限,已停止本轮并核对后台操作。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 响应超时,请稍后重试'); + expect( + projectRuntimeVisibleError( + '陶泥儿回合的宿主任务提前结束(崩溃或任务被取消),本轮已按失败收口,请重试。', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 本轮执行已中断,请重试'); + expect( + projectRuntimeVisibleError( + 'DirectProject 收尾历史失败:未确认历史完整落盘', + '陶泥儿智能创作', + true, + ), + ).toBe('陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试'); + }); + test('直连平台错误保留 HTTP 诊断字段但只显示已脱敏的敏感值', () => { const safe = projectRuntimeVisibleError( '陶泥儿美术包生成失败(规范图):请求平台图片生成失败:HTTP 401;code=invalid-token;field=authorization;message=token=[redacted-secret];detail=登录态已失效', diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 6dd1ef409..3ac659901 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -312,7 +312,9 @@ describe('DirectProject 聊天 reducer', () => { const notice = failed.history.at(-1); expect(notice?.itemId).toBe('direct-codex:turn-1:user:failure'); expect(notice?.role).toBe('assistant'); - expect(notice?.text).toBe('陶泥儿智能创作 执行失败,请稍后重试'); + expect(notice?.text).toBe( + '陶泥儿智能创作 保存运行记录失败,请检查项目目录后重试', + ); // 本轮开口条目照样按身份拿到边界(失败与正常终态同源)。 expect(selectDirectChatEntries(failed)[0]?.turnEndedAt).toBe(1_000_900); expect(selectDirectChatEntries(failed)[0]?.turnStartedAt).toBe(1_000_000);