Skip to content

[chat-engine] ChatEngine.handleComplete 扫描全部消息,历史失败消息会把之后每一轮正常回答误判为 error #4479

Open

Description

@hzkkk

tdesign-react 版本

  • tdesign-web-components: 1.3.0-alpha.2(项目在用)、1.3.1-alpha.14(最新 alpha,同样存在) - @tdesign/ai-chat-engine: 0.1.0(同样存在) - @tdesign-react/chat: 1.0.2 - 协议: agui(protocol: 'agui',流式) - Node: v24.3.0(复现脚本)/ v20.19.4(业务构建) - 浏览器: Chrome 最新版 - 系统: macOS

重现链接

No response

重现步骤

No response

期望结果

No response

实际结果

No response

框架版本

No response

浏览器版本

No response

系统版本

No response

Node版本

No response

补充说明

问题描述

ChatEngine.handleComplete 在正常完成分支判断本轮是否失败时,扫描的是整个会话的所有消息,而不是当前这条消息。因此只要会话里曾经出现过任何一条失败消息(例如上一轮请求失败、或从历史记录恢复出来的失败回答),此后每一轮即使上游正常结束(RUN_FINISHED、无 RUN_ERROR 帧),本轮也会被判为 error。

进一步地,setMessageStatus(id, 'error') 会把本轮消息最后一个内容块的 status 同步改成 error。由于末块通常就是本轮回答正文,业务侧据此落库/渲染时会把正文当作错误信息处理:正文丢失、界面显示“请求出错”。

相关源码

esm/chat-engine/index.js(lib、cjs 产物同):

// handleComplete 正常完成分支
const allContentFailed = this.messageStore.messages.find(
  (content) => content.status === "error"
);
this.setMessageStatus(id, isAborted ? "stop" : allContentFailed ? "error" : "complete");

store/message.js:

setMessageStatus(messageId, status) {
  // ...
  message.status = status;
  const lastContent = message.content[message.content.length - 1];
  if ("status" in lastContent && lastContent.status !== "error") {
    lastContent.status = status;   // 末内容块被同步改成 error
  }
}

变量名 allContentFailed、回调参数名 content 看起来原意是想检查本条消息的内容块,但实现变成了遍历全部消息。

重现步骤

最小复现(Node 环境,无需浏览器):

mkdir td-repro && cd td-repro
npm init -y
npm i tdesign-web-components@1.3.1-alpha.14 immer

repro.mjs:

import ChatEngine from 'tdesign-web-components/lib/chat-engine/index.js';

const engine = new ChatEngine();
engine.init(() => ({}), [
  // 历史中已有一条失败回答
  {
    id: 'old-failed',
    role: 'assistant',
    status: 'error',
    content: [{ type: 'text', data: '上一轮失败', status: 'error' }],
  },
  // 本轮:上游正常结束,正文已完整写入
  {
    id: 'current',
    role: 'assistant',
    status: 'streaming',
    content: [{ type: 'text', data: '本轮正常回答正文', status: 'streaming' }],
  },
]);

// RUN_FINISHED 时引擎内部调用的同一个入口
engine.handleComplete('current', false, {}, undefined);

const current = engine.messageStore.getMessageByID('current');
const lastBlock = current.content[current.content.length - 1];
console.log('current.status  :', current.status);
console.log('lastBlock.status:', lastBlock.status);
console.log('lastBlock.data  :', lastBlock.data);

执行 node repro.mjs。

业务侧更容易触发的操作路径:

  1. 在同一个会话里让某一轮回答失败(断网、后端 5xx 均可),不要点“重新发送”,也不要新建会话;
  2. 保持会话不变,直接再问一个新问题,让上游正常返回;
  3. 观察本轮回答:上游日志显示成功收尾,但界面显示“请求出错”,正文消失。

刷新页面后同样能触发:历史失败回答会以 status: 'error' 恢复进引擎,之后每一轮都会被污染。若业务方在重新发送时会先移除失败回答,则不会触发,这也是该问题容易被自测漏掉的原因。

期望结果

本轮终态只取决于当前这条消息自身是否失败:current.status === 'complete'、末内容块 status === 'complete',正文保留。

实际结果

current.status === 'error'、末内容块 status === 'error'(两个版本均复现):

version=1.3.0-alpha.2  current.status=error lastBlock.status=error data=本轮正常回答正文
version=1.3.1-alpha.14 current.status=error lastBlock.status=error data=本轮正常回答正文

建议修复

在 handleComplete 里把判定范围从“全部消息”收窄到“当前消息”,同时覆盖“消息状态已标记 error”和“仅内容块被标记 error”两种情况:

const currentMessage = this.messageStore.getMessageByID(id);
const allContentFailed =
  !!currentMessage &&
  (currentMessage.status === "error" ||
    (Array.isArray(currentMessage.content) &&
      currentMessage.content.some((content) => content?.status === "error")));

this.setMessageStatus(id, isAborted ? "stop" : allContentFailed ? "error" : "complete");

补充:原实现在“本轮末块已是 error、但消息状态尚未标记 error”的情况下会误判为 complete,上述写法一并修正。

补充说明

  • 该问题也存在于拆分后的独立包 @tdesign/ai-chat-engine@0.1.0(handleComplete 中仍是 this.messageStore.messages.find((content) => content.status === "error")),一并排查会更彻底。
  • handleComplete 目前是 private,业务侧只能通过替换实例方法的 monkey patch 方式规避,升级时容易静默失效,因此希望上游修正。
  • 临时规避方式(业务侧):不使用 status === 'stop' 作为历史失败消息的恢复状态(会渲染成“已停止”),也不要用 onComplete 返回 { status: 'complete' }(processMessageResult 只改内容不改消息状态,会停在 pending)。目前可行的是依赖补丁修改该判定,或在本轮结束后自行纠正终态。

环境信息

  • tdesign-web-components: 1.3.0-alpha.2(项目在用)、1.3.1-alpha.14(最新 alpha,同样存在)
  • @tdesign/ai-chat-engine: 0.1.0(同样存在)
  • @tdesign-react/chat: 1.0.2
  • 协议: agui(protocol: 'agui',流式)
  • Node: v24.3.0(复现脚本)/ v20.19.4(业务构建)
  • 浏览器: Chrome 最新版
  • 系统: macOS

Activity

  1. github-actions commented on Nov 12, 2026

    @github-actions
    Contributor

    👋 @hzkkk,感谢给 TDesign 提出了 issue。
    请根据 issue 模版确保背景信息的完善,我们将调查并尽快回复你。

  2. lnuxe commented on Nov 12, 2026

    @lnuxe

    已定位并修复:修复 PR → TDesignOteam/tdesign-ai-core#42(tdesign-react 的 packages/ai-core 子模块即该仓库,源码同源)。

    根因(与楼主分析一致):handleComplete 的 allContentFailed = messageStore.messages.find((content) => content.status === 'error') 扫描的是整个会话的消息数组——历史任何一条 error 消息都会让后续每一轮正常收尾被判为 error,再经 setMessageStatus 把本轮末内容块污染成 error。

    修复:结算范围收窄到当前消息——getMessageByID(id).content.some(c => c.status === 'error')。

    验证(本地 vitest,修复前 → 修复后):

    修复前修复后
    currentStatus : error   ← 被历史失败污染
    lastBlock.status: error ← 正文块被覆盖
    lastBlock.data : 本轮正常回答正文
    
    ✗ 2 条回归测试失败
    expected 'error' to be 'complete'
    expected 'complete' to be 'error'
    
    currentStatus : complete
    lastBlock.status: complete
    lastBlock.data : 本轮正常回答正文
    
    ✓ 24 tests passed (chat-engine)
    ✓ 全仓 40 files / 425 tests passed
    ✓ eslint --max-warnings 0 / tsc --noEmit 干净
    

    修复同时落实了该测试文件里原有的 it.todo('完成消息时忽略无关的更早消息上的错误状态'),新增 2 条回归测试锁定两个方向:历史失败不外溢 + 当前内容块失败仍正确判 error。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions