agent-console-smoke-test.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472
  1. const assert = require('assert/strict');
  2. const fs = require('fs');
  3. const os = require('os');
  4. const path = require('path');
  5. const { DatabaseSync } = require('node:sqlite');
  6. const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
  7. const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
  8. const { createCustomerTaskOfficialSync } = require('../mcp/src/core/customer-task-official-sync');
  9. const { evaluatePolledMessage } = require('../mcp/src/core/agent-poller-policy');
  10. const {
  11. ClaudeCodeClient,
  12. ClaudeCodeSessionStore,
  13. buildClaudeSessionName,
  14. enforceAuthoritativeGrounding,
  15. extractExplicitCustomerIntelligence,
  16. selectAuthoritativeHistory,
  17. } = require('../mcp/src/core/agent-runtime');
  18. const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
  19. const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
  20. const results = [];
  21. function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
  22. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
  23. const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
  24. globalPaused: paused,
  25. defaultMode,
  26. autoSendConfidence: 0.88,
  27. });
  28. const sent = [];
  29. const qiwei = {
  30. isConfigured: () => true,
  31. async sendText(toId, content) {
  32. sent.push({ toId, content });
  33. return { isSendSuccess: true };
  34. },
  35. };
  36. const agent = {
  37. async run(input) {
  38. if (agentRun) return agentRun(input);
  39. return {
  40. content: '这是 Agent 基于知识检索生成的草稿',
  41. confidence: 0.91,
  42. intent: '购房咨询',
  43. reason: '命中企业规则与 FAQ',
  44. requiresHuman: false,
  45. profileUpdates: { intent: '购房' },
  46. citations: [{ id: 'faq.md#1', source: 'faq.md', heading: 'Agent 能做什么' }],
  47. toolTrace: [{ tool: 'search_knowledge', args: { query: '购房咨询' }, result: [] }],
  48. };
  49. },
  50. };
  51. const config = {
  52. agent: { apiKey: 'smoke-only', model: 'stub-model', provider: 'stub' },
  53. qiwei: { allowedSenders: ['contact-1'] },
  54. };
  55. const service = new AgentWorkbenchService({ db, agent, qiwei, config });
  56. return {
  57. dir,
  58. db,
  59. sent,
  60. service,
  61. close() {
  62. db.close();
  63. fs.rmSync(dir, { recursive: true, force: true });
  64. },
  65. };
  66. }
  67. async function check(name, fn) {
  68. await fn();
  69. results.push({ name, status: 'passed' });
  70. }
  71. async function main() {
  72. await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
  73. const calls = [];
  74. const originalFetch = global.fetch;
  75. global.fetch = async (url, options = {}) => {
  76. const parsedBody = options.body ? JSON.parse(options.body) : null;
  77. calls.push({ url: String(url), options, body: parsedBody });
  78. const loginStatus = String(url).includes('/login/status');
  79. const payload = loginStatus
  80. ? { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } }
  81. : { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
  82. return {
  83. ok: true,
  84. status: 200,
  85. async text() { return JSON.stringify(payload); },
  86. };
  87. };
  88. try {
  89. const client = new FmodeQiweiClient({
  90. authToken: 'test-fmode-token',
  91. uid: 'uid-smoke',
  92. guid: 'guid-smoke',
  93. apiBase: 'https://gateway.example/api/qiwei',
  94. });
  95. const account = await client.checkLogin();
  96. await client.syncMessages(8, 50);
  97. await client.sendText('external-contact-1', '测试回复');
  98. assert.equal(account.online, true);
  99. assert.equal(account.nickname, '演示账号');
  100. assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/);
  101. assert.equal(calls[0].options.method, 'GET');
  102. assert.equal(calls[1].body.uid, 'uid-smoke');
  103. assert.equal(calls[1].body.method, '/msg/syncMsg');
  104. assert.equal(calls[1].body.params.guid, 'guid-smoke');
  105. assert.equal(calls[2].body.method, '/msg/sendText');
  106. assert.equal(calls[2].options.headers.Authorization, 'Bearer test-fmode-token');
  107. } finally {
  108. global.fetch = originalFetch;
  109. }
  110. });
  111. await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
  112. const { __testing } = require('../mcp/src/dashboard/agent-service');
  113. const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
  114. const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' };
  115. const keyA = __testing.accountRuntimeKey(accountA);
  116. const keyB = __testing.accountRuntimeKey(accountB);
  117. const configA = __testing.accountWorkbenchOverrides(accountA);
  118. const configB = __testing.accountWorkbenchOverrides(accountB);
  119. assert.notEqual(keyA, keyB);
  120. assert.notEqual(configA.dbPath, configB.dbPath);
  121. assert.notEqual(configA.agent.claudeSessionFile, configB.agent.claudeSessionFile);
  122. assert.equal(configA.qiwei.uid, accountA.uid);
  123. assert.equal(configB.qiwei.guid, accountB.guid);
  124. });
  125. await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
  126. const ctx = setup();
  127. try {
  128. const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
  129. const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
  130. assert.equal(first.status, 'pending_review');
  131. assert.equal(duplicate.status, 'duplicate_content');
  132. assert.equal(ctx.db.listMessages(first.conversation.id).length, 1);
  133. } finally { ctx.close(); }
  134. });
  135. await check('审核模式生成草稿但不自动外发', async () => {
  136. const ctx = setup();
  137. try {
  138. const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' });
  139. assert.equal(result.status, 'pending_review');
  140. assert.equal(ctx.sent.length, 0);
  141. assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending');
  142. assert.equal(result.draft.citations[0].source, 'faq.md');
  143. assert.equal(result.draft.tool_trace[0].tool, 'search_knowledge');
  144. } finally { ctx.close(); }
  145. });
  146. await check('批准草稿只发送一次,重复批准被拒绝', async () => {
  147. const ctx = setup();
  148. try {
  149. const result = await ctx.service.ingestInbound({ externalId: 'm3', contactId: 'contact-1', contactName: '王刚', content: '请给我一个建议' });
  150. await ctx.service.approveDraft(result.draft.id, { content: '人工编辑后的回复', actor: 'human' });
  151. await assert.rejects(() => ctx.service.approveDraft(result.draft.id, { actor: 'human' }), /不能重复发送/);
  152. assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '人工编辑后的回复' }]);
  153. assert.equal(ctx.db.getDraft(result.draft.id).status, 'sent');
  154. } finally { ctx.close(); }
  155. });
  156. await check('全局暂停与人工接管都抑制 Agent', async () => {
  157. const ctx = setup({ paused: true });
  158. try {
  159. const paused = await ctx.service.ingestInbound({ externalId: 'm4', contactId: 'contact-1', contactName: '王刚', content: '暂停时消息' });
  160. assert.equal(paused.status, 'paused');
  161. ctx.service.setGlobal({ paused: false });
  162. ctx.service.setConversationMode(paused.conversation.id, 'human');
  163. const human = await ctx.service.ingestInbound({ externalId: 'm5', contactId: 'contact-1', contactName: '王刚', content: '人工接管时消息' });
  164. assert.equal(human.status, 'human');
  165. assert.equal(ctx.db.listDrafts().length, 0);
  166. assert.equal(ctx.sent.length, 0);
  167. } finally { ctx.close(); }
  168. });
  169. await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
  170. const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
  171. try {
  172. const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' });
  173. assert.equal(result.status, 'agent_failed');
  174. assert.equal(ctx.db.listDrafts().length, 0);
  175. assert.equal(ctx.sent.length, 0);
  176. assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed');
  177. } finally { ctx.close(); }
  178. });
  179. await check('非白名单联系人被忽略且不能人工发送', async () => {
  180. const ctx = setup();
  181. try {
  182. const ignored = await ctx.service.ingestInbound({ externalId: 'm7', contactId: 'contact-2', contactName: '其他人', content: '你好' });
  183. assert.equal(ignored.status, 'ignored_not_allowlisted');
  184. assert.equal(ctx.db.listConversations().length, 0);
  185. const allowed = ctx.db.ensureConversation('contact-1', '王刚');
  186. ctx.db.db.prepare('UPDATE conversations SET contact_id=? WHERE id=?').run('contact-2', allowed.id);
  187. await assert.rejects(() => ctx.service.manualSend(allowed.id, '测试'), /不在测试白名单/);
  188. assert.equal(ctx.sent.length, 0);
  189. } finally { ctx.close(); }
  190. });
  191. await check('项目主控关联下每个客户绑定独立 Claude Code Session', async () => {
  192. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-map-'));
  193. try {
  194. const filePath = path.join(dir, 'sessions.json');
  195. const store = new ClaudeCodeSessionStore(filePath, {
  196. projectId: 'project-smoke',
  197. projectRoot: dir,
  198. mainSessionId: '11111111-1111-4111-8111-111111111111',
  199. });
  200. const first = store.ensure('conversation-a', { customerName: '王刚', displayName: '企微客户-王刚-a001' });
  201. const second = store.ensure('conversation-b', { customerName: '李女士', displayName: '企微客户-李女士-b002' });
  202. assert.notEqual(first.id, second.id);
  203. assert.equal(first.parentControllerSessionId, second.parentControllerSessionId);
  204. assert.equal(first.projectId, 'project-smoke');
  205. assert.equal(first.customerName, '王刚');
  206. assert.equal(first.displayName, '企微客户-王刚-a001');
  207. const persisted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  208. assert.equal(persisted.project.boundMainSessionId, '11111111-1111-4111-8111-111111111111');
  209. assert.equal(Object.keys(persisted.sessions).length, 2);
  210. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  211. });
  212. await check('Claude Code 只采用本轮权威上下文并使用客户可识别会话名', async () => {
  213. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-'));
  214. try {
  215. const messages = [
  216. { role: 'user', content: '我是两个两个买' },
  217. { role: 'user', content: '加进去这个api服务就不用管了' },
  218. { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
  219. { role: 'user', content: '200万吧' },
  220. ];
  221. const authoritative = selectAuthoritativeHistory(messages);
  222. assert.deepEqual(authoritative.map(item => item.content), [
  223. '新北区有4套三室房,请问预算是多少?',
  224. '200万吧',
  225. ]);
  226. const client = new ClaudeCodeClient({
  227. claudeSessionFile: path.join(dir, 'sessions.json'),
  228. claudeWorkdir: dir,
  229. });
  230. const prompt = client.buildPrompt(messages, { profile: { profile: {} } });
  231. assert.match(prompt, /本轮有效会话/);
  232. assert.match(prompt, /200万吧/);
  233. assert.doesNotMatch(prompt, /两个两个买/);
  234. assert.doesNotMatch(prompt, /api服务/);
  235. const sessionName = buildClaudeSessionName({ conversation: { contact_name: '王刚' } }, 'conversation-a');
  236. assert.match(sessionName, /^企微客户-王刚-[a-f0-9]{4}$/);
  237. assert.doesNotMatch(sessionName, /conversation-a/);
  238. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  239. });
  240. await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => {
  241. const history = [
  242. { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
  243. { role: 'user', content: '200万吧' },
  244. ];
  245. const guarded = enforceAuthoritativeGrounding({
  246. reply: '您之前提到“两个两个买”,是想一次买两套吗?',
  247. confidence: 0.9,
  248. intent: '预算确认',
  249. reason: '客户之前说“两个两个买”。',
  250. requiresHuman: false,
  251. }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧');
  252. assert.equal(guarded.requiresHuman, true);
  253. assert(guarded.confidence <= 0.68);
  254. assert.doesNotMatch(guarded.reply, /两个两个买|两套/);
  255. assert.match(guarded.reply, /新北区/);
  256. const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {
  257. profileUpdates: { purchaseQuantity: 2, budgetWan: 200 },
  258. tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }],
  259. alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }],
  260. });
  261. assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined);
  262. assert.equal(intelligence.profileUpdates.budgetWan, 200);
  263. assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/);
  264. });
  265. await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => {
  266. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-guide-'));
  267. try {
  268. const sessionFile = path.join(dir, 'sessions.json');
  269. fs.writeFileSync(sessionFile, JSON.stringify({
  270. version: 1,
  271. project: {},
  272. sessions: {
  273. 'conversation-a': {
  274. id: '22222222-2222-4222-8222-222222222222',
  275. role: 'customer-agent',
  276. initialized: true,
  277. displayName: '企微客户-王刚-a001',
  278. },
  279. },
  280. }), 'utf8');
  281. const guide = getCustomerSessionGuide({ id: 'conversation-a', contact_name: '王刚' }, { sessionFile });
  282. assert.equal(guide.ready, true);
  283. assert.equal(guide.displayName, '企微客户-王刚-a001');
  284. assert.match(guide.openCommand, /agent:session/);
  285. assert.match(guide.openCommand, /王刚/);
  286. assert.doesNotMatch(JSON.stringify(guide), /22222222/);
  287. assert.equal(guide.productionSessionProtected, true);
  288. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  289. });
  290. await check('监听消息持续沉淀客户画像、内部待办和预警', async () => {
  291. const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {});
  292. assert.equal(housing.profileUpdates.preferredRegion, '新北区');
  293. assert.equal(housing.profileUpdates.layout, '三室');
  294. const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {});
  295. assert.equal(explicit.profileUpdates.budgetWan, 200);
  296. assert(explicit.tasks.some(item => item.type === 'recommendation'));
  297. assert(explicit.alerts.some(item => item.type === 'high_intent'));
  298. const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {});
  299. assert.equal(purpose.profileUpdates.purpose, '自住');
  300. const ctx = setup({ agentRun: async () => ({
  301. content: '好的,我再确认一下您的用途和时间计划。',
  302. confidence: 0.82,
  303. intent: '预算确认',
  304. reason: '客户给出明确预算,需要补齐用途和时间。',
  305. requiresHuman: false,
  306. profileUpdates: { budgetWan: 200, budgetType: '待确认' },
  307. tasks: [{ type: 'qualification', title: '确认用途与时间计划', owner: '待分配', dueAt: '', priority: 'high', reason: '关键信息待补齐', evidence: '200万吧' }],
  308. alerts: [{ type: 'high_intent', severity: 'high', title: '预算已明确', detail: '可以进入需求收敛阶段', evidence: '200万吧', recommendedAction: '确认用途与时间' }],
  309. citations: [],
  310. toolTrace: [],
  311. }) });
  312. try {
  313. const result = await ctx.service.ingestInbound({ externalId: 'm-intel', contactId: 'contact-1', contactName: '王刚', content: '200万吧' });
  314. assert.equal(result.status, 'pending_review');
  315. const detail = ctx.service.conversationDetail(result.conversation.id);
  316. assert.equal(detail.profile.profile.budgetWan, 200);
  317. assert.equal(detail.tasks.length, 1);
  318. assert.equal(detail.alerts.length, 1);
  319. assert.equal(ctx.sent.length, 0);
  320. } finally { ctx.close(); }
  321. });
  322. await check('监听重启后仍接收停机期间的白名单积压消息', async () => {
  323. const candidate = evaluatePolledMessage({
  324. msgType: 1,
  325. senderId: 'contact-1',
  326. timestamp: Math.floor(Date.now() / 1000) - 600,
  327. msgData: { content: '自己住吧' },
  328. }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
  329. assert.equal(candidate.eligible, true);
  330. assert.equal(candidate.content, '自己住吧');
  331. });
  332. await check('同一业务待办只保留一张卡并聚合多条依据', async () => {
  333. const ctx = setup();
  334. try {
  335. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  336. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a');
  337. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b');
  338. const tasks = ctx.db.listCustomerTasks(conversation.id);
  339. assert.equal(tasks.length, 1);
  340. assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']);
  341. } finally { ctx.close(); }
  342. });
  343. await check('用途和购置时间补齐后资格确认待办自动完成', async () => {
  344. let turn = 0;
  345. const ctx = setup({ agentRun: async () => {
  346. turn += 1;
  347. return {
  348. content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false,
  349. profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' },
  350. tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [],
  351. alerts: [], citations: [], toolTrace: [],
  352. };
  353. } });
  354. try {
  355. const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' });
  356. assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open');
  357. await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' });
  358. const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline');
  359. assert.equal(qualification.status, 'done');
  360. assert.equal(qualification.resolution_reason, 'profile_condition_resolved');
  361. } finally { ctx.close(); }
  362. });
  363. await check('发送房源方案后重点方案待办自动完成', async () => {
  364. const ctx = setup();
  365. try {
  366. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  367. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c');
  368. await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。');
  369. const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist');
  370. assert.equal(task.status, 'done');
  371. assert.equal(task.resolution_reason, 'manual_recommendation_sent');
  372. } finally { ctx.close(); }
  373. });
  374. await check('旧数据库导入时合并重复业务项且不丢依据', async () => {
  375. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-import-'));
  376. const sourcePath = path.join(dir, 'legacy.db');
  377. const targetPath = path.join(dir, 'target.db');
  378. let source = new AgentWorkbenchDb(sourcePath, { defaultMode: 'review' });
  379. const conversation = source.ensureConversation('legacy-contact', '历史客户');
  380. source.close();
  381. const raw = new DatabaseSync(sourcePath);
  382. raw.exec('DROP INDEX IF EXISTS idx_customer_tasks_business_key');
  383. const timestamp = new Date().toISOString();
  384. 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)
  385. VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
  386. insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp);
  387. insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp);
  388. raw.close();
  389. const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' });
  390. try {
  391. const result = target.importCompatibleDatabase(sourcePath);
  392. assert.equal(result.imported, true);
  393. const tasks = target.listCustomerTasks(conversation.id);
  394. assert.equal(tasks.length, 1);
  395. assert.equal(tasks[0].business_key, 'qualification:purpose_and_timeline');
  396. assert.equal(JSON.parse(tasks[0].evidence_json).length, 2);
  397. } finally {
  398. target.close();
  399. fs.rmSync(dir, { recursive: true, force: true });
  400. }
  401. });
  402. await check('Claude Code 提示词读取统一待办和预警主账', async () => {
  403. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-'));
  404. try {
  405. const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') });
  406. const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: {
  407. tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }],
  408. alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
  409. } });
  410. assert.match(prompt, /当前未完成待办/);
  411. assert.match(prompt, /recommendation:shortlist/);
  412. assert.match(prompt, /当前未解决预警/);
  413. assert.match(prompt, /high_intent:core_demand_ready/);
  414. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  415. });
  416. await check('企微官方待办同步使用可注入 stub 并保持幂等', async () => {
  417. const ctx = setup();
  418. try {
  419. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  420. const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]);
  421. let createCalls = 0;
  422. const sync = createCustomerTaskOfficialSync({
  423. db: ctx.db,
  424. searchTodoUsers: async ({ keyword }) => ({ status: 'ok', data: { users: [{ id: 'internal-user-1', name: keyword, alias: '' }] } }),
  425. createTodoKnowledge: async input => {
  426. createCalls += 1;
  427. assert.deepEqual(input.followerIds, ['internal-user-1']);
  428. return { status: 'ok', summary: { todoId: 'official-todo-stub' }, data: { todo: { id: 'official-todo-stub' } } };
  429. },
  430. });
  431. await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
  432. await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
  433. const updated = ctx.db.getCustomerTask(task.id);
  434. assert.equal(createCalls, 1);
  435. assert.equal(updated.official_todo_id, 'official-todo-stub');
  436. assert.equal(updated.official_sync_status, 'synced');
  437. assert.equal(updated.status, 'in_progress');
  438. } finally { ctx.close(); }
  439. });
  440. process.stdout.write(`${JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2)}\n`);
  441. }
  442. main().catch(error => {
  443. process.stderr.write(`${error.stack || error.message}\n`);
  444. process.exitCode = 1;
  445. });