|
| 1 | +/** |
| 2 | + * Tests for buildChatHistoryContext fork semantics. |
| 3 | + * |
| 4 | + * buildChatHistoryContext now mirrors buildDirectTaskHistory's fork logic: |
| 5 | + * - No threadId → fetch from parent chat only |
| 6 | + * - Thread empty → fork from parent chat |
| 7 | + * - Thread messages < max → supplement with parent chat messages |
| 8 | + * - Thread messages ≥ max → first + latest (max - 1) |
| 9 | + * - Structured sections (parentMsgCount) passed to formatHistoryMessages |
| 10 | + */ |
| 11 | +// @ts-nocheck — test file |
| 12 | +import { describe, it, expect, vi } from 'vitest'; |
| 13 | + |
| 14 | +// ============================================================ |
| 15 | +// Mocks |
| 16 | +// ============================================================ |
| 17 | + |
| 18 | +const mockFetchRecentMessages = vi.fn(); |
| 19 | + |
| 20 | +vi.mock('../client.js', () => ({ |
| 21 | + feishuClient: { |
| 22 | + fetchRecentMessages: (...args: unknown[]) => mockFetchRecentMessages(...args), |
| 23 | + getUserName: vi.fn().mockResolvedValue(null), |
| 24 | + replyText: vi.fn(), |
| 25 | + replyInThread: vi.fn(), |
| 26 | + sendCard: vi.fn(), |
| 27 | + updateCard: vi.fn(), |
| 28 | + replyCardInThread: vi.fn(), |
| 29 | + sendText: vi.fn(), |
| 30 | + }, |
| 31 | +})); |
| 32 | + |
| 33 | +vi.mock('../../utils/logger.js', () => ({ |
| 34 | + logger: { |
| 35 | + info: vi.fn(), |
| 36 | + warn: vi.fn(), |
| 37 | + error: vi.fn(), |
| 38 | + debug: vi.fn(), |
| 39 | + }, |
| 40 | +})); |
| 41 | + |
| 42 | +vi.mock('../../config.js', () => ({ |
| 43 | + config: { |
| 44 | + feishu: { encryptKey: '', verifyToken: '' }, |
| 45 | + security: { allowedUserIds: [] }, |
| 46 | + claude: { defaultWorkDir: '/tmp/work' }, |
| 47 | + workspace: { baseDir: '/tmp/workspaces', branchPrefix: 'feat/test' }, |
| 48 | + db: { pipelineDbPath: ':memory:' }, |
| 49 | + agent: { bindings: [], groupConfigs: {} }, |
| 50 | + chat: { historyMaxCount: 10, historyMaxChars: 8000 }, |
| 51 | + memory: { enabled: false }, |
| 52 | + }, |
| 53 | + isMultiBotMode: vi.fn(() => false), |
| 54 | +})); |
| 55 | + |
| 56 | +// event-handler.ts dependency chain mocks |
| 57 | +vi.mock('../../claude/executor.js', () => ({ |
| 58 | + claudeExecutor: { execute: vi.fn(), killSession: vi.fn() }, |
| 59 | +})); |
| 60 | +vi.mock('../../session/manager.js', () => ({ |
| 61 | + sessionManager: { |
| 62 | + get: vi.fn(), getOrCreate: vi.fn(), setWorkingDir: vi.fn(), |
| 63 | + setStatus: vi.fn(), setConversationId: vi.fn(), setThread: vi.fn(), |
| 64 | + getThreadSession: vi.fn(), upsertThreadSession: vi.fn(), |
| 65 | + setThreadConversationId: vi.fn(), setThreadWorkingDir: vi.fn(), |
| 66 | + getRecentSummaries: vi.fn(() => []), saveSummary: vi.fn(), reset: vi.fn(), |
| 67 | + }, |
| 68 | +})); |
| 69 | +vi.mock('../../session/queue.js', () => ({ |
| 70 | + taskQueue: { enqueue: vi.fn(), dequeue: vi.fn(), complete: vi.fn(), pendingCount: vi.fn(() => 0), cancelPending: vi.fn(() => 0), isBusy: vi.fn(() => false) }, |
| 71 | +})); |
| 72 | +vi.mock('../message-builder.js', () => ({ |
| 73 | + buildProgressCard: vi.fn(), buildResultCard: vi.fn(), buildStatusCard: vi.fn(), |
| 74 | +})); |
| 75 | +vi.mock('../../utils/security.js', () => ({ |
| 76 | + isUserAllowed: vi.fn(() => true), containsDangerousCommand: vi.fn(() => false), |
| 77 | +})); |
| 78 | +vi.mock('../../pipeline/store.js', () => ({ |
| 79 | + pipelineStore: { get: vi.fn(), findPendingByChat: vi.fn(), tryStart: vi.fn() }, |
| 80 | +})); |
| 81 | +vi.mock('../../pipeline/runner.js', () => ({ |
| 82 | + createPendingPipeline: vi.fn(), startPipeline: vi.fn(), |
| 83 | + abortPipeline: vi.fn(), cancelPipeline: vi.fn(), retryPipeline: vi.fn(), |
| 84 | +})); |
| 85 | +vi.mock('../../agent/router.js', () => ({ |
| 86 | + resolveAgent: vi.fn(() => 'dev'), shouldRespond: vi.fn(() => true), |
| 87 | +})); |
| 88 | +vi.mock('../../agent/registry.js', () => ({ |
| 89 | + agentRegistry: { get: vi.fn(), getOrThrow: vi.fn(), allIds: vi.fn(() => []) }, |
| 90 | +})); |
| 91 | +vi.mock('../multi-account.js', () => ({ |
| 92 | + accountManager: { getAllBotOpenIds: vi.fn(() => new Set()), getBotOpenId: vi.fn() }, |
| 93 | +})); |
| 94 | +vi.mock('../bot-registry.js', () => ({ |
| 95 | + chatBotRegistry: { getBots: vi.fn(() => []), addBot: vi.fn(), removeBot: vi.fn(), clearChat: vi.fn() }, |
| 96 | +})); |
| 97 | +vi.mock('../approval.js', () => ({ |
| 98 | + checkAndRequestApproval: vi.fn(() => true), handleApprovalTextCommand: vi.fn(() => false), handleApprovalCardAction: vi.fn(), setOnApproved: vi.fn(), |
| 99 | +})); |
| 100 | +vi.mock('../thread-context.js', () => ({ resolveThreadContext: vi.fn() })); |
| 101 | +vi.mock('../../agent/config-loader.js', () => ({ readPersonaFile: vi.fn(), loadKnowledgeContent: vi.fn() })); |
| 102 | +vi.mock('../../agent/tools/discussion.js', () => ({ createDiscussionMcpServer: vi.fn() })); |
| 103 | +vi.mock('../oauth.js', () => ({ generateAuthUrl: vi.fn(), hasCallbackUrl: vi.fn(), handleManualCode: vi.fn() })); |
| 104 | +vi.mock('../../memory/injector.js', () => ({ injectMemories: vi.fn(() => '') })); |
| 105 | +vi.mock('../../memory/extractor.js', () => ({ extractMemories: vi.fn() })); |
| 106 | +vi.mock('../../memory/commands.js', () => ({ handleMemoryCommand: vi.fn(), handleMemoryCardAction: vi.fn() })); |
| 107 | +vi.mock('../../workspace/identity.js', () => ({ getRepoIdentity: vi.fn((p: string) => p) })); |
| 108 | +vi.mock('../../utils/quick-ack.js', () => ({ generateQuickAck: vi.fn() })); |
| 109 | +vi.mock('../../utils/thread-relevance.js', () => ({ checkThreadRelevance: vi.fn() })); |
| 110 | +vi.mock('../../workspace/manager.js', () => ({ setupWorkspace: vi.fn() })); |
| 111 | + |
| 112 | +// ============================================================ |
| 113 | +// Replicate buildChatHistoryContext fork logic for testing |
| 114 | +// (function is private, same approach as direct-thread.test.ts) |
| 115 | +// ============================================================ |
| 116 | + |
| 117 | +const HISTORY_MAX_COUNT = 10; |
| 118 | + |
| 119 | +type SimpleMessage = { |
| 120 | + messageId: string; |
| 121 | + senderId: string; |
| 122 | + senderType: 'user' | 'app'; |
| 123 | + content: string; |
| 124 | + msgType: string; |
| 125 | + createTime?: string; |
| 126 | +}; |
| 127 | + |
| 128 | +interface ForkResult { |
| 129 | + messages: SimpleMessage[]; |
| 130 | + parentMsgCount: number; |
| 131 | +} |
| 132 | + |
| 133 | +/** |
| 134 | + * Extracted fork logic from the updated buildChatHistoryContext. |
| 135 | + */ |
| 136 | +function forkMessages( |
| 137 | + threadId: string | undefined, |
| 138 | + threadMsgs: SimpleMessage[], |
| 139 | + parentMsgs: SimpleMessage[], |
| 140 | + currentMessageId?: string, |
| 141 | +): ForkResult { |
| 142 | + let messages: SimpleMessage[]; |
| 143 | + let parentMsgCount = 0; |
| 144 | + |
| 145 | + if (!threadId) { |
| 146 | + messages = currentMessageId |
| 147 | + ? parentMsgs.filter(m => m.messageId !== currentMessageId) |
| 148 | + : parentMsgs; |
| 149 | + return { messages, parentMsgCount: 0 }; |
| 150 | + } |
| 151 | + |
| 152 | + // Thread mode: fork semantics |
| 153 | + const filtered = currentMessageId |
| 154 | + ? threadMsgs.filter(m => m.messageId !== currentMessageId) |
| 155 | + : threadMsgs; |
| 156 | + |
| 157 | + if (filtered.length === 0) { |
| 158 | + // Thread empty → fork from parent |
| 159 | + messages = parentMsgs; |
| 160 | + } else if (filtered.length <= HISTORY_MAX_COUNT) { |
| 161 | + // Thread < max → supplement with parent |
| 162 | + const remaining = HISTORY_MAX_COUNT - filtered.length; |
| 163 | + if (remaining > 0 && parentMsgs.length > 0) { |
| 164 | + parentMsgCount = parentMsgs.length; |
| 165 | + messages = [...parentMsgs, ...filtered]; |
| 166 | + } else { |
| 167 | + messages = filtered; |
| 168 | + } |
| 169 | + } else { |
| 170 | + // Thread > max → first + latest (max - 1) |
| 171 | + const first = filtered[0]; |
| 172 | + const latest = filtered.slice(-(HISTORY_MAX_COUNT - 1)); |
| 173 | + messages = [first, ...latest]; |
| 174 | + } |
| 175 | + |
| 176 | + return { messages, parentMsgCount }; |
| 177 | +} |
| 178 | + |
| 179 | +function makeMsg(id: string, content: string, senderType: 'user' | 'app' = 'user'): SimpleMessage { |
| 180 | + return { messageId: id, senderId: `sender_${id}`, senderType, content, msgType: 'text' }; |
| 181 | +} |
| 182 | + |
| 183 | +// ============================================================ |
| 184 | +// Tests |
| 185 | +// ============================================================ |
| 186 | + |
| 187 | +describe('buildChatHistoryContext fork semantics', () => { |
| 188 | + describe('fork logic (unit)', () => { |
| 189 | + it('no threadId → returns parent chat messages only', () => { |
| 190 | + const parentMsgs = [makeMsg('p1', 'hello'), makeMsg('p2', 'world')]; |
| 191 | + const { messages, parentMsgCount } = forkMessages(undefined, [], parentMsgs); |
| 192 | + expect(messages).toHaveLength(2); |
| 193 | + expect(parentMsgCount).toBe(0); |
| 194 | + }); |
| 195 | + |
| 196 | + it('no threadId → filters current message', () => { |
| 197 | + const parentMsgs = [makeMsg('p1', 'hello'), makeMsg('current', 'me')]; |
| 198 | + const { messages } = forkMessages(undefined, parentMsgs, parentMsgs, 'current'); |
| 199 | + expect(messages).toHaveLength(1); |
| 200 | + expect(messages[0].messageId).toBe('p1'); |
| 201 | + }); |
| 202 | + |
| 203 | + it('empty thread → fork from parent chat', () => { |
| 204 | + const parentMsgs = [makeMsg('p1', 'parent msg 1'), makeMsg('p2', 'parent msg 2')]; |
| 205 | + const threadMsgs = [makeMsg('current', 'hi')]; // only current message |
| 206 | + const { messages, parentMsgCount } = forkMessages('thread1', threadMsgs, parentMsgs, 'current'); |
| 207 | + expect(messages).toEqual(parentMsgs); |
| 208 | + expect(parentMsgCount).toBe(0); // fork mode, not supplement |
| 209 | + }); |
| 210 | + |
| 211 | + it('thread with 3 messages (< max) → supplement with parent', () => { |
| 212 | + const threadMsgs = [ |
| 213 | + makeMsg('t1', 'thread first'), |
| 214 | + makeMsg('t2', 'thread second'), |
| 215 | + makeMsg('t3', 'thread third'), |
| 216 | + ]; |
| 217 | + const parentMsgs = Array.from({ length: 7 }, (_, i) => makeMsg(`p${i}`, `parent ${i}`)); |
| 218 | + const { messages, parentMsgCount } = forkMessages('thread1', threadMsgs, parentMsgs); |
| 219 | + |
| 220 | + // Parent messages first, then thread messages |
| 221 | + expect(messages).toHaveLength(10); |
| 222 | + expect(messages[0].content).toBe('parent 0'); |
| 223 | + expect(messages[7].content).toBe('thread first'); |
| 224 | + expect(parentMsgCount).toBe(7); |
| 225 | + }); |
| 226 | + |
| 227 | + it('thread with exactly 10 messages → no supplementing', () => { |
| 228 | + const threadMsgs = Array.from({ length: 10 }, (_, i) => makeMsg(`t${i}`, `msg ${i}`)); |
| 229 | + const { messages, parentMsgCount } = forkMessages('thread1', threadMsgs, []); |
| 230 | + expect(messages).toHaveLength(10); |
| 231 | + expect(parentMsgCount).toBe(0); |
| 232 | + }); |
| 233 | + |
| 234 | + it('thread with 15 messages (> max) → first + last 9', () => { |
| 235 | + const threadMsgs = Array.from({ length: 15 }, (_, i) => makeMsg(`t${i}`, `thread msg ${i}`)); |
| 236 | + const { messages, parentMsgCount } = forkMessages('thread1', threadMsgs, []); |
| 237 | + |
| 238 | + expect(messages).toHaveLength(10); |
| 239 | + expect(messages[0].content).toBe('thread msg 0'); // first |
| 240 | + expect(messages[1].content).toBe('thread msg 6'); // latest[0] |
| 241 | + expect(messages[9].content).toBe('thread msg 14'); // latest[8] |
| 242 | + expect(parentMsgCount).toBe(0); |
| 243 | + }); |
| 244 | + |
| 245 | + it('filters current message from thread before fork calculation', () => { |
| 246 | + const threadMsgs = [ |
| 247 | + makeMsg('t1', 'old msg'), |
| 248 | + makeMsg('current', 'current msg'), |
| 249 | + ]; |
| 250 | + const parentMsgs = Array.from({ length: 9 }, (_, i) => makeMsg(`p${i}`, `parent ${i}`)); |
| 251 | + const { messages, parentMsgCount } = forkMessages('thread1', threadMsgs, parentMsgs, 'current'); |
| 252 | + |
| 253 | + // After filtering: 1 thread msg, supplement with 9 parent |
| 254 | + expect(messages).toHaveLength(10); |
| 255 | + expect(messages.find(m => m.content === 'current msg')).toBeUndefined(); |
| 256 | + expect(parentMsgCount).toBe(9); |
| 257 | + }); |
| 258 | + }); |
| 259 | + |
| 260 | + describe('structured sections via formatHistoryMessages', () => { |
| 261 | + it('renders structured sections when parentMsgCount > 0', async () => { |
| 262 | + const { _testFormatHistoryMessages: formatHistoryMessages } = await import('../event-handler.js'); |
| 263 | + const parentMsgs = [ |
| 264 | + makeMsg('p1', 'parent context 1'), |
| 265 | + makeMsg('p2', 'parent context 2'), |
| 266 | + ]; |
| 267 | + const threadMsgs = [ |
| 268 | + makeMsg('t1', 'thread question'), |
| 269 | + ]; |
| 270 | + const combined = [...parentMsgs, ...threadMsgs]; |
| 271 | + |
| 272 | + const result = await formatHistoryMessages(combined, 'chat1', undefined, { parentMsgCount: 2 }); |
| 273 | + |
| 274 | + expect(result).toContain('### 群主聊天'); |
| 275 | + expect(result).toContain('### 当前话题'); |
| 276 | + expect(result).toContain('parent context 1'); |
| 277 | + expect(result).toContain('thread question'); |
| 278 | + }); |
| 279 | + |
| 280 | + it('renders flat list when no parent supplement (parentMsgCount = 0)', async () => { |
| 281 | + const { _testFormatHistoryMessages: formatHistoryMessages } = await import('../event-handler.js'); |
| 282 | + const threadMsgs = [ |
| 283 | + makeMsg('t1', 'msg one'), |
| 284 | + makeMsg('t2', 'msg two'), |
| 285 | + ]; |
| 286 | + |
| 287 | + const result = await formatHistoryMessages(threadMsgs, 'chat1'); |
| 288 | + |
| 289 | + expect(result).toContain('以下是用户 @bot 之前的聊天记录'); |
| 290 | + expect(result).not.toContain('### 群主聊天'); |
| 291 | + expect(result).not.toContain('### 当前话题'); |
| 292 | + }); |
| 293 | + }); |
| 294 | +}); |
0 commit comments