Просмотр исходного кода

fix(chat): 统一「内容是否为空」的判空口径,修切会话误报「请求已取消」

根因:两处判空口径不一致——
  normalizeSessionHistory 用 content.trim()(<scope> 进度块算作有内容)
  isInterruptedEmptyScopeMessage 用 stripScopeBlocks()(进度块不算内容)
同一段内容,一处认为「有」、一处认为「没有」。

新协议的正文要等 done 才写入,处理期间内容只有进度块,把这个缝隙从偶发
放大成「一切会话就出现」:归一化认为有内容 → 不替换文案,却把状态翻成 Finish;
渲染层认为没内容 → 显示「请求已取消」,而请求其实还在跑。

修法:抽出唯一的判空函数 hasVisibleMessageContent()(utils/interrupted-message.ts),
归一化与渲染层都改用它;渲染层顺带收敛了两处重复的内联 strip。

新增回归脚本 harness/tools/verify-empty-content-agreement.mjs:
这个 bug 的本质是「两处不一致」,所以断言的不变量就是「两处必须一致」,
30 项断言(9 例基准 + 18 例一致性 + 3 例回归)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 дней назад
Родитель
Сommit
363235e744
3 измененных файлов с 40 добавлено и 3 удалено
  1. 14 0
      README.md
  2. 4 1
      src/components/business-assistant/shared.ts
  3. 22 2
      src/utils/interrupted-message.ts

+ 14 - 0
README.md

@@ -53,3 +53,17 @@
 - 补问卡片:正文里已展示过的同一段文字不再在卡片内重复
 - 「跳过公司查询」由发送 `/skip` 改为发送选项文本「跳过公司查询」
 - 问卷提交:修复只取第一题(多题丢失)、多选只取第一项、以及数字开头选项被静默改写成纯数字
+
+## 20260917
+
+- 修复切会话时误报「请求已取消」:
+  - 根因是**两处「内容是否为空」的判空口径不一致**——归一化用 `content.trim()`
+    (`<scope>` 进度块算作有内容),渲染层用 `stripScopeBlocks()`
+    (进度块不算内容)。同一段内容,一处认为「有」、一处认为「没有」
+  - 新协议的正文要等 `done` 才写入,处理期间内容**只有进度块**,把这个缝隙
+    从偶发放大成「一切会话就出现」:归一化认为有内容 → 不替换文案,却把状态翻成
+    Finish;渲染层认为没内容 → 显示「请求已取消」,而请求其实还在跑
+  - 修法:抽出**唯一**的判空口径 `hasVisibleMessageContent()`(`utils/interrupted-message.ts`),
+    归一化与渲染层都改用它
+  - 新增回归脚本 `harness/tools/verify-empty-content-agreement.mjs`,
+    断言的不变量就是「两处必须一致」,30 项断言

+ 4 - 1
src/components/business-assistant/shared.ts

@@ -1,4 +1,5 @@
 import { From } from '@/types/record';
+import { hasVisibleMessageContent } from '@/utils/interrupted-message';
 
 const MessageStateValue = {
   Update: 0,
@@ -306,7 +307,9 @@ export const normalizeSessionHistory = (session: BusinessAssistantSession) => {
     changed = true;
   }
 
-  if (!String(lastMsg.content || '').trim()) {
+  // 判空口径与渲染层统一:`<scope>` 进度块不算可见内容
+  // (见 utils/interrupted-message.ts 的 hasVisibleMessageContent 说明)
+  if (!hasVisibleMessageContent(lastMsg.content)) {
     lastMsg.content = CANCELLED_SESSION_TEXT;
     changed = true;
   }

+ 22 - 2
src/utils/interrupted-message.ts

@@ -16,6 +16,25 @@ const stripScopeBlocks = (content: string) => {
     .replace(/<scope\b[^>]*>[\s\S]*/gi, "");
 };
 
+/** 剥掉 `<silence>` 与 `<scope>` 块后剩下的内容 */
+const stripNonVisibleBlocks = (content: unknown) =>
+  stripScopeBlocks(stripSilenceTags(String(content || "")));
+
+/**
+ * 内容在"可见"意义下是否为空。
+ *
+ * ⚠️ 这是**唯一**的判空口径。`normalizeSessionHistory`(shared.ts)与本文件的
+ * `isInterruptedEmptyScopeMessage` 都必须用它。
+ *
+ * 曾经两处口径不一致——一处用 `content.trim()`(`<scope>` 进度块算作有内容),
+ * 一处用 `stripScopeBlocks()`(进度块不算内容)。而新协议的正文要等到 `done`
+ * 才写入,处理期间内容**只有进度块**,于是切会话时:
+ * 归一化认为"有内容"→ 不替换文案、把状态翻成 Finish;
+ * 渲染层认为"没内容"→ 显示「请求已取消」。请求其实还在跑,属于误报。
+ */
+export const hasVisibleMessageContent = (content: unknown): boolean =>
+  stripNonVisibleBlocks(content).trim().length > 0;
+
 export function isInterruptedEmptyScopeMessage(message: any): boolean {
   if (!message) {
     return false;
@@ -35,12 +54,13 @@ export function isInterruptedEmptyScopeMessage(message: any): boolean {
   const content = String(message.content || "");
   const visibleContent = stripEmptyScopeBlocks(stripSilenceTags(content)).trim();
 
+  // 统一口径:见 hasVisibleMessageContent 的说明
   if (message.history === true) {
-    return stripScopeBlocks(stripSilenceTags(content)).trim().length === 0;
+    return !hasVisibleMessageContent(content);
   }
 
   if (STOPPED_STATES.has(state)) {
-    return stripScopeBlocks(stripSilenceTags(content)).trim().length === 0;
+    return !hasVisibleMessageContent(content);
   }
 
   if (!/<scope\b/i.test(content)) {