const assert = require('assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); const { DatabaseSync } = require('node:sqlite'); const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db'); const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service'); const { createCustomerTaskOfficialSync } = require('../mcp/src/core/customer-task-official-sync'); const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy'); const { ClaudeCodeClient, ClaudeCodeSessionStore, buildClaudeSessionName, enforceAuthoritativeGrounding, extractExplicitCustomerIntelligence, selectAuthoritativeHistory, } = require('../mcp/src/core/agent-runtime'); const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide'); const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport'); const results = []; function setup({ paused = false, defaultMode = 'review', agentRun } = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-')); const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), { globalPaused: paused, defaultMode, autoSendConfidence: 0.88, }); const sent = []; const qiwei = { isConfigured: () => true, async sendText(toId, content) { sent.push({ toId, content }); return { isSendSuccess: true }; }, }; const agent = { async run(input) { if (agentRun) return agentRun(input); return { content: '这是 Agent 基于知识检索生成的草稿', confidence: 0.91, intent: '购房咨询', reason: '命中企业规则与 FAQ', requiresHuman: false, profileUpdates: { intent: '购房' }, citations: [{ id: 'faq.md#1', source: 'faq.md', heading: 'Agent 能做什么' }], toolTrace: [{ tool: 'search_knowledge', args: { query: '购房咨询' }, result: [] }], }; }, }; const config = { agent: { apiKey: 'smoke-only', model: 'stub-model', provider: 'stub' }, qiwei: { allowedSenders: ['contact-1'] }, }; const service = new AgentWorkbenchService({ db, agent, qiwei, config }); return { dir, db, sent, service, close() { db.close(); fs.rmSync(dir, { recursive: true, force: true }); }, }; } async function check(name, fn) { await fn(); results.push({ name, status: 'passed' }); } async function main() { await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => { const calls = []; const originalFetch = global.fetch; global.fetch = async (url, options = {}) => { const parsedBody = options.body ? JSON.parse(options.body) : null; calls.push({ url: String(url), options, body: parsedBody }); const loginStatus = String(url).includes('/login/status'); const payload = loginStatus ? { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } } : { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } }; return { ok: true, status: 200, async text() { return JSON.stringify(payload); }, }; }; try { const client = new FmodeQiweiClient({ authToken: 'test-fmode-token', uid: 'uid-smoke', guid: 'guid-smoke', apiBase: 'https://gateway.example/api/qiwei', }); const account = await client.checkLogin(); await client.syncMessages(8, 50); await client.sendText('external-contact-1', '测试回复'); assert.equal(account.online, true); assert.equal(account.nickname, '演示账号'); assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/); assert.equal(calls[0].options.method, 'GET'); assert.equal(calls[1].body.uid, 'uid-smoke'); assert.equal(calls[1].body.method, '/msg/syncMsg'); assert.equal(calls[1].body.params.guid, 'guid-smoke'); assert.equal(calls[2].body.method, '/msg/sendText'); assert.equal(calls[2].options.headers.Authorization, 'Bearer test-fmode-token'); } finally { global.fetch = originalFetch; } }); await check('多企微账号使用独立工作台数据库和 Claude Session', async () => { const { __testing } = require('../mcp/src/dashboard/agent-service'); const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' }; const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' }; const keyA = __testing.accountRuntimeKey(accountA); const keyB = __testing.accountRuntimeKey(accountB); const configA = __testing.accountWorkbenchOverrides(accountA); const configB = __testing.accountWorkbenchOverrides(accountB); assert.notEqual(keyA, keyB); assert.notEqual(configA.dbPath, configB.dbPath); assert.notEqual(configA.agent.claudeSessionFile, configB.agent.claudeSessionFile); assert.equal(configA.qiwei.uid, accountA.uid); assert.equal(configB.qiwei.guid, accountB.guid); }); await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => { const ctx = setup(); try { const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' }); const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' }); assert.equal(first.status, 'pending_review'); assert.equal(duplicate.status, 'duplicate_content'); assert.equal(ctx.db.listMessages(first.conversation.id).length, 1); } finally { ctx.close(); } }); await check('审核模式生成草稿但不自动外发', async () => { const ctx = setup(); try { const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' }); assert.equal(result.status, 'pending_review'); assert.equal(ctx.sent.length, 0); assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending'); assert.equal(result.draft.citations[0].source, 'faq.md'); assert.equal(result.draft.tool_trace[0].tool, 'search_knowledge'); } finally { ctx.close(); } }); await check('批准草稿只发送一次,重复批准被拒绝', async () => { const ctx = setup(); try { const result = await ctx.service.ingestInbound({ externalId: 'm3', contactId: 'contact-1', contactName: '王刚', content: '请给我一个建议' }); await ctx.service.approveDraft(result.draft.id, { content: '人工编辑后的回复', actor: 'human' }); await assert.rejects(() => ctx.service.approveDraft(result.draft.id, { actor: 'human' }), /不能重复发送/); assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '人工编辑后的回复' }]); assert.equal(ctx.db.getDraft(result.draft.id).status, 'sent'); } finally { ctx.close(); } }); await check('全局暂停与人工接管都抑制 Agent', async () => { const ctx = setup({ paused: true }); try { const paused = await ctx.service.ingestInbound({ externalId: 'm4', contactId: 'contact-1', contactName: '王刚', content: '暂停时消息' }); assert.equal(paused.status, 'paused'); ctx.service.setGlobal({ paused: false }); ctx.service.setConversationMode(paused.conversation.id, 'human'); const human = await ctx.service.ingestInbound({ externalId: 'm5', contactId: 'contact-1', contactName: '王刚', content: '人工接管时消息' }); assert.equal(human.status, 'human'); assert.equal(ctx.db.listDrafts().length, 0); assert.equal(ctx.sent.length, 0); } finally { ctx.close(); } }); await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => { const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } }); try { const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' }); assert.equal(result.status, 'agent_failed'); assert.equal(ctx.db.listDrafts().length, 0); assert.equal(ctx.sent.length, 0); assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed'); } finally { ctx.close(); } }); await check('非白名单联系人被忽略且不能人工发送', async () => { const ctx = setup(); try { const ignored = await ctx.service.ingestInbound({ externalId: 'm7', contactId: 'contact-2', contactName: '其他人', content: '你好' }); assert.equal(ignored.status, 'ignored_not_allowlisted'); assert.equal(ctx.db.listConversations().length, 0); const allowed = ctx.db.ensureConversation('contact-1', '王刚'); ctx.db.db.prepare('UPDATE conversations SET contact_id=? WHERE id=?').run('contact-2', allowed.id); await assert.rejects(() => ctx.service.manualSend(allowed.id, '测试'), /不在测试白名单/); assert.equal(ctx.sent.length, 0); } finally { ctx.close(); } }); await check('项目主控关联下每个客户绑定独立 Claude Code Session', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-map-')); try { const filePath = path.join(dir, 'sessions.json'); const store = new ClaudeCodeSessionStore(filePath, { projectId: 'project-smoke', projectRoot: dir, mainSessionId: '11111111-1111-4111-8111-111111111111', }); const first = store.ensure('conversation-a', { customerName: '王刚', displayName: '企微客户-王刚-a001' }); const second = store.ensure('conversation-b', { customerName: '李女士', displayName: '企微客户-李女士-b002' }); assert.notEqual(first.id, second.id); assert.equal(first.parentControllerSessionId, second.parentControllerSessionId); assert.equal(first.projectId, 'project-smoke'); assert.equal(first.customerName, '王刚'); assert.equal(first.displayName, '企微客户-王刚-a001'); const persisted = JSON.parse(fs.readFileSync(filePath, 'utf8')); assert.equal(persisted.project.boundMainSessionId, '11111111-1111-4111-8111-111111111111'); assert.equal(Object.keys(persisted.sessions).length, 2); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); await check('Claude Code 只采用本轮权威上下文并使用客户可识别会话名', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-')); try { const messages = [ { role: 'user', content: '我是两个两个买' }, { role: 'user', content: '加进去这个api服务就不用管了' }, { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' }, { role: 'user', content: '200万吧' }, ]; const authoritative = selectAuthoritativeHistory(messages); assert.deepEqual(authoritative.map(item => item.content), [ '新北区有4套三室房,请问预算是多少?', '200万吧', ]); const client = new ClaudeCodeClient({ claudeSessionFile: path.join(dir, 'sessions.json'), claudeWorkdir: dir, }); const prompt = client.buildPrompt(messages, { profile: { profile: {} } }); assert.match(prompt, /本轮有效会话/); assert.match(prompt, /200万吧/); assert.doesNotMatch(prompt, /两个两个买/); assert.doesNotMatch(prompt, /api服务/); const sessionName = buildClaudeSessionName({ conversation: { contact_name: '王刚' } }, 'conversation-a'); assert.match(sessionName, /^企微客户-王刚-[a-f0-9]{4}$/); assert.doesNotMatch(sessionName, /conversation-a/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => { const history = [ { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' }, { role: 'user', content: '200万吧' }, ]; const guarded = enforceAuthoritativeGrounding({ reply: '您之前提到“两个两个买”,是想一次买两套吗?', confidence: 0.9, intent: '预算确认', reason: '客户之前说“两个两个买”。', requiresHuman: false, }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧'); assert.equal(guarded.requiresHuman, true); assert(guarded.confidence <= 0.68); assert.doesNotMatch(guarded.reply, /两个两个买|两套/); assert.match(guarded.reply, /新北区/); const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, { profileUpdates: { purchaseQuantity: 2, budgetWan: 200 }, tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }], alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }], }); assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined); assert.equal(intelligence.profileUpdates.budgetWan, 200); assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/); }); await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-guide-')); try { const sessionFile = path.join(dir, 'sessions.json'); fs.writeFileSync(sessionFile, JSON.stringify({ version: 1, project: {}, sessions: { 'conversation-a': { id: '22222222-2222-4222-8222-222222222222', role: 'customer-agent', initialized: true, displayName: '企微客户-王刚-a001', }, }, }), 'utf8'); const guide = getCustomerSessionGuide({ id: 'conversation-a', contact_name: '王刚' }, { sessionFile }); assert.equal(guide.ready, true); assert.equal(guide.displayName, '企微客户-王刚-a001'); assert.match(guide.openCommand, /agent:session/); assert.match(guide.openCommand, /王刚/); assert.doesNotMatch(JSON.stringify(guide), /22222222/); assert.equal(guide.productionSessionProtected, true); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); await check('监听消息持续沉淀客户画像、内部待办和预警', async () => { const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {}); assert.equal(housing.profileUpdates.preferredRegion, '新北区'); assert.equal(housing.profileUpdates.layout, '三室'); const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {}); assert.equal(explicit.profileUpdates.budgetWan, 200); assert(explicit.tasks.some(item => item.type === 'recommendation')); assert(explicit.alerts.some(item => item.type === 'high_intent')); const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {}); assert.equal(purpose.profileUpdates.purpose, '自住'); const ctx = setup({ agentRun: async () => ({ content: '好的,我再确认一下您的用途和时间计划。', confidence: 0.82, intent: '预算确认', reason: '客户给出明确预算,需要补齐用途和时间。', requiresHuman: false, profileUpdates: { budgetWan: 200, budgetType: '待确认' }, tasks: [{ type: 'qualification', title: '确认用途与时间计划', owner: '待分配', dueAt: '', priority: 'high', reason: '关键信息待补齐', evidence: '200万吧' }], alerts: [{ type: 'high_intent', severity: 'high', title: '预算已明确', detail: '可以进入需求收敛阶段', evidence: '200万吧', recommendedAction: '确认用途与时间' }], citations: [], toolTrace: [], }) }); try { const result = await ctx.service.ingestInbound({ externalId: 'm-intel', contactId: 'contact-1', contactName: '王刚', content: '200万吧' }); assert.equal(result.status, 'pending_review'); const detail = ctx.service.conversationDetail(result.conversation.id); assert.equal(detail.profile.profile.budgetWan, 200); assert.equal(detail.tasks.length, 1); assert.equal(detail.alerts.length, 1); assert.equal(ctx.sent.length, 0); } finally { ctx.close(); } }); await check('监听重启后仍接收停机期间的白名单积压消息', async () => { const candidate = evaluatePolledMessage({ msgType: 1, senderId: 'contact-1', timestamp: Math.floor(Date.now() / 1000) - 600, msgData: { content: '自己住吧' }, }, { selfUserId: 'self', allowedSenders: ['contact-1'] }); assert.equal(candidate.eligible, true); assert.equal(candidate.content, '自己住吧'); }); await check('同一业务待办只保留一张卡并聚合多条依据', async () => { const ctx = setup(); try { const conversation = ctx.db.ensureConversation('contact-1', '王刚'); ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a'); ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b'); const tasks = ctx.db.listCustomerTasks(conversation.id); assert.equal(tasks.length, 1); assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']); } finally { ctx.close(); } }); await check('用途和购置时间补齐后资格确认待办自动完成', async () => { let turn = 0; const ctx = setup({ agentRun: async () => { turn += 1; return { content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false, profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' }, tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [], alerts: [], citations: [], toolTrace: [], }; } }); try { const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' }); assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open'); await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' }); const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline'); assert.equal(qualification.status, 'done'); assert.equal(qualification.resolution_reason, 'profile_condition_resolved'); } finally { ctx.close(); } }); await check('发送房源方案后重点方案待办自动完成', async () => { const ctx = setup(); try { const conversation = ctx.db.ensureConversation('contact-1', '王刚'); ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c'); await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。'); const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist'); assert.equal(task.status, 'done'); assert.equal(task.resolution_reason, 'manual_recommendation_sent'); } finally { ctx.close(); } }); await check('旧数据库导入时合并重复业务项且不丢依据', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-import-')); const sourcePath = path.join(dir, 'legacy.db'); const targetPath = path.join(dir, 'target.db'); let source = new AgentWorkbenchDb(sourcePath, { defaultMode: 'review' }); const conversation = source.ensureConversation('legacy-contact', '历史客户'); source.close(); const raw = new DatabaseSync(sourcePath); raw.exec('DROP INDEX IF EXISTS idx_customer_tasks_business_key'); const timestamp = new Date().toISOString(); const insert = raw.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,status,evidence,evidence_json,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`); insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp); insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp); raw.close(); const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' }); try { const result = target.importCompatibleDatabase(sourcePath); assert.equal(result.imported, true); const tasks = target.listCustomerTasks(conversation.id); assert.equal(tasks.length, 1); assert.equal(tasks[0].business_key, 'qualification:purpose_and_timeline'); assert.equal(JSON.parse(tasks[0].evidence_json).length, 2); } finally { target.close(); fs.rmSync(dir, { recursive: true, force: true }); } }); await check('Claude Code 提示词读取统一待办和预警主账', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-')); try { const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') }); const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: { tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }], alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }], } }); assert.match(prompt, /当前未完成待办/); assert.match(prompt, /recommendation:shortlist/); assert.match(prompt, /当前未解决预警/); assert.match(prompt, /high_intent:core_demand_ready/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); await check('企微官方待办同步使用可注入 stub 并保持幂等', async () => { const ctx = setup(); try { const conversation = ctx.db.ensureConversation('contact-1', '王刚'); const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]); let createCalls = 0; const sync = createCustomerTaskOfficialSync({ db: ctx.db, searchTodoUsers: async ({ keyword }) => ({ status: 'ok', data: { users: [{ id: 'internal-user-1', name: keyword, alias: '' }] } }), createTodoKnowledge: async input => { createCalls += 1; assert.deepEqual(input.followerIds, ['internal-user-1']); return { status: 'ok', summary: { todoId: 'official-todo-stub' }, data: { todo: { id: 'official-todo-stub' } } }; }, }); await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' }); await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' }); const updated = ctx.db.getCustomerTask(task.id); assert.equal(createCalls, 1); assert.equal(updated.official_todo_id, 'official-todo-stub'); assert.equal(updated.official_sync_status, 'synced'); assert.equal(updated.status, 'in_progress'); } finally { ctx.close(); } }); process.stdout.write(`${JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2)}\n`); } main().catch(error => { process.stderr.write(`${error.stack || error.message}\n`); process.exitCode = 1; });