Pārlūkot izejas kodu

fix(chat): 修任务表响应式陷阱 —— mock 流跑完按钮不恢复(真实对话也被波及)

用户报:点 mock 测试按钮后内容输出完了,按钮仍是"运行中"。

根因是我上一轮引入的响应式陷阱:任务表写成了 `ref(new Map())`。
**ref 会把 Map 变成响应式代理,`tasks.value.get(id)` 返回的是代理、
不是存进去的那个对象**(实测 `ref(new Map()).get(k) === raw` → false,
shallowRef → true)。而所有监听器里的守卫都是
`tasks.value.get(sessionId) !== task` → **永远成立** → 监听器全部提前返回:

- mock 流:finishTask 从不执行 → 任务永驻 → isGenerating 恒真 → 按钮卡在"停止"
- **真实对话:message 监听器同样提前返回 → 内容一帧都不追加**
  (比用户报告更严重的一面,本轮一并修掉)

修法:
- tasks 改 `shallowRef(new Map())` + 整体替换;新增纯函数 withTaskAdded /
  withTaskRemoved(复制出新 Map,不改原 Map),4 处写入点全部改用
- 在 chat-generation-task.ts 末尾写清这个坑(含实测结论),防止被"简化"回去
- _entry-chat-tasks.ts 从 vue 重导出 ref/shallowRef/computed(仅测试用)

回归断言(verify-chat-task-utils 24 → 33 项):
- 不可变替换语义(新 Map 有/原 Map 没有;取出来就是存进去的那个对象)
- **把陷阱本身钉死**:断言 ref(new Map()) 会破坏同一性、shallowRef 不会、
  整体替换能触发 computed、原地 mutate 不触发响应式

验证:npm run build 通过;verify-chat-task-utils 33/33;
其余全绿(11/31/8/10/21/30);validate-harness 通过。
⚠️ 浏览器需复验:mock 按钮 + **真实对话**(两者都被这个 bug 波及)。

另:同轮修正一处我自己的测试错误——断言 computed 惰性时忘了先求值填充缓存,
是测试写错不是代码问题。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 3 dienas atpakaļ
vecāks
revīzija
94c8c3c156

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 1 - 2
harness/feature_list.json


+ 37 - 0
harness/progress.md

@@ -28,6 +28,43 @@
 
 ## 会话记录
 
+## Session 030
+
+- **日期**:2026-09-17
+- **本轮目标**:修用户报告的「mock 测试点击后内容输出完,按钮仍是运行时的按钮」
+- **根因(我上一轮引入的响应式陷阱)**:
+  - 任务表当时写成 `ref(new Map())`。**`ref` 会把 Map 变成响应式代理,
+    `tasks.value.get(id)` 返回的是代理、不是存进去的那个对象**。
+    实测:`ref(new Map()).get('A') === 原始对象` → **false**;`shallowRef` → true。
+  - 而我在**所有监听器**里写的守卫是 `tasks.value.get(sessionId) !== task` →
+    **永远成立** → 监听器全部提前返回
+  - 后果:mock 流的 `finishTask` 从不执行 → 任务永驻 → `isGenerating` 恒真 →
+    **按钮卡在"停止"态**(用户看到的);**真实对话的内容追加也被同一个守卫挡掉**
+    (`message` 监听器同样提前返回)—— 这是比用户报告更严重的一面,同轮一并修掉
+- **修法**:
+  - `tasks` 改 **`shallowRef(new Map())` + 整体替换**;新增纯函数
+    `withTaskAdded` / `withTaskRemoved`(复制出新 Map,不改原 Map),4 处写入点全部改用
+  - 在 `chat-generation-task.ts` 末尾写清这个坑(含实测结论),防止以后被"简化"回去
+- **新增回归断言**(`verify-chat-task-utils.mjs` 第 6、7 节,脚本从 24 → **33 项**):
+  - 不可变替换语义(新 Map 有、原 Map 没有;取出来就是存进去的那个对象)
+  - **把陷阱本身钉死**:断言 `ref(new Map()).get(k) !== raw`(若哪天 Vue 行为变了这条会失败,
+    提示需重新评估写法)、`shallowRef` 下同一性保持、整体替换能触发 computed、
+    原地 mutate 不触发响应式
+  - 为此 `_entry-chat-tasks.ts` 从 vue 重导出 `ref/shallowRef/computed`(**仅测试用**)
+- **运行过的验证**:
+  - `npm run build` 通过
+  - `verify-chat-task-utils.mjs` **33/33**;其余全绿(11/31/8/10/21/30);`validate-harness` 通过
+- **已记录证据**:本文件 Session 030;`verify-chat-task-utils.mjs` 第 6、7 节
+- **更新过的文件或工件**:`src/components/business-assistant/chat-generation-task.ts`、
+  `src/components/business-assistant/useBusinessAssistantChat.ts`、
+  `harness/tools/{_entry-chat-tasks.ts,verify-chat-task-utils.mjs}`、`harness/feature_list.json`、本文件
+- **已知风险或未解决问题**:
+  - ⚠️ **浏览器仍未人工验证**:请重新点一遍 mock 测试按钮(内容输出完按钮应恢复成"发送"),
+    **并请务必试一次真实对话**(这是被我引入的同一个 bug 波及、本轮修好的部分)
+  - ⚠️ 同轮还修了一处我自己的测试错误:断言 computed 惰性时忘了先求值填充缓存
+    (computed 不先读就没有缓存可失效),已修正——**是测试写错,不是代码问题**
+- **下一步最佳动作**:浏览器复验 mock 按钮 + 真实对话
+
 ## Session 029
 
 - **日期**:2026-09-17

+ 11 - 1
harness/tools/_entry-chat-tasks.ts

@@ -1,7 +1,17 @@
-/** esbuild 入口:按会话并行生成的纯决策函数 */
+/**
+ * esbuild 入口:按会话并行生成的纯决策函数与任务表更新函数。
+ *
+ * 另外从 vue 重导出 `ref` / `shallowRef` / `computed`:验证脚本要用它们复现
+ * 「响应式容器破坏对象同一性」这个陷阱(见 verify-chat-task-utils.mjs 最后一节)。
+ * 那是**测试用**的,业务代码里 vue 的引用仍在 composable 内。
+ */
 export {
   isSessionGenerating,
   resolveSendAction,
   resolveStopTargetTaskId,
   shouldNormalizeSessionHistory,
+  withTaskAdded,
+  withTaskRemoved,
 } from '../../src/components/business-assistant/chat-generation-task';
+
+export { ref, shallowRef, computed } from 'vue';

+ 49 - 0
harness/tools/verify-chat-task-utils.mjs

@@ -16,6 +16,11 @@ import {
   resolveSendAction,
   resolveStopTargetTaskId,
   shouldNormalizeSessionHistory,
+  withTaskAdded,
+  withTaskRemoved,
+  ref,
+  shallowRef,
+  computed,
 } from './_chat-tasks.mjs';
 
 let pass = 0;
@@ -80,5 +85,49 @@ check('A、B 同时生成:在 C 仍可发', resolveSendAction(parallel, 'C') =
 check('A、B 同时生成:停 A 不影响 B', resolveStopTargetTaskId(parallel, 'A') === 'A' && isSessionGenerating(parallel, 'B'));
 check('A、B 都在生成 → 两者都不归一化', shouldNormalizeSessionHistory(parallel, 'A') === false && shouldNormalizeSessionHistory(parallel, 'B') === false);
 
+console.log('\n【6】任务表更新(不可变替换,不改原 Map)');
+{
+  const t1 = stubTask('A');
+  const m0 = new Map();
+  const m1 = withTaskAdded(m0, 'A', t1);
+  check('加任务后:新 Map 有、原 Map 没有', m1.has('A') && !m0.has('A'));
+  check('取出来的就是存进去的那个对象(同一性)', m1.get('A') === t1);
+  const m2 = withTaskRemoved(m1, 'A');
+  check('移除后:新 Map 没有、原 Map 仍有', !m2.has('A') && m1.has('A'));
+  check('null / undefined 入参不抛错', withTaskAdded(null, 'A', t1).has('A') && withTaskRemoved(undefined, 'A').size === 0);
+}
+
+console.log('\n【7】⚠️ 响应式陷阱回归(这个坑真踩过)');
+{
+  // 背景:任务表曾写成 `ref(new Map())`,于是 `tasks.value.get(id) !== task` 永远成立
+  // → 监听器全部提前返回 → mock 流跑完按钮不恢复、真实对话内容一帧不追加。
+  // 下面几条把「必须用 shallowRef + 整体替换」钉死。
+  const task = stubTask('A');
+
+  const reactiveMap = ref(new Map());
+  reactiveMap.value.set('A', task);
+  check(
+    'ref(new Map()) 取出来的**不是**原对象(这正是当年的 bug 源)',
+    reactiveMap.value.get('A') !== task,
+    '若这条失败说明 Vue 行为变了,需重新评估任务表写法'
+  );
+
+  const shallowMap = shallowRef(new Map());
+  shallowMap.value = withTaskAdded(shallowMap.value, 'A', task);
+  check('shallowRef + 整体替换:取出来就是原对象', shallowMap.value.get('A') === task);
+
+  const size = computed(() => shallowMap.value.size);
+  check('初始 size = 1', size.value === 1, String(size.value));
+  shallowMap.value = withTaskRemoved(shallowMap.value, 'A');
+  check('移除后 computed 感知到 size 变 0(响应式没丢)', size.value === 0, String(size.value));
+  check('原地 mutate 不触发响应式(所以必须整体替换)', (() => {
+    const s = shallowRef(new Map());
+    const c = computed(() => s.value.size);
+    const before = c.value; // ⚠️ 先求值填充缓存(computed 是惰性的,不先读就没缓存可失效)
+    s.value.set('B', task); // 原地 set,不换 Map
+    return before === 0 && c.value === 0; // 缓存仍在旧值 → 证明徒手 mutate 不会通知
+  })());
+}
+
 console.log(`\n===== 通过 ${pass} 项,失败 ${fail} 项 =====`);
 process.exit(fail ? 1 : 0);

+ 35 - 0
src/components/business-assistant/chat-generation-task.ts

@@ -82,3 +82,38 @@ export const shouldNormalizeSessionHistory = (
   tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
   sessionId: string | null | undefined
 ): boolean => !isSessionGenerating(tasks, sessionId);
+
+// ─── 任务表的更新方式(⚠️ 这里踩过一个坑,别改回去)─────────────────────
+//
+// 任务表必须用 **`shallowRef(new Map())` + 整体替换**,不能用 `ref(new Map())`:
+//
+//   ref(new Map()).get(id) 返回的是**响应式代理**,不是存进去的那个对象,
+//   于是 `tasks.value.get(id) !== task` 这种「取出来的还是不是我存的那个」判断
+//   **永远成立** —— 监听器会全部提前返回,表现为:mock 流跑完了按钮还停在
+//   "运行中"、真实对话的内容一帧都不追加。
+//
+//   (实测:`ref(new Map()).get(k) === raw` 为 false;shallowRef 为 true。)
+//
+// shallowRef 不代理内部值,所以取出来的就是原对象;但**原地 mutate 不会触发响应式**,
+// 因此下面两个函数用「复制 + 替换」的方式产出新 Map,由调用方赋值给 `tasks.value`。
+
+/** 产出一个「加了这个任务」的新 Map(不改原 Map) */
+export const withTaskAdded = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  sessionId: string,
+  task: ChatGenerationTask
+): Map<string, ChatGenerationTask> => {
+  const next = new Map(tasks ?? []);
+  next.set(sessionId, task);
+  return next;
+};
+
+/** 产出一个「移除了这个任务」的新 Map(不改原 Map) */
+export const withTaskRemoved = (
+  tasks: ReadonlyMap<string, ChatGenerationTask> | null | undefined,
+  sessionId: string
+): Map<string, ChatGenerationTask> => {
+  const next = new Map(tasks ?? []);
+  next.delete(sessionId);
+  return next;
+};

+ 16 - 9
src/components/business-assistant/useBusinessAssistantChat.ts

@@ -1,4 +1,4 @@
-import { computed, nextTick, provide, ref, watch } from "vue";
+import { computed, nextTick, provide, ref, shallowRef, watch } from "vue";
 import cardAPI from '@/network/api/card/index';
 import { isZhaoshangLLM } from '@/utils';
 import { getChatApiBaseUrl } from '@/utils/runtime-config';
@@ -8,6 +8,8 @@ import { ApiChatCoordinator, toChatQuestionAnswer, type ChatInterruptPayload } f
 import {
   ChatGenerationTask,
   isSessionGenerating,
+  withTaskAdded,
+  withTaskRemoved,
   resolveSendAction,
   resolveStopTargetTaskId,
   shouldNormalizeSessionHistory,
@@ -54,10 +56,15 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
   /**
    * 按会话并行的生成任务表(key = sessionId)。
    *
-   * 用 `ref(new Map())` 而不是 `reactive(new Map())`:只需要 `.size` 这一个响应式依赖,
-   * reactive 会把 task(含协调器实例)整体 proxy 化,既无意义又可能出怪问题。
+   * ⚠️ 必须是 **`shallowRef` + 整体替换**(用 withTaskAdded/withTaskRemoved),
+   * 不能用 `ref(new Map())` 也不能原地 `.set()/.delete()`:
+   *   · `ref(new Map())` 取出来的值是**响应式代理**,`tasks.value.get(id) !== task`
+   *     这种同一性判断会永远成立 → 监听器全部提前返回(曾因此让 mock 流跑完不清理、
+   *     真实对话内容一帧不追加)
+   *   · shallowRef 下原地 mutate 不触发响应式 → 所以每次换一个新 Map
+   * 详见 chat-generation-task.ts 末尾的说明。
    */
-  const tasks = ref(new Map<string, ChatGenerationTask>());
+  const tasks = shallowRef(new Map<string, ChatGenerationTask>());
   /** 最近一次补问数据,按会话存(补问提交发生在上一轮结束之后,协调器实例已销毁) */
   const sessionInterruptPayloads = new Map<string, ChatInterruptPayload>();
   /** 是否**有任何**会话在生成(mock 测试按钮等全局场景用) */
@@ -251,7 +258,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
 
     task.chunkBuffer.flush();
     task.chunkBuffer.cancel();
-    tasks.value.delete(task.sessionId);
+    tasks.value = withTaskRemoved(tasks.value, task.sessionId);
 
     // 补问提交发生在**下一轮**(协调器实例已销毁),所以把最近一次补问数据留在会话上
     if (task.coordinator?.lastInterruptPayload) {
@@ -449,7 +456,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       baseUrl: getChatApiBaseUrl(),
       threadId: session.id,
     });
-    tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
+    tasks.value = withTaskAdded(tasks.value, session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
 
     saveHistory();
     await options.afterSend?.();
@@ -487,7 +494,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
     const mockTask = createMockTask(session.id, pendingAiMessage.id);
-    tasks.value.set(session.id, mockTask);
+    tasks.value = withTaskAdded(tasks.value, session.id, mockTask);
     saveHistory();
     await options.afterSend?.();
 
@@ -549,7 +556,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
     const pendingAiMessage = ensurePendingAiMessage(session.messages);
     inputText.value = '';
     const mockTask = createMockTask(session.id, pendingAiMessage.id);
-    tasks.value.set(session.id, mockTask);
+    tasks.value = withTaskAdded(tasks.value, session.id, mockTask);
     saveHistory();
     await options.afterSend?.();
 
@@ -1650,7 +1657,7 @@ export function useBusinessAssistantChat(options: UseBusinessAssistantChatOption
       baseUrl: getChatApiBaseUrl(),
       threadId: session.id,
     });
-    tasks.value.set(session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
+    tasks.value = withTaskAdded(tasks.value, session.id, createGenerationTask(session.id, pendingAiMessage.id, coordinator));
 
     saveHistory();
     await options.afterSend?.();

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels