|
@@ -277,6 +277,7 @@ describe('ChatSessionService', () => {
|
|
|
const svc2 = withApiStub({
|
|
const svc2 = withApiStub({
|
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
|
|
|
upsertChatThreads: async () => undefined,
|
|
upsertChatThreads: async () => undefined,
|
|
|
|
|
+ removeChatThread: async () => undefined,
|
|
|
});
|
|
});
|
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
|
const s = svc2.create(ROOM, AGENT);
|
|
const s = svc2.create(ROOM, AGENT);
|
|
@@ -286,6 +287,54 @@ describe('ChatSessionService', () => {
|
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
|
expect(svc2.byId(s.id)).withContext('推送已成功但云端为空 → 说明被删了,应移除').toBeUndefined();
|
|
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)', () => {
|
|
describe('消息推进必须同步云端(2026-11-01)', () => {
|
|
@@ -366,8 +415,8 @@ describe('ChatSessionService', () => {
|
|
|
expect(svc2.byId('$w')?.workspaceId).withContext('归属丢了会掉进日常对话').toBe(WS);
|
|
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({
|
|
const svc2 = withApiStub({
|
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
|
|
|
o?.includeDeleted
|
|
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: '' },
|
|
{ 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);
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
|
svc2.moveTo('$w', WS);
|
|
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);
|
|
expect(svc2.byId('$w')?.workspaceId).toBe(WS);
|
|
|
});
|
|
});
|
|
|
|
|
|
|
|
it('移回日常对话时云端收到空字符串(不是不传)', async () => {
|
|
it('移回日常对话时云端收到空字符串(不是不传)', async () => {
|
|
|
- const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve());
|
|
|
|
|
|
|
+ const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
|
|
|
const svc2 = withApiStub({
|
|
const svc2 = withApiStub({
|
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
|
|
fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
|
|
|
o?.includeDeleted
|
|
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 },
|
|
{ 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);
|
|
await svc2.hydrateFromCloud(AGENT);
|
|
|
svc2.moveTo('$w', null);
|
|
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();
|
|
expect(svc2.byId('$w')?.workspaceId).toBeNull();
|
|
|
});
|
|
});
|
|
|
});
|
|
});
|