chat-session.service.spec.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691
  1. import { TestBed } from '@angular/core/testing';
  2. import { ChatSessionService } from './chat-session.service';
  3. import { FmodeApi } from './api/fmode-api';
  4. import { SessionService } from './session.service';
  5. import { legacySessionId } from './chat-session';
  6. /**
  7. * ChatSessionService —— 会话目录的唯一 owner。
  8. *
  9. * 覆盖:新建 / 归档 / 重命名 / 移除黑名单 / 云端单向同步 / 持久化。
  10. * 这些是「多会话」的数据底座,错一个字段用户就会看到串号的对话名。
  11. */
  12. /**
  13. * 以「带 FmodeApi 桩」的方式构造服务实例。
  14. *
  15. * 文件级声明:多个 describe 都要用它构造独立实例,
  16. * 每次重建都拿到干净的 signal 状态(`resetTestingModule`)。
  17. */
  18. function withApiStub(api: Record<string, unknown>): ChatSessionService {
  19. TestBed.resetTestingModule();
  20. TestBed.configureTestingModule({
  21. providers: [
  22. ChatSessionService,
  23. { provide: SessionService, useValue: { me: () => ({ id: 'u1' }) } },
  24. { provide: FmodeApi, useValue: api },
  25. ],
  26. });
  27. const s2 = TestBed.inject(ChatSessionService);
  28. s2.load();
  29. return s2;
  30. }
  31. describe('ChatSessionService', () => {
  32. let svc: ChatSessionService;
  33. beforeEach(() => {
  34. localStorage.clear();
  35. TestBed.configureTestingModule({
  36. providers: [
  37. ChatSessionService,
  38. { provide: SessionService, useValue: { me: () => ({ id: 'u1' }) } },
  39. ],
  40. });
  41. svc = TestBed.inject(ChatSessionService);
  42. svc.reset();
  43. });
  44. afterEach(() => localStorage.clear());
  45. const ROOM = '!room1:fmode.cn';
  46. const AGENT = 'agent-a';
  47. it('新建会话:标题默认「新建对话」、未归档、计数 0', () => {
  48. const s = svc.create(ROOM, AGENT);
  49. expect(s.title).toBe('新建对话');
  50. expect(s.archived).toBeFalse();
  51. expect(s.messageCount).toBe(0);
  52. expect(s.roomId).toBe(ROOM);
  53. expect(s.agentId).toBe(AGENT);
  54. });
  55. it('create 生成独立线程根、同容器可并存多条', () => {
  56. const a = svc.create(ROOM, AGENT);
  57. const b = svc.create(ROOM, AGENT);
  58. expect(b.id).not.toBe(a.id);
  59. expect(svc.listFor(AGENT).length).toBe(2);
  60. });
  61. it('为什么服务端认为是同一个 agent 的不同容器 —— 会话互不可见', () => {
  62. svc.create(ROOM, 'agent-a');
  63. svc.create(ROOM, 'agent-b');
  64. expect(svc.listFor('agent-a').length).toBe(1);
  65. expect(svc.listFor('agent-b').length).toBe(1);
  66. });
  67. it('归档后默认列表不含它,但可按 id 取到(链接可打开)', () => {
  68. const s = svc.create(ROOM, AGENT);
  69. svc.archive(s.id);
  70. expect(svc.listFor(AGENT)).toEqual([]);
  71. expect(svc.listFor(AGENT, { includeArchived: true }).length).toBe(1);
  72. expect(svc.byId(s.id)?.archived).toBeTrue();
  73. });
  74. it('取消归档后回到列表', () => {
  75. const s = svc.create(ROOM, AGENT);
  76. svc.archive(s.id);
  77. svc.archive(s.id, false);
  78. expect(svc.listFor(AGENT).length).toBe(1);
  79. });
  80. it('重命名会锁定标题,之后消息不再覆盖', () => {
  81. const s = svc.create(ROOM, AGENT);
  82. svc.rename(s.id, ' 我的任务 ');
  83. const renamed = svc.byId(s.id)!;
  84. expect(renamed.title).toBe('我的任务');
  85. expect(renamed.titleLocked).toBeTrue();
  86. svc.noteMessage(s.id, { text: '新消息内容', ts: Date.now(), fromMe: true });
  87. expect(svc.byId(s.id)!.title).toBe('我的任务');
  88. });
  89. it('重命名空字符串被忽略', () => {
  90. const s = svc.create(ROOM, AGENT);
  91. svc.rename(s.id, ' ');
  92. // 未命名会话的默认标题是「新建对话」(2026-11-01 起)
  93. expect(svc.byId(s.id)!.title).toBe('新建对话');
  94. });
  95. it('用户首条消息推导标题', () => {
  96. const s = svc.create(ROOM, AGENT);
  97. svc.noteMessage(s.id, { text: '帮我做个登录页', ts: 2000, fromMe: true });
  98. expect(svc.byId(s.id)!.title).toBe('做个登录页');
  99. });
  100. describe('remove(用户主动移除)', () => {
  101. it('移除后从列表消失', () => {
  102. const s = svc.create(ROOM, AGENT);
  103. svc.remove(s.id);
  104. expect(svc.byId(s.id)).toBeUndefined();
  105. });
  106. it('🔴 移除的线程不会被下一次云端拉取复活', async () => {
  107. const svc2 = withApiStub({
  108. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  109. o?.includeDeleted
  110. ? []
  111. : [
  112. {
  113. objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone',
  114. title: '被移除的对话', titleLocked: false, messageCount: 5,
  115. lastMessageAt: 100, archived: false, lastPreview: '',
  116. },
  117. ],
  118. upsertChatThreads: async () => undefined,
  119. removeChatThread: async () => undefined,
  120. });
  121. svc2.adopt({ id: '$gone', roomId: ROOM, agentId: AGENT });
  122. svc2.remove('$gone');
  123. await svc2.hydrateFromCloud(AGENT);
  124. expect(svc2.byId('$gone')).withContext('被移除的线程又回来了').toBeUndefined();
  125. });
  126. it('isRemoved 能查到移除记录', () => {
  127. const s = svc.create(ROOM, AGENT);
  128. expect(svc.isRemoved(s.id)).toBeFalse();
  129. svc.remove(s.id);
  130. expect(svc.isRemoved(s.id)).toBeTrue();
  131. });
  132. it('移除记录会持久化', () => {
  133. const s = svc.create(ROOM, AGENT);
  134. svc.remove(s.id);
  135. svc.reset();
  136. svc.load();
  137. expect(svc.isRemoved(s.id)).withContext('重载后黑名单丢了').toBeTrue();
  138. });
  139. });
  140. describe('历史对话(承接无 thread 旧消息)', () => {
  141. it('首次创建返回 created=true', () => {
  142. const r = svc.adoptLegacy(ROOM, AGENT, '旧消息预览');
  143. expect(r.created).toBeTrue();
  144. expect(r.session.id).toBe(legacySessionId(ROOM));
  145. expect(r.session.title).toBe('历史对话');
  146. expect(r.session.legacy).toBeTrue();
  147. expect(r.session.titleLocked).withContext('历史会话不改名').toBeTrue();
  148. });
  149. it('幂等:重复调用返回 created=false 且不重复添加', () => {
  150. svc.adoptLegacy(ROOM, AGENT);
  151. const again = svc.adoptLegacy(ROOM, AGENT);
  152. expect(again.created).toBeFalse();
  153. expect(svc.listFor(AGENT).length).toBe(1);
  154. });
  155. it('不同房间的历史会话 id 不同', () => {
  156. expect(legacySessionId('!a:x')).not.toBe(legacySessionId('!b:x'));
  157. });
  158. });
  159. describe('持久化', () => {
  160. it('新建后写入 localStorage(v2 缓存键,按用户隔离)', () => {
  161. const s = svc.create(ROOM, AGENT);
  162. const raw = localStorage.getItem('fmode_chat_sessions_v2_u1');
  163. expect(raw).withContext('未按用户隔离存储').toBeTruthy();
  164. expect(JSON.parse(raw!)[0].id).toBe(s.id);
  165. });
  166. it('重新载入能恢复会话', () => {
  167. const s = svc.create(ROOM, AGENT);
  168. svc.reset();
  169. svc.load();
  170. expect(svc.byId(s.id)).toBeDefined();
  171. });
  172. // 换账号不能看到上一个账号的会话
  173. it('🔴 换用户后不串号', () => {
  174. svc.create(ROOM, AGENT);
  175. expect(svc.all().length).toBe(1);
  176. // 切换成另一个用户
  177. TestBed.resetTestingModule();
  178. TestBed.configureTestingModule({
  179. providers: [
  180. ChatSessionService,
  181. { provide: SessionService, useValue: { me: () => ({ id: 'u2' }) } },
  182. ],
  183. });
  184. const other = TestBed.inject(ChatSessionService);
  185. other.load();
  186. expect(other.all()).withContext('读到了别人的会话').toEqual([]);
  187. });
  188. it('坏 JSON 不影响后续使用(降级为空列表)', () => {
  189. localStorage.setItem('fmode_chat_sessions_u1', '{{{ 不是 json');
  190. svc.reset();
  191. expect(() => svc.load()).not.toThrow();
  192. expect(svc.all()).toEqual([]);
  193. });
  194. it('缺字段的脏数据被过滤', () => {
  195. localStorage.setItem(
  196. 'fmode_chat_sessions_v2_u1',
  197. JSON.stringify([{ id: '$x' }, { id: '$ok', roomId: '!r:x', agentId: 'a', title: 't', createdAt: 1, updatedAt: 1 }]),
  198. );
  199. svc.reset();
  200. svc.load();
  201. expect(svc.all().length).toBe(1);
  202. expect(svc.all()[0].id).toBe('$ok');
  203. });
  204. });
  205. describe('threadIdsOf', () => {
  206. it('返回该房间下所有会话的线程根', () => {
  207. const a = svc.create(ROOM, AGENT);
  208. const b = svc.create(ROOM, 'agent-b');
  209. svc.create('!other:x', AGENT);
  210. const ids = svc.threadIdsOf(ROOM);
  211. expect(ids.has(a.id)).toBeTrue();
  212. expect(ids.has(b.id)).toBeTrue();
  213. expect(ids.size).toBe(2);
  214. });
  215. });
  216. describe('新建会话不被云端拉取冲掉(2026-11-01)', () => {
  217. const ROOM = '!room1:fmode.cn';
  218. const AGENT = 'agent-a';
  219. beforeEach(() => localStorage.clear());
  220. /*
  221. * 🔴 回归:点「新建对话」后,聊天区是新的但**左侧列表没有内容**。
  222. *
  223. * 根因:`create()` 之后是**异步**推云端的,而新建对话紧接着就换 URL
  224. * → 重新 `open()` → 又拉一次云端。那一刻云端列表里还没有这条,
  225. * `hydrateFromCloud` 按「云端没有就丢弃」处理,刚建出来的会话当场消失。
  226. */
  227. it('🔴 刚创建、还没推到云端的会话,不能被 hydrate 丢弃', async () => {
  228. let remote: unknown[] = [];
  229. const svc2 = withApiStub({
  230. // 云端暂时还没有这条(推送是异步的,这里模拟「推送还没落地」)
  231. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  232. o?.includeDeleted ? [] : remote,
  233. // upsert 永不 resolve —— 模拟推送尚未完成
  234. upsertChatThreads: () => new Promise<void>(() => undefined),
  235. });
  236. await svc2.hydrateFromCloud(AGENT);
  237. const s = svc2.create(ROOM, AGENT);
  238. // 换 URL 后重新拉云端(此时云端仍为空)
  239. await svc2.hydrateFromCloud(AGENT);
  240. expect(svc2.byId(s.id)).withContext('刚建的会话被云端拉取抹掉了').toBeDefined();
  241. });
  242. it('commit 落库后再拉取,以云端为准(标记已撤销)', async () => {
  243. const svc2 = withApiStub({
  244. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  245. upsertChatThreads: async () => undefined,
  246. removeChatThread: async () => undefined,
  247. });
  248. await svc2.hydrateFromCloud(AGENT);
  249. const s = svc2.create(ROOM, AGENT);
  250. svc2.commit(s.id);
  251. // 等推送 promise 落定
  252. await new Promise((r) => setTimeout(r, 10));
  253. await svc2.hydrateFromCloud(AGENT);
  254. expect(svc2.byId(s.id)).withContext('已落库但云端为空 → 说明已删除,应移除').toBeUndefined();
  255. });
  256. /*
  257. * 🔴 第二个回归:`pruneEmpty` 把刚建的会话当空壳删掉。
  258. *
  259. * 真实时序是「建会话 → 换 URL → 第二轮 onRouteChange → open() → pruneEmpty」,
  260. * 那一轮 keepId 可能还没指向它,于是刚建好的 0 条会话被当场清掉。
  261. */
  262. it('🔴 正在看的那条空会话不会被清理(keepId 保护)', async () => {
  263. const svc2 = withApiStub({
  264. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  265. upsertChatThreads: async () => undefined,
  266. removeChatThread: async () => undefined,
  267. });
  268. await svc2.hydrateFromCloud(AGENT);
  269. const s = svc2.create(ROOM, AGENT);
  270. // 用户正看着它 → 必须保留
  271. svc2.pruneEmpty(s.id);
  272. expect(svc2.byId(s.id)).withContext('当前会话被误删').toBeDefined();
  273. });
  274. /*
  275. * 🔴 用户反馈:多次新建对话后,前几次的都持久化/残留了。
  276. *
  277. * 规则是「**发过消息**才持久化」。从未落库的空会话(点了新建但没发消息)
  278. * 应当在切走时直接消失,不该留在列表里。
  279. */
  280. it('🔴 连点新建留下、且没发过消息的空会话,切走时全部清掉', async () => {
  281. const svc2 = withApiStub({
  282. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  283. upsertChatThreads: async () => undefined,
  284. removeChatThread: async () => undefined,
  285. });
  286. await svc2.hydrateFromCloud(AGENT);
  287. const a = svc2.create(ROOM, AGENT);
  288. const b = svc2.create(ROOM, AGENT);
  289. const c = svc2.create(ROOM, AGENT);
  290. // 用户切走到别的会话(c 被打开 → a、b 应被清掉)
  291. svc2.pruneEmpty(c.id);
  292. expect(svc2.byId(a.id)).withContext('未落库的空会话应被清掉').toBeUndefined();
  293. expect(svc2.byId(b.id)).toBeUndefined();
  294. expect(svc2.byId(c.id)).withContext('当前这条要保留').toBeDefined();
  295. });
  296. it('🔴 未落库的空会话被清理时,不向云端发删除请求', async () => {
  297. const remove = jasmine.createSpy('remove').and.returnValue(Promise.resolve());
  298. const svc2 = withApiStub({
  299. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  300. upsertChatThreads: async () => undefined,
  301. removeChatThread: remove,
  302. });
  303. await svc2.hydrateFromCloud(AGENT);
  304. svc2.create(ROOM, AGENT);
  305. svc2.pruneEmpty(undefined);
  306. expect(remove).withContext('云端本来就没有这条,不该发删除请求').not.toHaveBeenCalled();
  307. });
  308. it('超过保护期的空壳仍会被清理(不会长期堆积)', async () => {
  309. /*
  310. * 保护期只针对「刚创建」的空会话(60 秒)。
  311. * 这里预先写入一条 61 秒前创建的空会话,它应该被正常清掉。
  312. * 直接写 localStorage 再 load,比 mock 系统时钟简单可靠。
  313. */
  314. const oldId = '$old:fmode.cn';
  315. localStorage.setItem(
  316. 'fmode_chat_sessions_v2_u1',
  317. JSON.stringify([
  318. {
  319. id: oldId, roomId: ROOM, agentId: AGENT, title: '旧空壳',
  320. createdAt: Date.now() - 61_000, updatedAt: 1, messageCount: 0, archived: false,
  321. },
  322. ]),
  323. );
  324. const svc2 = withApiStub({
  325. // 云端不可达 → 不会覆盖本地,能直接观察 pruneEmpty 的效果
  326. fetchChatThreads: async () => { throw new Error('offline'); },
  327. upsertChatThreads: async () => undefined,
  328. removeChatThread: async () => undefined,
  329. });
  330. svc2.pruneEmpty(undefined);
  331. expect(svc2.byId(oldId)).withContext('过期的空壳应该被清掉').toBeUndefined();
  332. });
  333. });
  334. describe('消息推进必须同步云端(2026-11-01)', () => {
  335. const ROOM = '!room1:fmode.cn';
  336. const AGENT = 'agent-a';
  337. beforeEach(() => localStorage.clear());
  338. /*
  339. * 🔴 回归:历史会话名称加载不对,显示成「新对话」。
  340. *
  341. * 根因:`noteMessage` 只写本地,标题/计数/时间从不推云端。
  342. * 云端那条记录永远停在「新对话 / 0 条」,换设备或重新拉列表就退回默认名。
  343. */
  344. it('🔴 首条消息确定标题后必须推送到云端', async () => {
  345. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  346. const svc2 = withApiStub({
  347. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  348. upsertChatThreads: upsert,
  349. });
  350. await svc2.hydrateFromCloud(AGENT);
  351. const s = svc2.create(ROOM, AGENT);
  352. upsert.calls.reset();
  353. svc2.noteMessage(s.id, { text: '帮我做个登录页', ts: Date.now(), fromMe: true });
  354. expect(upsert).withContext('标题变了必须推云端,否则换设备退回「新对话」').toHaveBeenCalled();
  355. const pushed = upsert.calls.mostRecent().args[0][0];
  356. expect(pushed.title).toBe('做个登录页');
  357. expect(pushed.threadRoot).toBe(s.id);
  358. });
  359. it('同一会话短时间内重复消息不重复打接口(节流)', async () => {
  360. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  361. const svc2 = withApiStub({
  362. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  363. upsertChatThreads: upsert,
  364. });
  365. await svc2.hydrateFromCloud(AGENT);
  366. const s = svc2.create(ROOM, AGENT);
  367. /*
  368. * 2026-11-01:`create()` 只建本地,**未落库**的会话在 noteMessage 里
  369. * 不参与节流(必须立刻写)。这里先 commit 让它进入「已落库」状态,
  370. * 才能验证节流本身。
  371. */
  372. svc2.commit(s.id);
  373. await new Promise((r) => setTimeout(r, 10));
  374. upsert.calls.reset();
  375. // 首条定标题(触发一次)
  376. svc2.noteMessage(s.id, { text: '第一条', ts: 1000, fromMe: true });
  377. const afterFirst = upsert.calls.count();
  378. // 紧随其后的几条只改计数,不应再推
  379. svc2.noteMessage(s.id, { text: '第二条', ts: 1001, fromMe: true });
  380. svc2.noteMessage(s.id, { text: '第三条', ts: 1002, fromMe: true });
  381. expect(upsert.calls.count()).withContext('计数变化不该每条都打接口').toBe(afterFirst);
  382. });
  383. });
  384. describe('工作区归属(2026-11-01 上云)', () => {
  385. const ROOM = '!room1:fmode.cn';
  386. const AGENT = 'agent-a';
  387. const WS = 'ws-1';
  388. beforeEach(() => localStorage.clear());
  389. /*
  390. * 🔴 回归:用户反馈「在工作区里新建对话,结果跑到下面日常对话里去了」。
  391. *
  392. * 根因:归属只存本地,`hydrateFromCloud` 重建列表时靠本地缓存「捡」,
  393. * 捡不到就回落 null(日常)。现在云端是权威来源。
  394. */
  395. it('🔴 云端返回的 workspaceId 必须覆盖本地为空的归属', async () => {
  396. const svc2 = withApiStub({
  397. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  398. o?.includeDeleted
  399. ? []
  400. : [
  401. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS },
  402. ],
  403. upsertChatThreads: async () => undefined,
  404. });
  405. await svc2.hydrateFromCloud(AGENT);
  406. expect(svc2.byId('$w')?.workspaceId).withContext('归属丢了会掉进日常对话').toBe(WS);
  407. });
  408. it('moveTo 会把归属推到云端(用 upsert,云端可能还没这条)', async () => {
  409. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  410. const svc2 = withApiStub({
  411. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  412. o?.includeDeleted
  413. ? []
  414. : [
  415. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '' },
  416. ],
  417. upsertChatThreads: upsert,
  418. });
  419. await svc2.hydrateFromCloud(AGENT);
  420. svc2.moveTo('$w', WS);
  421. /*
  422. * 🔴 必须是 upsert 而不是 patch:
  423. * 「新建对话 → 立刻移动工作区」时云端可能还没有这条记录,
  424. * patch 会返回 404 被当成成功,归属压根没写进云端。
  425. */
  426. expect(upsert)
  427. .withContext('云端可能还没这条,必须用 upsert')
  428. .toHaveBeenCalledWith(
  429. jasmine.arrayContaining([jasmine.objectContaining({ threadRoot: '$w', workspaceId: WS })]),
  430. );
  431. expect(svc2.byId('$w')?.workspaceId).toBe(WS);
  432. });
  433. it('移回日常对话时云端收到空字符串(不是不传)', async () => {
  434. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  435. const svc2 = withApiStub({
  436. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  437. o?.includeDeleted
  438. ? []
  439. : [
  440. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS },
  441. ],
  442. upsertChatThreads: upsert,
  443. });
  444. await svc2.hydrateFromCloud(AGENT);
  445. svc2.moveTo('$w', null);
  446. expect(upsert).toHaveBeenCalledWith(
  447. jasmine.arrayContaining([jasmine.objectContaining({ threadRoot: '$w', workspaceId: '' })]),
  448. );
  449. expect(svc2.byId('$w')?.workspaceId).toBeNull();
  450. });
  451. });
  452. describe('云端会话目录(2026-11-01 单向同步)', () => {
  453. const ROOM = '!room1:fmode.cn';
  454. const AGENT = 'agent-a';
  455. beforeEach(() => localStorage.clear());
  456. /** 以「带 FmodeApi 桩」的方式重建服务(内部用;原 `withApi` 已改为文件级 `withApiStub`) */
  457. function withApi(api: Record<string, unknown>): ChatSessionService {
  458. return withApiStub(api);
  459. }
  460. it('云端有新会话、本地为空 → 重建到本地(换浏览器场景)', async () => {
  461. const svc2 = withApi({
  462. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  463. o?.includeDeleted
  464. ? []
  465. : [
  466. {
  467. objectId: 'x1', agentId: AGENT, roomId: ROOM, threadRoot: '$cloud1',
  468. title: '云端会话', titleLocked: false, messageCount: 3,
  469. lastMessageAt: 1000, archived: false, lastPreview: 'hi',
  470. },
  471. ],
  472. upsertChatThreads: async () => undefined,
  473. });
  474. await svc2.hydrateFromCloud(AGENT);
  475. expect(svc2.byId('$cloud1')?.title).toBe('云端会话');
  476. expect(svc2.byId('$cloud1')?.messageCount).toBe(3);
  477. });
  478. it('已移除的线程不会被云端复活', async () => {
  479. const svc2 = withApi({
  480. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  481. o?.includeDeleted
  482. ? []
  483. : [
  484. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$gone', title: 'a', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' },
  485. ],
  486. upsertChatThreads: async () => undefined,
  487. removeChatThread: async () => undefined,
  488. });
  489. const s0 = svc2.create(ROOM, AGENT);
  490. svc2.remove(s0.id);
  491. await svc2.hydrateFromCloud(AGENT);
  492. expect(svc2.byId(s0.id)).toBeUndefined();
  493. });
  494. /*
  495. * 🔴 行为变更(2026-11-01):单向同步下**云端标题才是权威**。
  496. *
  497. * 旧断言「本地锁定标题不被云端覆盖」在合并式同步下成立,但正是它允许了
  498. * 「本地与云端各说各话」——改名推云端失败后,本地会永远保留一个云端不知道的标题。
  499. * 现在:改名会立刻 `patchChatThread` 推云端,云端成功后自然以云端为准;
  500. * `titleLocked` 只用于防止「首条消息自动命名」覆盖用户手动改过的名字。
  501. */
  502. it('改名成功后标题以云端为准', async () => {
  503. let remoteTitle = '云端标题';
  504. const svc2 = withApi({
  505. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  506. o?.includeDeleted
  507. ? []
  508. : [
  509. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$t', title: remoteTitle, titleLocked: true, messageCount: 1, lastMessageAt: 5, archived: false, lastPreview: '' },
  510. ],
  511. upsertChatThreads: async () => undefined,
  512. patchChatThread: async (i: { title?: string }) => {
  513. if (i.title) remoteTitle = i.title;
  514. },
  515. });
  516. svc2.adopt({ id: '$t', roomId: ROOM, agentId: AGENT });
  517. svc2.rename('$t', '我改的标题');
  518. await svc2.hydrateFromCloud(AGENT);
  519. expect(svc2.byId('$t')?.title).withContext('云端已更新,应展示新标题').toBe('我改的标题');
  520. });
  521. it('归档状态以云端为准', async () => {
  522. const svc2 = withApi({
  523. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
  524. o?.includeDeleted
  525. ? []
  526. : [
  527. { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 't', titleLocked: false, messageCount: 1, lastMessageAt: 9, archived: true, lastPreview: '' },
  528. ],
  529. upsertChatThreads: async () => undefined,
  530. });
  531. svc2.adopt({ id: '$a', roomId: ROOM, agentId: AGENT });
  532. await svc2.hydrateFromCloud(AGENT);
  533. expect(svc2.byId('$a')?.archived).toBeTrue();
  534. });
  535. it('🔴 云端明确为空 → 本机**已落库**的会话一并清掉(删除后不再复活)', async () => {
  536. const svc2 = withApi({
  537. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  538. upsertChatThreads: async () => undefined,
  539. });
  540. const s = svc2.create(ROOM, AGENT);
  541. /*
  542. * 2026-11-01:`create()` 只建本地、不落库,且带 `_pendingPush` 保护 ——
  543. * 云端为空时它会被保留(否则新建对话刚建出来就被抹掉)。
  544. * 这里模拟「已经落库完成」的会话,验证云端删除仍能正常生效。
  545. */
  546. svc2.commit(s.id);
  547. await new Promise((r) => setTimeout(r, 0));
  548. await svc2.hydrateFromCloud(AGENT);
  549. expect(svc2.listFor(AGENT, { includeArchived: true }).length).toBe(0);
  550. });
  551. it('云端为空时不误推本地会话(新建的未落库会话除外)', async () => {
  552. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  553. const svc2 = withApi({
  554. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  555. upsertChatThreads: upsert,
  556. });
  557. /*
  558. * 2026-11-01:`create()` 已不再推云端(本地先建、首条消息才落库),
  559. * 所以这里验证的是「pushAll 不会被调用去灌云端」。
  560. * 未落库的新会话由 commit() 负责,不属于 pushAll 的职责。
  561. */
  562. await svc2.hydrateFromCloud(AGENT);
  563. await svc2.pushAll(AGENT);
  564. expect(upsert.calls.count()).withContext('云端为空时不该推任何东西').toBe(0);
  565. });
  566. /*
  567. * 🔴 行为变更(2026-11-01 用户裁决):云端不可达时**列表不可信**。
  568. *
  569. * 旧行为「保留本地缓存照常展示」看着更"稳",实际是两端互相覆盖的根源:
  570. * 用户看到的可能是几小时前的列表,在上面做的删除/归档又会回写云端。
  571. */
  572. it('🔴 云端不可达 → ready=false,界面据此显示「加载失败」', async () => {
  573. const svc2 = withApi({
  574. fetchChatThreads: async () => { throw new Error('network'); },
  575. upsertChatThreads: async () => { throw new Error('network'); },
  576. });
  577. await svc2.hydrateFromCloud(AGENT);
  578. expect(svc2.ready()).toBeFalse();
  579. expect(svc2.error()).toBe('network');
  580. });
  581. it('云端不可达时 pushAll 不推送(列表本身就不可信)', async () => {
  582. const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
  583. const svc2 = withApi({
  584. fetchChatThreads: async () => { throw new Error('network'); },
  585. upsertChatThreads: upsert,
  586. });
  587. svc2.create(ROOM, AGENT);
  588. const before = upsert.calls.count();
  589. await svc2.hydrateFromCloud(AGENT);
  590. await svc2.pushAll(AGENT);
  591. expect(upsert.calls.count()).withContext('云端不可达时不应回写').toBe(before);
  592. });
  593. it('云端成功返回后 ready=true', async () => {
  594. const svc2 = withApi({
  595. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
  596. upsertChatThreads: async () => undefined,
  597. });
  598. expect(svc2.ready()).toBeFalse();
  599. await svc2.hydrateFromCloud(AGENT);
  600. expect(svc2.ready()).toBeTrue();
  601. });
  602. it('🔴 云端删掉的会话,本地下一次拉取即消失', async () => {
  603. let live = [
  604. { objectId: 'a', agentId: AGENT, roomId: ROOM, threadRoot: '$a', title: 'A', titleLocked: false, messageCount: 1, lastMessageAt: 1, archived: false, lastPreview: '' },
  605. { objectId: 'b', agentId: AGENT, roomId: ROOM, threadRoot: '$b', title: 'B', titleLocked: false, messageCount: 1, lastMessageAt: 2, archived: false, lastPreview: '' },
  606. ];
  607. const svc2 = withApi({
  608. fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : live),
  609. upsertChatThreads: async () => undefined,
  610. });
  611. await svc2.hydrateFromCloud(AGENT);
  612. expect(svc2.listFor(AGENT).length).toBe(2);
  613. live = [live[0]];
  614. await svc2.hydrateFromCloud(AGENT);
  615. expect(svc2.listFor(AGENT).map((s) => s.id)).withContext('云端没有的应被移除').toEqual(['$a']);
  616. });
  617. });
  618. });