Files
WechatExplorer/tests/integration/agent-hub-inbound-flow.test.ts
T
Wxw-Gu 4b2395a8d2 feat: 重写agent hub连接器,新增对话记录和微信输入状态
- 新增 Agent Hub 对话记录面板,按会话回看机器人与微信用户的完整收发内容
- 新增微信原生正在输入状态,长任务维持 typing,异常路径强制收尾
2026-09-17 17:32:37 +08:00

483 lines
18 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
userData: '',
ask: vi.fn(),
generateAgentGroupReport: vi.fn(),
isReady: vi.fn(),
listRecentChat: vi.fn(),
listContacts: vi.fn(),
listMessages: vi.fn(),
getGroupSnapshot: vi.fn()
}))
vi.mock('electron', () => ({
app: { getPath: () => mocks.userData },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../src/main/services/ask-wechat-service', () => ({
AskWechatService: class {
ask(input: unknown, conversationKey?: string): Promise<unknown> {
return mocks.ask(input, conversationKey)
}
}
}))
vi.mock('../../src/main/services/query-agent-service', () => ({ QueryAgentService: class {} }))
vi.mock('../../src/main/services/agent-group-report-service', () => ({
generateAgentGroupReport: (input: unknown) => mocks.generateAgentGroupReport(input)
}))
vi.mock('../../src/main/services/ai-provider-service', () => ({
AIProviderService: class {
async chat(): Promise<{ success: boolean; data: string }> {
return { success: true, data: 'AI 总结' }
}
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => mocks.isReady(),
listRecentChat: (limit: number) => mocks.listRecentChat(limit),
listContacts: () => mocks.listContacts(),
listMessages: (...args: unknown[]) => mocks.listMessages(...args),
getGroupSnapshot: (md5: string) => mocks.getGroupSnapshot(md5),
resolveMd5: vi.fn()
}))
import {
AgentHubService,
type AgentHubWechatConnectorPort
} from '../../src/main/services/agent-hub-service'
import { WechatInboundInbox } from '../../src/main/services/wechat-inbound-inbox'
import { WechatSendGateway } from '../../src/main/services/wechat-send-gateway'
import { WechatSendLogService } from '../../src/main/services/wechat-send-log-service'
import type { AppSettings } from '../../src/main/services/settings-store'
import type { WechatInboundMessage } from '../../src/main/services/wechat-ilink/types'
const ACCOUNT_ID = 'bot-1'
const USER_ID = 'user@im.wechat'
function inbound(overrides: Partial<WechatInboundMessage> = {}): WechatInboundMessage {
return {
accountId: ACCOUNT_ID,
fromUserId: USER_ID,
messageId: '1001',
messageType: 1,
contextToken: 'ctx-inbound',
items: [{ type: 1, text: '微信里最近发生了什么' }],
receivedAt: 1_700_000_000_000,
...overrides
}
}
function createFakeConnector(
contextTokens: Map<string, string>,
typingEvents: string[],
typingShouldFail: () => boolean
): AgentHubWechatConnectorPort {
return {
setHost: () => undefined,
listAccounts: () => [{ accountId: ACCOUNT_ID, wechatUserId: 'user_42' }],
startLogin: () => undefined,
cancelLogin: () => undefined,
submitVerifyCode: () => undefined,
isLoginInProgress: () => false,
start: async () => undefined,
stop: async () => undefined,
getPhase: () => 'polling',
getActiveAccountId: () => ACCOUNT_ID,
resolveContextToken: (_accountId, toUserId) => contextTokens.get(toUserId),
beginTyping: async () => {
if (typingShouldFail()) throw new Error('typing 不可用')
typingEvents.push('typing-on')
return {
stop: async () => {
typingEvents.push('typing-off')
}
}
},
sendText: async () => undefined,
sendMediaPath: async () => undefined,
sendMediaUrl: async () => undefined
}
}
describe('Agent Hub 入站 → 出站闭环', () => {
const roots: string[] = []
let service: AgentHubService
let inbox: WechatInboundInbox
let gateway: WechatSendGateway
let sent: unknown[]
let ilinkShouldFail: boolean
let contextTokens: Map<string, string>
let userDataRoot: string
/** 与发送交织在一起的事件序列,用来断言 typing 的生命周期边界。 */
let typingEvents: string[]
let typingShouldFail: boolean
/** 还要失败几次发送;0 表示不再失败。用于确定性地构造"先失败后成功"。 */
let ilinkFailuresRemaining: number
beforeEach(() => {
const root = mkdtempSync(join(tmpdir(), 'tracememo-agent-hub-'))
roots.push(root)
userDataRoot = root
mocks.userData = root
mocks.ask
.mockReset()
.mockResolvedValue({ status: 'answered', answer: '这是 Query Agent 的回答' })
mocks.generateAgentGroupReport.mockReset()
mocks.isReady.mockReset().mockReturnValue(true)
mocks.listRecentChat.mockReset().mockReturnValue([])
mocks.listContacts.mockReset().mockReturnValue([])
mocks.listMessages.mockReset().mockReturnValue([])
mocks.getGroupSnapshot.mockReset().mockReturnValue(null)
sent = []
ilinkShouldFail = false
typingEvents = []
typingShouldFail = false
ilinkFailuresRemaining = 0
contextTokens = new Map()
inbox = new WechatInboundInbox({
filePath: () => join(root, 'agent-hub', 'inbound-inbox.json')
})
gateway = new WechatSendGateway({
now: () => 1_700_000_000_000,
createRequestId: (() => {
let sequence = 0
return () => `req-${(sequence += 1)}`
})(),
log: new WechatSendLogService({ getUserDataPath: () => root }),
sendIlink: async (request) => {
// 先把这次发送记进事件序列,失败与否都算一次尝试。
sent.push(request)
typingEvents.push(`send:${String((request as { type: string }).type)}`)
if (ilinkFailuresRemaining > 0) {
ilinkFailuresRemaining -= 1
throw new Error('iLink 发送失败')
}
if (ilinkShouldFail) throw new Error('iLink 发送失败')
}
})
service = new AgentHubService({ inbox, inboundRetryDelayMs: 5 })
service.setSendGateway(gateway)
service.setWechatConnector(
createFakeConnector(contextTokens, typingEvents, () => typingShouldFail)
)
service.setQueryAgentService({} as never)
})
afterEach(() => {
service.stop()
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
it('入站 context_token 一路带到出站发送,不丢失', async () => {
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound()])
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(sent[0]).toMatchObject({
to: USER_ID,
type: 'text',
msg: '这是 Query Agent 的回答',
context_token: 'ctx-inbound',
transport: 'ilink'
})
expect(mocks.ask).toHaveBeenCalledWith(
expect.objectContaining({ text: '微信里最近发生了什么' }),
`${ACCOUNT_ID}::${USER_ID}`
)
})
it('处理成功后收件箱清空,重复 message_id 不会二次回复', async () => {
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound()])
await vi.waitFor(() => expect(sent).toHaveLength(1))
await service.handleInboundMessages([inbound()])
await vi.waitFor(() => expect(inbox.size()).toBe(0))
expect(sent).toHaveLength(1)
})
it('发送失败时消息保留在收件箱,恢复后自动补发', async () => {
await service.start({} as AppSettings)
// 用"前 1 次发送必定失败"代替布尔标志,避免测试与后台处理抢时序。
ilinkFailuresRemaining = 1
await service.handleInboundMessages([inbound({ messageId: '2002' })])
// 失败后条目必须仍留在收件箱,不能被静默丢弃。
const key = `${ACCOUNT_ID}::2002`
await vi.waitFor(() => expect(inbox.contains(key)).toBe(true))
await vi.waitFor(() => expect(inbox.contains(key)).toBe(false))
expect(
sent.filter((item) => (item as { type: string }).type === 'text').length
).toBeGreaterThanOrEqual(2)
})
it('群日报请求先回执确认语,再触发 Report Action', async () => {
mocks.generateAgentGroupReport.mockResolvedValue({
success: true,
pngPath: '/tmp/report.png',
groupName: '产品交流群',
messageCount: 12
})
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({ messageId: '3003', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] })
])
await vi.waitFor(() => expect(mocks.generateAgentGroupReport).toHaveBeenCalled())
await vi.waitFor(() =>
expect(sent.some((item) => (item as { type: string }).type === 'image')).toBe(true)
)
expect(sent[0]).toMatchObject({ type: 'text', context_token: 'ctx-inbound' })
expect(String((sent[0] as { msg: string }).msg)).toContain('正在生成群聊总结')
expect(mocks.generateAgentGroupReport).toHaveBeenCalledWith(
expect.objectContaining({ range: 'today' })
)
// 群日报图片仍走同一条发送通道,并保留会话上下文。
expect(sent.find((item) => (item as { type: string }).type === 'image')).toMatchObject({
msg: '/tmp/report.png',
context_token: 'ctx-inbound'
})
})
it('会话列表请求走确定性能力,不调用 Query Agent', async () => {
mocks.listRecentChat.mockReturnValue([
{ m_nsNickName: '张三', m_nsUsrName: 'wxid_a', type: 'contact' }
])
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({ messageId: '4004', items: [{ type: 1, text: '最近 5 个会话' }] })
])
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(String((sent[0] as { msg: string }).msg)).toContain('张三')
expect(mocks.ask).not.toHaveBeenCalled()
})
it('主动通知在没有入站 token 时回退到会话最近一次有效 token', async () => {
contextTokens.set(USER_ID, 'ctx-cached')
await service.start({} as AppSettings)
const result = await service.sendNotification({ to: USER_ID, text: '定时日报已生成' })
expect(result).toMatchObject({ success: true, status: 'sent' })
expect(sent[0]).toMatchObject({ context_token: 'ctx-cached', msg: '定时日报已生成' })
})
it('登录失效时把 STALE_TOKEN 映射成 token_expired', async () => {
await service.start({} as AppSettings)
gateway.configureIlinkSender(async () => {
const { ILinkError } = await import('../../src/main/services/wechat-ilink/errors')
throw new ILinkError({ kind: 'stale_token', message: '登录凭证已失效', ret: -14 })
})
const result = await service.sendNotification({ to: USER_ID, text: '测试' })
expect(result).toMatchObject({ success: false, status: 'token_expired' })
})
it('未注入 iLink 通道时映射成 connector_offline', async () => {
await service.start({} as AppSettings)
const bare = new WechatSendGateway({
log: new WechatSendLogService({ getUserDataPath: () => mocks.userData })
})
service.setSendGateway(bare)
const result = await service.sendNotification({ to: USER_ID, text: '测试' })
expect(result).toMatchObject({ success: false, status: 'connector_offline' })
})
it('对话记录保存完整收发内容,且不落 context_token', async () => {
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound()])
// 出站记录发生在发送返回之后,所以等对话记录本身而不是等 sent。
await vi.waitFor(() => expect(service.getConversation(USER_ID)?.messages).toHaveLength(2))
const conversation = service.getConversation(USER_ID)
expect(conversation?.messages.map((message) => [message.direction, message.text])).toEqual([
['in', '微信里最近发生了什么'],
['out', '这是 Query Agent 的回答']
])
expect(service.listConversations()).toHaveLength(1)
const raw = readFileSync(join(userDataRoot, 'agent-hub', 'conversations.json'), 'utf8')
// 会话上下文令牌属于运行时凭据,绝不能跟着对话记录落盘。
expect(raw).not.toContain('ctx-inbound')
expect(raw).not.toContain('context_token')
})
it('发送失败也会记进对话记录,并带上错误码', async () => {
await service.start({} as AppSettings)
ilinkShouldFail = true
await service.handleInboundMessages([inbound({ messageId: '5005' })])
await vi.waitFor(() => {
const messages = service.getConversation(USER_ID)?.messages ?? []
expect(messages.some((message) => message.status === 'failed')).toBe(true)
})
const failed = service
.getConversation(USER_ID)
?.messages.find((message) => message.status === 'failed')
expect(failed).toMatchObject({ direction: 'out', errorCode: 'SEND_FAILED' })
})
it('群日报图片在对话记录里记为图片消息', async () => {
mocks.generateAgentGroupReport.mockResolvedValue({
success: true,
pngPath: '/tmp/report.png',
groupName: '产品交流群',
messageCount: 12
})
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({ messageId: '6006', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] })
])
await vi.waitFor(() => {
const kinds = service.getConversation(USER_ID)?.messages.map((message) => message.kind) ?? []
expect(kinds).toContain('image')
})
const image = service
.getConversation(USER_ID)
?.messages.find((message) => message.kind === 'image')
// 媒体只记录文件名,不记录完整本地路径。
expect(image?.text).toBe('report.png')
})
it('普通查询:typing ON → AI → 回复 → typing OFF,且不额外发"收到"', async () => {
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound({ messageId: '8101' })])
await vi.waitFor(() => expect(typingEvents).toContain('typing-off'))
expect(typingEvents).toEqual(['typing-on', 'send:text', 'typing-off'])
// 普通查询只发最终回答,没有"收到,正在查询"这类确认语。
expect(sent).toHaveLength(1)
expect(String((sent[0] as { msg: string }).msg)).toBe('这是 Query Agent 的回答')
})
it('群日报:确认语 → typing ON → 生成 → 文字+图片 → typing OFF', async () => {
mocks.generateAgentGroupReport.mockResolvedValue({
success: true,
pngPath: '/tmp/report.png',
groupName: '产品交流群',
messageCount: 12
})
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({ messageId: '8102', items: [{ type: 1, text: '生成产品交流群今天的群聊总结图片' }] })
])
await vi.waitFor(() =>
expect(typingEvents.filter((event) => event === 'typing-off')).toHaveLength(1)
)
expect(typingEvents[0]).toBe('send:text')
expect(typingEvents).toContain('typing-on')
expect(typingEvents).toContain('send:image')
// typing 必须在图片发完之后才收掉。
expect(typingEvents.indexOf('typing-off')).toBeGreaterThan(typingEvents.indexOf('send:image'))
})
it('Query Agent 抛异常时仍然收掉 typing', async () => {
mocks.ask.mockRejectedValueOnce(new Error('provider 挂了'))
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound({ messageId: '8103' })])
await vi.waitFor(() => expect(typingEvents).toContain('typing-off'))
expect(typingEvents[0]).toBe('typing-on')
expect(typingEvents.indexOf('typing-off')).toBeGreaterThan(typingEvents.indexOf('send:text'))
})
it('最终消息发送失败时仍然收掉 typing', async () => {
await service.start({} as AppSettings)
ilinkShouldFail = true
await service.handleInboundMessages([inbound({ messageId: '8104' })])
await vi.waitFor(() => expect(typingEvents).toContain('typing-off'))
expect(typingEvents[0]).toBe('typing-on')
expect(typingEvents[typingEvents.length - 1]).toBe('typing-off')
})
it('typing 不可用时业务照常执行并回复', async () => {
typingShouldFail = true
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound({ messageId: '8105' })])
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(String((sent[0] as { msg: string }).msg)).toBe('这是 Query Agent 的回答')
expect(typingEvents).toEqual(['send:text'])
})
it('recent_list 这类快速确定性回复不触发 typing', async () => {
mocks.listRecentChat.mockReturnValue([
{ m_nsNickName: '张三', m_nsUsrName: 'wxid_a', type: 'contact' }
])
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({ messageId: '8106', items: [{ type: 1, text: '最近 5 个会话' }] })
])
await vi.waitFor(() => expect(sent).toHaveLength(1))
expect(typingEvents).toEqual(['send:text'])
})
it('成员分析:确认语之后才 typing ON,结果发完才 OFF', async () => {
mocks.listContacts.mockReturnValue([
{ type: 'group', m_nsNickName: '产品交流群', m_nsUsrName: 'room@chatroom', md5: 'md5-room' }
])
mocks.getGroupSnapshot.mockReturnValue({
members: [
{
wxid: 'wxid_zhang',
groupNickname: '张三',
wechatNickname: '张三',
remark: '',
nickname: '张三'
}
]
})
mocks.listMessages.mockReturnValue([
{
datetime: '2026-09-17 10:00',
content: '今天聊了新版本',
type: '普通文本',
senderId: 'wxid_zhang',
name: '张三'
}
])
await service.start({} as AppSettings)
await service.handleInboundMessages([
inbound({
messageId: '8107',
items: [{ type: 1, text: '看看产品交流群里张三今天说了什么' }]
})
])
await vi.waitFor(() => expect(typingEvents).toContain('typing-off'))
// 确认语在前,typing 在后。
expect(typingEvents[0]).toBe('send:text')
expect(typingEvents.indexOf('typing-on')).toBeGreaterThan(0)
expect(typingEvents[typingEvents.length - 1]).toBe('typing-off')
})
it('清空对话记录后不再保留任何会话', async () => {
await service.start({} as AppSettings)
await service.handleInboundMessages([inbound()])
await vi.waitFor(() => expect(service.listConversations()).toHaveLength(1))
service.clearConversations()
expect(service.listConversations()).toEqual([])
expect(service.getConversation(USER_ID)).toBeNull()
})
})