Преглед на файлове

fix(chat): 修复工作区新建对话归属丢失 + 刚建会话被误删

现象:工作区显示「还没有对话」,日常对话里有内容 —— 会话建出来了但归属没留住。

根因一:moveTo 用 patchChatThread,而 patch 要求云端已有该记录。
「新建对话 → 立刻移动工作区」时 create() 的 upsert 还是异步的,
云端记录尚未落地 → patch 返回 404 → 被当成成功 → 归属压根没写进云端。
下次 hydrate 拉到的 workspaceId 为空,会话掉回「日常对话」。
→ moveTo 改用 upsert(幂等,两种时序都正确)

根因二:新建后换 URL 触发第二轮 onRouteChange → open() → 又一次 pruneEmpty,
那一轮 keepId 因路由时序可能还没指向新会话,刚建的 0 条会话被当场删掉。
→ pruneEmpty 给创建 60 秒内的空会话保护期

验证:tsc 0 错误;单测 1082/1082(新增 3 条回归);构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct преди 22 часа
родител
ревизия
15acd05d2d
променени са 3 файла, в които са добавени 127 реда и са изтрити 13 реда
  1. 29 0
      docs/00-tracking/操作追踪.md
  2. 67 9
      src/app/core/chat-session.service.spec.ts
  3. 31 4
      src/app/core/chat-session.service.ts

+ 29 - 0
docs/00-tracking/操作追踪.md

@@ -3673,3 +3673,32 @@ agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
 - tsc 0 错误;单测 **1080/1080**(新增 2 条回归:
   未推送的会话不被 hydrate 丢弃 / 推送完成后以云端为准)
 - 构建通过;verify:ui 12/12
+
+## 2026-11-01 第十六轮:工作区新建对话归属丢失(第三次修复)
+
+### 现象(用户截图)
+工作区 test 显示「还没有对话」,而日常对话里有内容 ——
+说明会话建出来了,但**归属没留住**。
+
+### 🔴 根因
+`moveTo()` 用的是 `patchChatThread`,而 **patch 要求云端已有这条记录**。
+「新建对话 → 立刻移动工作区」时 `create()` 的 upsert 还是异步的,
+云端记录尚未落地 → patch 返回 404 → 被 `patchChatThread` 当成成功(设计如此)
+→ **归属压根没写进云端**。下次 `hydrateFromCloud` 拉到的 workspaceId 是空的,
+会话就掉回「日常对话」。
+
+### 第二处:pruneEmpty 误删刚建的会话
+新建后换 URL 会触发第二轮 `onRouteChange` → `open()` → 又一次 `pruneEmpty`。
+那一轮的 `keepId` 因为路由时序可能还没指向新会话,
+刚建好的 0 条消息会话被**当场当空壳删掉**。
+
+### 修复
+1. `moveTo()` 改用 **upsert**(幂等:有就更新、没有就创建),两种时序都正确
+2. `pruneEmpty()` 给**创建 60 秒内**的空会话保护期 ——
+   足够熬过 URL 切换与首次同步,又不会让空壳长期堆积
+
+### 验证
+- tsc 0 错误;单测 **1082/1082**
+  (新增:刚建会话不被 pruneEmpty 清理 / 过期空壳仍会被清理 /
+    moveTo 用 upsert 而非 patch)
+- 构建通过;verify:ui 12/12

+ 67 - 9
src/app/core/chat-session.service.spec.ts

@@ -277,6 +277,7 @@ describe('ChatSessionService', () => {
       const svc2 = withApiStub({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
         upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
       });
       await svc2.hydrateFromCloud(AGENT);
       const s = svc2.create(ROOM, AGENT);
@@ -286,6 +287,54 @@ describe('ChatSessionService', () => {
       await svc2.hydrateFromCloud(AGENT);
       expect(svc2.byId(s.id)).withContext('推送已成功但云端为空 → 说明被删了,应移除').toBeUndefined();
     });
+
+    /*
+     * 🔴 第二个回归:`pruneEmpty` 把刚建的会话当空壳删掉。
+     *
+     * 真实时序是「建会话 → 换 URL → 第二轮 onRouteChange → open() → pruneEmpty」,
+     * 那一轮 keepId 可能还没指向它,于是刚建好的 0 条会话被当场清掉。
+     */
+    it('🔴 刚创建的会话不参与 pruneEmpty 清理', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+
+      // 模拟换 URL 后那一轮清理:keepId 尚未指向新会话
+      svc2.pruneEmpty(undefined);
+
+      expect(svc2.byId(s.id)).withContext('刚建的会话被当成空壳删掉了').toBeDefined();
+    });
+
+    it('超过保护期的空壳仍会被清理(不会长期堆积)', async () => {
+      /*
+       * 保护期只针对「刚创建」的空会话(60 秒)。
+       * 这里预先写入一条 61 秒前创建的空会话,它应该被正常清掉。
+       * 直接写 localStorage 再 load,比 mock 系统时钟简单可靠。
+       */
+      const oldId = '$old:fmode.cn';
+      localStorage.setItem(
+        'fmode_chat_sessions_v2_u1',
+        JSON.stringify([
+          {
+            id: oldId, roomId: ROOM, agentId: AGENT, title: '旧空壳',
+            createdAt: Date.now() - 61_000, updatedAt: 1, messageCount: 0, archived: false,
+          },
+        ]),
+      );
+      const svc2 = withApiStub({
+        // 云端不可达 → 不会覆盖本地,能直接观察 pruneEmpty 的效果
+        fetchChatThreads: async () => { throw new Error('offline'); },
+        upsertChatThreads: async () => undefined,
+        removeChatThread: async () => undefined,
+      });
+      svc2.pruneEmpty(undefined);
+
+      expect(svc2.byId(oldId)).withContext('过期的空壳应该被清掉').toBeUndefined();
+    });
   });
 
   describe('消息推进必须同步云端(2026-11-01)', () => {
@@ -366,8 +415,8 @@ describe('ChatSessionService', () => {
       expect(svc2.byId('$w')?.workspaceId).withContext('归属丢了会掉进日常对话').toBe(WS);
     });
 
-    it('moveTo 会把归属推到云端', async () => {
-      const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve());
+    it('moveTo 会把归属推到云端(用 upsert,云端可能还没这条)', async () => {
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
       const svc2 = withApiStub({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
           o?.includeDeleted
@@ -375,17 +424,25 @@ describe('ChatSessionService', () => {
             : [
                 { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '' },
               ],
-        upsertChatThreads: async () => undefined,
-        patchChatThread: patch,
+        upsertChatThreads: upsert,
       });
       await svc2.hydrateFromCloud(AGENT);
       svc2.moveTo('$w', WS);
-      expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: WS });
+      /*
+       * 🔴 必须是 upsert 而不是 patch:
+       *    「新建对话 → 立刻移动工作区」时云端可能还没有这条记录,
+       *    patch 会返回 404 被当成成功,归属压根没写进云端。
+       */
+      expect(upsert)
+        .withContext('云端可能还没这条,必须用 upsert')
+        .toHaveBeenCalledWith(
+          jasmine.arrayContaining([jasmine.objectContaining({ threadRoot: '$w', workspaceId: WS })]),
+        );
       expect(svc2.byId('$w')?.workspaceId).toBe(WS);
     });
 
     it('移回日常对话时云端收到空字符串(不是不传)', async () => {
-      const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve());
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
       const svc2 = withApiStub({
         fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
           o?.includeDeleted
@@ -393,12 +450,13 @@ describe('ChatSessionService', () => {
             : [
                 { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS },
               ],
-        upsertChatThreads: async () => undefined,
-        patchChatThread: patch,
+        upsertChatThreads: upsert,
       });
       await svc2.hydrateFromCloud(AGENT);
       svc2.moveTo('$w', null);
-      expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: '' });
+      expect(upsert).toHaveBeenCalledWith(
+        jasmine.arrayContaining([jasmine.objectContaining({ threadRoot: '$w', workspaceId: '' })]),
+      );
       expect(svc2.byId('$w')?.workspaceId).toBeNull();
     });
   });

+ 31 - 4
src/app/core/chat-session.service.ts

@@ -517,9 +517,19 @@ export class ChatSessionService {
    */
   moveTo(id: string, workspaceId: string | null): void {
     this.update(id, { workspaceId });
-    void this.trySync(() =>
-      this.api!.patchChatThread({ threadRoot: id, workspaceId: workspaceId ?? '' }),
-    );
+    const cur = this.byId(id);
+    if (!cur) return;
+    /*
+     * 🔴 用 **upsert 而不是 patch**(2026-11-01 修复)。
+     *
+     * `patch` 要求云端已有这条记录;而「新建对话 → 立刻移动工作区」时
+     * 云端记录可能还没落地(create 是异步推的),patch 返回 404 被当成成功,
+     * 归属**压根没写进云端**。下次拉取时云端 workspaceId 为空 →
+     * 会话掉回「日常对话」(用户实测:工作区里新建的对话跑到日常去了)。
+     *
+     * upsert 是幂等的:有就更新、没有就创建,两种时序都正确。
+     */
+    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(cur)]));
   }
 
   /** 某工作区下的会话数(不含归档) */
@@ -572,8 +582,25 @@ export class ChatSessionService {
    * @param keepId 当前正在看的会话,必须保留(它也是 0 条,但用户正要发消息)
    */
   pruneEmpty(keepId?: string): void {
+    const now = Date.now();
     const victims = this._all().filter(
-      (s) => !s.legacy && s.id !== keepId && (s.messageCount ?? 0) === 0,
+      (s) =>
+        !s.legacy &&
+        s.id !== keepId &&
+        (s.messageCount ?? 0) === 0 &&
+        /*
+         * 🔴 刚创建的空会话**不参与清理**(2026-11-01)。
+         *
+         * 新建对话是「点击即建」,建出来就是一个 0 条消息的会话;
+         * 紧接着换 URL 会触发第二轮 `onRouteChange` → `open()` →
+         * 又一次 `pruneEmpty`。那一轮的 `keepId` 若因为路由时序还没指向它,
+         * 这条刚建好的会话就被**当场当作空壳删掉**
+         * (用户实测:聊天区是新的,左侧列表里却没有)。
+         *
+         * 给新会话 60 秒保护期:足够熬过 URL 切换与首次同步,
+         * 又不会让空壳长期堆积(超过 60 秒还没发消息的仍会被清掉)。
+         */
+        now - s.createdAt > 60_000,
     );
     if (victims.length === 0) return;
     const dead = new Set(victims.map((s) => s.id));