前端:认不出的拒单也在聊天里补一条同级提示

- 新增 `directTurnUnrecognizedRejectionNoticeText`:宿主 / 环境事实的拒单在聊天里的文案取宿主收口文案的脱敏摘要与建议(`projectRuntimeVisibleRejectionError`),不是收口形状时只给一句通用兜底,机器字段不进聊天
- 控制器在「认不出的拒单」分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份):拒单不产生 `turn.completed`,这条乐观用户气泡后面不会再有事件来解释它;上报与横幅照旧保留
- 修正该处注释「聊天里的失败说明不由这里写」——那条只对回合失败成立,拒单没有终态出口;同时把非结构化错误继续只走横幅的理由写清楚
- 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉);`project-conversation.suite.ts` 补该文案函数的单元断言
This commit is contained in:
2026-09-24 16:21:35 +08:00
parent b42966eb9e
commit 85d69a29a9
4 changed files with 104 additions and 11 deletions
@@ -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;
@@ -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,
'陶泥儿智能创作',
);
}
@@ -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 已经发过开始事件),随后立刻返回。
@@ -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'),