前端:认不出的拒单也在聊天里补一条同级提示
- 新增 `directTurnUnrecognizedRejectionNoticeText`:宿主 / 环境事实的拒单在聊天里的文案取宿主收口文案的脱敏摘要与建议(`projectRuntimeVisibleRejectionError`),不是收口形状时只给一句通用兜底,机器字段不进聊天 - 控制器在「认不出的拒单」分支补写一条与用户消息同级的提示(沿用 `directTurnRejectionNoticeMessageId` 身份):拒单不产生 `turn.completed`,这条乐观用户气泡后面不会再有事件来解释它;上报与横幅照旧保留 - 修正该处注释「聊天里的失败说明不由这里写」——那条只对回合失败成立,拒单没有终态出口;同时把非结构化错误继续只走横幅的理由写清楚 - 用例:`chat-composer.suite.ts` 补一条结构化拒单的界面用例(同级提示可见、`direct-codex-failure` / `stage=` 不进聊天、忙碌态放掉);`project-conversation.suite.ts` 补该文案函数的单元断言
This commit is contained in:
+26
-11
@@ -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;
|
||||
|
||||
+21
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user