Skip to content

Commit 9748975

Browse files
lishuceoclaude
andauthored
fix: 话题模式下 agent 补充主群消息上下文 (#228)
* fix: buildChatHistoryContext 话题模式补充主群消息 话题模式下 agent 只能看到话题内消息,无法看到主群的上下文。 将 buildDirectTaskHistory 的 fork 语义移植到 buildChatHistoryContext, 统一行为:话题为空时 fork 主群、消息不足时补充、超量时保留首条+最新。 传递 parentMsgCount 给 formatHistoryMessages 启用结构化分区。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: 添加 buildChatHistoryContext fork 语义测试 覆盖 fork 逻辑的全部分支:无话题、话题为空、话题补充、 话题超量、当前消息过滤、结构化分区渲染。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 移除未使用的 beforeEach import 修复 lint error Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4623024 commit 9748975

2 files changed

Lines changed: 339 additions & 14 deletions

File tree

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
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+
});

src/feishu/event-handler.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,31 +1444,62 @@ async function buildChatHistoryContext(
14441444
selfBotOpenIds?: Set<string>,
14451445
): Promise<HistoryResult> {
14461446
try {
1447-
const containerId = threadId ?? chatId;
1448-
const containerType = threadId ? 'thread' as const : 'chat' as const;
1449-
const messages = await feishuClient.fetchRecentMessages(containerId, containerType, config.chat.historyMaxCount, threadId ? chatId : undefined);
1447+
type HistoryMsg = { messageId: string; senderId: string; senderType: 'user' | 'app'; content: string; msgType: string; createTime?: string; imageRefs?: Array<{ imageKey: string }> };
1448+
let messages: HistoryMsg[];
1449+
let parentMsgCount = 0;
14501450

1451-
// 过滤掉当前消息
1452-
let filtered = currentMessageId
1453-
? messages.filter(m => m.messageId !== currentMessageId)
1454-
: messages;
1451+
if (!threadId) {
1452+
// 主聊天区:直接取父群最近消息
1453+
messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount);
1454+
} else {
1455+
// 话题模式:fork 语义(与 buildDirectTaskHistory 一致)
1456+
const threadMsgs = await feishuClient.fetchRecentMessages(threadId, 'thread', 50, chatId);
1457+
const filtered = currentMessageId
1458+
? threadMsgs.filter(m => m.messageId !== currentMessageId)
1459+
: threadMsgs;
1460+
1461+
if (filtered.length === 0) {
1462+
// 话题为空,从父群 fork
1463+
messages = await feishuClient.fetchRecentMessages(chatId, 'chat', config.chat.historyMaxCount);
1464+
} else if (filtered.length <= config.chat.historyMaxCount) {
1465+
// 话题消息不足 max,补充父群消息
1466+
const remaining = config.chat.historyMaxCount - filtered.length;
1467+
if (remaining > 0) {
1468+
const parentMsgs = await feishuClient.fetchRecentMessages(chatId, 'chat', remaining);
1469+
parentMsgCount = parentMsgs.length;
1470+
messages = [...parentMsgs, ...filtered];
1471+
} else {
1472+
messages = filtered;
1473+
}
1474+
} else {
1475+
// 话题消息 > max:首条 + 最近 (max - 1) 条
1476+
const first = filtered[0];
1477+
const latest = filtered.slice(-(config.chat.historyMaxCount - 1));
1478+
messages = [first, ...latest];
1479+
}
1480+
}
1481+
1482+
// 过滤当前消息(主聊天区路径,话题路径已在上面过滤)
1483+
if (!threadId && currentMessageId) {
1484+
messages = messages.filter(m => m.messageId !== currentMessageId);
1485+
}
14551486

14561487
// 记录最新 messageId(去重锚点,在过滤 afterMsgId 之前取)
1457-
const newestMsgId = filtered.length > 0 ? filtered[filtered.length - 1].messageId : undefined;
1488+
const newestMsgId = messages.length > 0 ? messages[messages.length - 1].messageId : undefined;
14581489

14591490
// 增量去重:只保留 afterMsgId 之后的新消息
1460-
if (afterMsgId && filtered.length > 0) {
1461-
const idx = filtered.findIndex(m => m.messageId === afterMsgId);
1491+
if (afterMsgId && messages.length > 0) {
1492+
const idx = messages.findIndex(m => m.messageId === afterMsgId);
14621493
if (idx >= 0) {
1463-
filtered = filtered.slice(idx + 1);
1494+
messages = messages.slice(idx + 1);
14641495
}
14651496
// afterMsgId 不在列表中 → 可能消息已过期滚动,注入全部
14661497
}
14671498

14681499
const [text, images, historyFiles] = await Promise.all([
1469-
formatHistoryMessages(filtered, chatId, selfBotOpenIds),
1470-
downloadHistoryImages(filtered),
1471-
downloadHistoryFiles(filtered),
1500+
formatHistoryMessages(messages, chatId, selfBotOpenIds, parentMsgCount > 0 ? { parentMsgCount } : undefined),
1501+
downloadHistoryImages(messages),
1502+
downloadHistoryFiles(messages),
14721503
]);
14731504
return {
14741505
text: text ?? undefined,

0 commit comments

Comments
 (0)