agent-console-smoke-test.js 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  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, roomIdOf } = require('../mcp/src/core/agent-poller-policy');
  10. const {
  11. ClaudeCodeClient,
  12. ClaudeCodeSessionStore,
  13. buildClaudeSessionName,
  14. claudeSessionResetReason,
  15. enforceAuthoritativeGrounding,
  16. extractExplicitCustomerIntelligence,
  17. isNoReplyNeededMessage,
  18. parseClaudeProcessResult,
  19. resolveClaudeExecutable,
  20. selectAuthoritativeHistory,
  21. } = require('../mcp/src/core/agent-runtime');
  22. const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
  23. const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
  24. const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../mcp/src/core/allowlist-config');
  25. const { GroupAgentService } = require('../mcp/src/dashboard/group-agent-service');
  26. const { friendlyAgentError } = require('../mcp/src/core/agent-error-message');
  27. const results = [];
  28. function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
  29. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
  30. const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
  31. globalPaused: paused,
  32. defaultMode,
  33. autoSendConfidence: 0.88,
  34. });
  35. const sent = [];
  36. const qiwei = {
  37. isConfigured: () => true,
  38. async sendText(toId, content) {
  39. sent.push({ toId, content });
  40. return { isSendSuccess: true };
  41. },
  42. };
  43. const agent = {
  44. async run(input) {
  45. if (agentRun) return agentRun(input);
  46. return {
  47. content: '这是 Agent 基于知识检索生成的草稿',
  48. confidence: 0.91,
  49. intent: '购房咨询',
  50. reason: '命中企业规则与 FAQ',
  51. requiresHuman: false,
  52. profileUpdates: { intent: '购房' },
  53. citations: [{ id: 'faq.md#1', source: 'faq.md', heading: 'Agent 能做什么' }],
  54. toolTrace: [{ tool: 'search_knowledge', args: { query: '购房咨询' }, result: [] }],
  55. };
  56. },
  57. };
  58. const config = {
  59. agent: { apiKey: 'smoke-only', model: 'stub-model', provider: 'stub' },
  60. qiwei: { allowedSenders: ['contact-1'] },
  61. };
  62. const service = new AgentWorkbenchService({ db, agent, qiwei, config });
  63. return {
  64. dir,
  65. db,
  66. sent,
  67. service,
  68. close() {
  69. db.close();
  70. fs.rmSync(dir, { recursive: true, force: true });
  71. },
  72. };
  73. }
  74. async function check(name, fn) {
  75. await fn();
  76. results.push({ name, status: 'passed' });
  77. }
  78. async function main() {
  79. await check('invalid contact names cannot overwrite a real customer name', async () => {
  80. const ctx = setup();
  81. try {
  82. const original = ctx.db.ensureConversation('contact-name-test', 'Valid Customer');
  83. const corrupted = ctx.db.ensureConversation('contact-name-test', '??????');
  84. assert.equal(corrupted.id, original.id);
  85. assert.equal(corrupted.contact_name, 'Valid Customer');
  86. const renamed = ctx.db.ensureConversation('contact-name-test', 'Renamed Customer');
  87. assert.equal(renamed.contact_name, 'Renamed Customer');
  88. const unnamed = ctx.db.ensureConversation('contact-unnamed-test', '????');
  89. assert.equal(unnamed.contact_name, '');
  90. } finally { ctx.close(); }
  91. });
  92. await check('Claude Code 原始错误会转换为可操作的用户提示', async () => {
  93. assert.equal(friendlyAgentError('403 reached your usage limit for this billing cycle').code, 'quota_exhausted');
  94. assert.match(friendlyAgentError('403 reached your usage limit for this billing cycle').message, /额度不足/);
  95. assert.equal(friendlyAgentError('Failed to authenticate: invalid API key').code, 'authentication_failed');
  96. assert.equal(friendlyAgentError('spawn claude ENOENT').code, 'cli_not_found');
  97. assert.equal(friendlyAgentError('request timed out').code, 'timeout');
  98. });
  99. await check('白名单选择会去重并拒绝不安全的联系人 ID', async () => {
  100. assert.deepEqual(normalizeAllowlistIds(['contact-1', ' contact-1 ', 'wm_test:2']), ['contact-1', 'wm_test:2']);
  101. assert.throws(() => normalizeAllowlistIds(['contact-1\nINJECTED=true']), /联系人 ID 格式无效/);
  102. assert.deepEqual(normalizeAllowlistContact({ userId: 'contact-1', remark: '刘总', corpName: '示例公司' }), {
  103. id: 'contact-1', displayName: '刘总', remark: '刘总', company: '示例公司'
  104. });
  105. });
  106. await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
  107. const calls = [];
  108. const originalFetch = global.fetch;
  109. global.fetch = async (url, options = {}) => {
  110. const parsedBody = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : null;
  111. calls.push({ url: String(url), options, body: parsedBody });
  112. if (String(url).endsWith('/doFileApi')) {
  113. return { ok: true, status: 200, async text() { return JSON.stringify({ code: 0, data: { data: { fileId: 'file-voice', fileAesKey: 'aes-voice', fileSize: 128 } } }); } };
  114. }
  115. const loginStatus = String(url).includes('/login/status');
  116. let payload;
  117. if (loginStatus) {
  118. payload = { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } };
  119. } else if (parsedBody?.method === '/contact/getWxContactList') {
  120. payload = { code: 0, data: { data: { currentSeq: 9, contactCount: 1, hasMore: false, contactList: [{ userId: 'contact-1' }] } } };
  121. } else if (parsedBody?.method === '/contact/batchGetUserinfo') {
  122. payload = { code: 0, data: { data: { contactList: [{ userId: 'contact-1', nickname: '测试客户' }] } } };
  123. } else {
  124. payload = { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
  125. }
  126. return {
  127. ok: true,
  128. status: 200,
  129. async text() { return JSON.stringify(payload); },
  130. };
  131. };
  132. try {
  133. const client = new FmodeQiweiClient({
  134. authToken: 'test-fmode-token',
  135. uid: 'uid-smoke',
  136. guid: 'guid-smoke',
  137. apiBase: 'https://gateway.example/api/qiwei',
  138. });
  139. const account = await client.checkLogin();
  140. await client.syncMessages(8, 50);
  141. const contacts = await client.listExternalContacts();
  142. await client.sendText('external-contact-1', '测试回复');
  143. await client.sendLocation('external-contact-1', {
  144. title: '会面地点', address: '示例路 1 号', latitude: 31.23, longitude: 121.47,
  145. });
  146. await client.sendWeapp('external-contact-1', {
  147. appId: 'wx-demo-app', username: 'gh_demo', title: '服务入口', pagePath: '/pages/home',
  148. coverFileId: 'cover-file', coverFileAesKey: 'cover-key', coverFileSize: 64,
  149. });
  150. const voiceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-transport-'));
  151. const voicePath = path.join(voiceDir, 'voice.silk');
  152. fs.writeFileSync(voicePath, Buffer.from('#!SILK_V3'));
  153. const uploaded = await client.uploadVoiceFile(voicePath);
  154. await client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 });
  155. fs.rmSync(voiceDir, { recursive: true, force: true });
  156. assert.equal(account.online, true);
  157. assert.equal(account.nickname, '演示账号');
  158. assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/);
  159. assert.equal(calls[0].options.method, 'GET');
  160. assert.equal(calls[1].body.uid, 'uid-smoke');
  161. assert.equal(calls[1].body.method, '/msg/syncMsg');
  162. assert.equal(calls[1].body.params.guid, 'guid-smoke');
  163. assert.equal(contacts.contacts[0].nickname, '测试客户');
  164. assert.equal(calls[2].body.method, '/contact/getWxContactList');
  165. assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
  166. assert.equal(calls[4].body.method, '/msg/sendText');
  167. assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
  168. assert.equal(calls[5].body.method, '/msg/sendLocation');
  169. assert.equal(calls[5].body.params.latitude, 31.23);
  170. assert.equal(calls[6].body.method, '/msg/sendWeapp');
  171. assert.equal(calls[6].body.params.username, 'gh_demo@app');
  172. assert.match(calls[7].url, /\/doFileApi$/);
  173. assert.equal(calls[8].body.method, '/msg/sendVoice');
  174. assert.equal(calls[8].body.params.voiceTime, 2);
  175. let failedSendAttempts = 0;
  176. global.fetch = async () => {
  177. failedSendAttempts += 1;
  178. throw new Error('ambiguous network failure');
  179. };
  180. await assert.rejects(() => client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 }), /网络请求失败/);
  181. assert.equal(failedSendAttempts, 1);
  182. } finally {
  183. global.fetch = originalFetch;
  184. }
  185. });
  186. await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
  187. const { __testing } = require('../mcp/src/dashboard/agent-service');
  188. const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
  189. const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' };
  190. const keyA = __testing.accountRuntimeKey(accountA);
  191. const keyB = __testing.accountRuntimeKey(accountB);
  192. const configA = __testing.accountWorkbenchOverrides(accountA);
  193. const configB = __testing.accountWorkbenchOverrides(accountB);
  194. assert.notEqual(keyA, keyB);
  195. assert.notEqual(configA.dbPath, configB.dbPath);
  196. assert.notEqual(configA.agent.claudeSessionFile, configB.agent.claudeSessionFile);
  197. assert.equal(configA.qiwei.uid, accountA.uid);
  198. assert.equal(configB.qiwei.guid, accountB.guid);
  199. });
  200. await check('白名单文件变更会在监听期间热加载', async () => {
  201. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-allowlist-reload-'));
  202. try {
  203. const envFile = path.join(dir, '.env.local');
  204. fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-1,contact-2\n', 'utf8');
  205. const config = { selfUserId: 'self', allowedSenders: ['contact-1'] };
  206. const { __testing } = require('../mcp/src/dashboard/agent-service');
  207. const refreshed = __testing.refreshAllowedSendersFromEnv(config, envFile);
  208. assert.deepEqual(refreshed, { changed: true, count: 2 });
  209. assert.deepEqual(config.allowedSenders, ['contact-1', 'contact-2']);
  210. const candidate = evaluatePolledMessage({
  211. msgType: 1,
  212. senderId: 'contact-2',
  213. receiverId: 'self',
  214. timestamp: Math.floor(Date.now() / 1000),
  215. msgData: { content: '新加入白名单后的首条消息' },
  216. }, config);
  217. assert.equal(candidate.eligible, true);
  218. } finally {
  219. fs.rmSync(dir, { recursive: true, force: true });
  220. }
  221. });
  222. await check('监听恢复可保留现有 Agent 模式', async () => {
  223. const { __testing } = require('../mcp/src/dashboard/agent-service');
  224. const calls = [];
  225. const target = {
  226. service: {
  227. setGlobal(input) { calls.push(['global', input]); },
  228. setConversationMode(id, mode) { calls.push(['conversation', id, mode]); },
  229. },
  230. db: { listConversations: () => [{ id: 'conversation-a' }] },
  231. };
  232. // preserveAgentState=true 时 stopListener 不调用该接管逻辑;普通人工停止仍调用。
  233. assert.equal(calls.length, 0);
  234. __testing.applyManualTakeover(target);
  235. assert.deepEqual(calls, [
  236. ['global', { paused: false, defaultMode: 'review' }],
  237. ['conversation', 'conversation-a', 'human'],
  238. ]);
  239. });
  240. await check('发送语音后会结算当前待审核草稿并关联语音消息', async () => {
  241. const { __testing } = require('../mcp/src/dashboard/agent-service');
  242. const draft = {
  243. id: 'draft-voice-1',
  244. conversation_id: 'conversation-1',
  245. status: 'pending',
  246. content: '原草稿',
  247. };
  248. const updates = [];
  249. const db = {
  250. getDraft(id) { return id === draft.id ? draft : null; },
  251. listDrafts() { return [draft]; },
  252. updateDraft(id, fields) {
  253. updates.push({ id, fields });
  254. return { ...draft, ...fields };
  255. },
  256. };
  257. const selected = __testing.pendingVoiceDraft(db, 'conversation-1', draft.id);
  258. const resolved = __testing.markVoiceDraftSent(db, selected, {
  259. content: '实际发送的语音内容',
  260. messageId: 'message-voice-1',
  261. });
  262. assert.equal(resolved.status, 'sent');
  263. assert.equal(resolved.content, '实际发送的语音内容');
  264. assert.equal(resolved.sent_message_id, 'message-voice-1');
  265. assert.equal(resolved.reviewer, 'human:voice');
  266. assert.equal(updates.length, 1);
  267. assert.throws(() => __testing.pendingVoiceDraft(db, 'another-conversation', draft.id), /当前会话不匹配/);
  268. });
  269. await check('启动监听保留当前审核策略,不自动切换会话模式', async () => {
  270. const { __testing } = require('../mcp/src/dashboard/agent-service');
  271. const calls = [];
  272. const target = {
  273. poller: {
  274. async start() {
  275. calls.push('poller.start');
  276. return { running: true, syncKey: 7 };
  277. }
  278. }
  279. };
  280. const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
  281. assert.deepEqual(calls, ['poller.start']);
  282. assert.equal(result.status, 'ok');
  283. assert.equal(result.data.running, true);
  284. });
  285. await check('Claude Code 可从 Fmode Studio npm-global PATH 中发现', async () => {
  286. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-path-'));
  287. const executable = process.platform === 'win32'
  288. ? path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')
  289. : path.join(dir, 'claude');
  290. fs.mkdirSync(path.dirname(executable), { recursive: true });
  291. fs.writeFileSync(executable, 'smoke');
  292. const previousPath = process.env.PATH;
  293. try {
  294. process.env.PATH = `${dir}${path.delimiter}${previousPath || ''}`;
  295. assert.equal(resolveClaudeExecutable({}), executable);
  296. } finally {
  297. process.env.PATH = previousPath;
  298. fs.rmSync(dir, { recursive: true, force: true });
  299. }
  300. });
  301. await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
  302. const ctx = setup();
  303. try {
  304. const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
  305. const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
  306. assert.equal(first.status, 'pending_review');
  307. assert.equal(duplicate.status, 'duplicate_content');
  308. assert.equal(ctx.db.listMessages(first.conversation.id).length, 1);
  309. } finally { ctx.close(); }
  310. });
  311. await check('审核模式生成草稿但不自动外发', async () => {
  312. const ctx = setup();
  313. try {
  314. const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' });
  315. assert.equal(result.status, 'pending_review');
  316. assert.equal(ctx.sent.length, 0);
  317. assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending');
  318. assert.equal(result.draft.citations[0].source, 'faq.md');
  319. assert.equal(result.draft.tool_trace[0].tool, 'search_knowledge');
  320. } finally { ctx.close(); }
  321. });
  322. await check('批准草稿只发送一次,重复批准被拒绝', async () => {
  323. const ctx = setup();
  324. try {
  325. const result = await ctx.service.ingestInbound({ externalId: 'm3', contactId: 'contact-1', contactName: '王刚', content: '请给我一个建议' });
  326. await ctx.service.approveDraft(result.draft.id, { content: '人工编辑后的回复', actor: 'human' });
  327. await assert.rejects(() => ctx.service.approveDraft(result.draft.id, { actor: 'human' }), /不能重复发送/);
  328. assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '人工编辑后的回复' }]);
  329. assert.equal(ctx.db.getDraft(result.draft.id).status, 'sent');
  330. } finally { ctx.close(); }
  331. });
  332. await check('全局暂停与人工接管都抑制 Agent', async () => {
  333. const ctx = setup({ paused: true });
  334. try {
  335. const paused = await ctx.service.ingestInbound({ externalId: 'm4', contactId: 'contact-1', contactName: '王刚', content: '暂停时消息' });
  336. assert.equal(paused.status, 'paused');
  337. ctx.service.setGlobal({ paused: false });
  338. ctx.service.setConversationMode(paused.conversation.id, 'human');
  339. const human = await ctx.service.ingestInbound({ externalId: 'm5', contactId: 'contact-1', contactName: '王刚', content: '人工接管时消息' });
  340. assert.equal(human.status, 'human');
  341. assert.equal(ctx.db.listDrafts().length, 0);
  342. assert.equal(ctx.sent.length, 0);
  343. } finally { ctx.close(); }
  344. });
  345. await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
  346. const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
  347. try {
  348. const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' });
  349. assert.equal(result.status, 'agent_failed');
  350. assert.equal(ctx.db.listDrafts().length, 0);
  351. assert.equal(ctx.sent.length, 0);
  352. assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed');
  353. assert.match(result.error, /暂时无法完成/);
  354. const audit = ctx.db.listAudit(20, result.conversation.id).find(item => item.action === 'agent_failed');
  355. assert.equal(audit.detail.message, result.error);
  356. assert.equal(audit.detail.rawMessage, 'Agent 上游暂时不可用(HTTP 522)');
  357. } finally { ctx.close(); }
  358. });
  359. await check('确认消息无需调用模型、无需回复且会清除旧错误状态', async () => {
  360. let agentCalls = 0;
  361. const ctx = setup({ agentRun: async () => { agentCalls += 1; throw new Error('不应调用模型'); } });
  362. try {
  363. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  364. ctx.db.audit({ actor: 'agent', action: 'agent_failed', conversationId: conversation.id, detail: { message: '历史上游失败' } });
  365. const result = await ctx.service.ingestInbound({ externalId: 'm-ack', contactId: 'contact-1', contactName: '王刚', content: '收到' });
  366. assert.equal(result.status, 'no_reply_needed');
  367. assert.equal(agentCalls, 0);
  368. assert.equal(ctx.sent.length, 0);
  369. assert.equal(ctx.db.latestAgentState(conversation.id), null);
  370. assert.equal(ctx.db.latestAgentOutcome(conversation.id).action, 'agent_no_reply_needed');
  371. assert.equal(ctx.db.latestAgentOutcome(conversation.id).entityId, result.message.id);
  372. assert.equal(isNoReplyNeededMessage('好的。'), true);
  373. assert.equal(isNoReplyNeededMessage('地址确认好了吗'), false);
  374. } finally { ctx.close(); }
  375. });
  376. await check('Claude Code 预算超限时轮换客户 Session 并只重试一次', async () => {
  377. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-budget-reset-'));
  378. try {
  379. const client = new ClaudeCodeClient({
  380. claudeWorkdir: dir,
  381. claudeSessionFile: path.join(dir, 'sessions.json'),
  382. claudeMaxBudgetUsd: 0.35,
  383. claudeRetryMaxBudgetUsd: 1,
  384. });
  385. const sessionIds = [];
  386. const invokeOptions = [];
  387. client.invoke = async (_messages, _context, session, options = {}) => {
  388. sessionIds.push(session.id);
  389. invokeOptions.push(options);
  390. if (sessionIds.length === 1) throw new Error('Claude Code 调用失败(退出码 1):error_max_budget_usd');
  391. return { content: '{}', claudeCode: { resumed: false } };
  392. };
  393. const result = await client.complete([{ role: 'user', content: '请推荐房源' }], [], { conversation: { id: 'conversation-budget', contact_name: '王刚' } });
  394. assert.equal(sessionIds.length, 2);
  395. assert.notEqual(sessionIds[0], sessionIds[1]);
  396. assert.equal(invokeOptions[1].maxBudgetUsd, 1);
  397. assert.equal(result.claudeCode.sessionResetReason, 'budget_exceeded');
  398. assert.equal(claudeSessionResetReason(new Error('error_max_budget_usd')), 'budget_exceeded');
  399. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  400. });
  401. await check('Claude Code 客服调用使用精简模式与低推理强度', async () => {
  402. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-bare-'));
  403. try {
  404. let capturedArgs = [];
  405. const client = new ClaudeCodeClient({
  406. claudeWorkdir: dir,
  407. claudeSessionFile: path.join(dir, 'sessions.json'),
  408. claudeBare: true,
  409. claudeEffort: 'low',
  410. claudeTools: 'Read,Glob,Grep',
  411. model: 'deepseek-v4-pro',
  412. });
  413. client.runProcess = async args => {
  414. capturedArgs = args;
  415. return { structured_output: { reply: '测试草稿' }, duration_ms: 1, total_cost_usd: 0.01 };
  416. };
  417. await client.invoke([{ role: 'system', content: '测试' }, { role: 'user', content: '推荐房源' }], {}, { id: '33333333-3333-4333-8333-333333333333', initialized: false });
  418. assert(capturedArgs.includes('--bare'));
  419. assert.equal(capturedArgs[capturedArgs.indexOf('--effort') + 1], 'low');
  420. assert.equal(capturedArgs[capturedArgs.indexOf('--tools') + 1], 'Read,Glob,Grep');
  421. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  422. });
  423. await check('Claude Code 已完成结构化输出时不因末尾预算退出码丢弃草稿', async () => {
  424. const recovered = parseClaudeProcessResult(JSON.stringify({
  425. is_error: true,
  426. subtype: 'error_max_budget_usd',
  427. structured_output: {
  428. reply: '已经生成的客服草稿',
  429. confidence: 0.75,
  430. intent: '继续推荐',
  431. },
  432. }), '', 1);
  433. assert.equal(recovered.error, undefined);
  434. assert.equal(recovered.payload.is_error, false);
  435. assert.equal(recovered.payload.structured_output.reply, '已经生成的客服草稿');
  436. assert.equal(recovered.payload.process_warning.detail, 'error_max_budget_usd');
  437. const failed = parseClaudeProcessResult(JSON.stringify({ is_error: true, subtype: 'error_max_budget_usd' }), '', 1);
  438. assert.match(failed.error, /error_max_budget_usd/);
  439. });
  440. await check('非白名单联系人被忽略且不能人工发送', async () => {
  441. const ctx = setup();
  442. try {
  443. const ignored = await ctx.service.ingestInbound({ externalId: 'm7', contactId: 'contact-2', contactName: '其他人', content: '你好' });
  444. assert.equal(ignored.status, 'ignored_not_allowlisted');
  445. assert.equal(ctx.db.listConversations().length, 0);
  446. const allowed = ctx.db.ensureConversation('contact-1', '王刚');
  447. ctx.db.db.prepare('UPDATE conversations SET contact_id=? WHERE id=?').run('contact-2', allowed.id);
  448. await assert.rejects(() => ctx.service.manualSend(allowed.id, '测试'), /不在测试白名单/);
  449. assert.equal(ctx.sent.length, 0);
  450. } finally { ctx.close(); }
  451. });
  452. await check('项目主控关联下每个客户绑定独立 Claude Code Session', async () => {
  453. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-map-'));
  454. try {
  455. const filePath = path.join(dir, 'sessions.json');
  456. const store = new ClaudeCodeSessionStore(filePath, {
  457. projectId: 'project-smoke',
  458. projectRoot: dir,
  459. mainSessionId: '11111111-1111-4111-8111-111111111111',
  460. });
  461. const first = store.ensure('conversation-a', { customerName: '王刚', displayName: '企微客户-王刚-a001' });
  462. const second = store.ensure('conversation-b', { customerName: '李女士', displayName: '企微客户-李女士-b002' });
  463. assert.notEqual(first.id, second.id);
  464. assert.equal(first.parentControllerSessionId, second.parentControllerSessionId);
  465. assert.equal(first.projectId, 'project-smoke');
  466. assert.equal(first.customerName, '王刚');
  467. assert.equal(first.displayName, '企微客户-王刚-a001');
  468. const persisted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  469. assert.equal(persisted.project.boundMainSessionId, '11111111-1111-4111-8111-111111111111');
  470. assert.equal(Object.keys(persisted.sessions).length, 2);
  471. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  472. });
  473. await check('Claude Code 只采用本轮权威上下文并使用客户可识别会话名', async () => {
  474. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-'));
  475. try {
  476. const messages = [
  477. { role: 'user', content: '我是两个两个买' },
  478. { role: 'user', content: '加进去这个api服务就不用管了' },
  479. { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
  480. { role: 'user', content: '200万吧' },
  481. ];
  482. const authoritative = selectAuthoritativeHistory(messages);
  483. assert.deepEqual(authoritative.map(item => item.content), [
  484. '新北区有4套三室房,请问预算是多少?',
  485. '200万吧',
  486. ]);
  487. const client = new ClaudeCodeClient({
  488. claudeSessionFile: path.join(dir, 'sessions.json'),
  489. claudeWorkdir: dir,
  490. });
  491. const prompt = client.buildPrompt(messages, { profile: { profile: {} } });
  492. assert.match(prompt, /本轮有效会话/);
  493. assert.match(prompt, /200万吧/);
  494. assert.doesNotMatch(prompt, /两个两个买/);
  495. assert.doesNotMatch(prompt, /api服务/);
  496. const sessionName = buildClaudeSessionName({ conversation: { contact_name: '王刚' } }, 'conversation-a');
  497. assert.match(sessionName, /^企微客户-王刚-[a-f0-9]{4}$/);
  498. assert.doesNotMatch(sessionName, /conversation-a/);
  499. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  500. });
  501. await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => {
  502. const history = [
  503. { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
  504. { role: 'user', content: '200万吧' },
  505. ];
  506. const guarded = enforceAuthoritativeGrounding({
  507. reply: '您之前提到“两个两个买”,是想一次买两套吗?',
  508. confidence: 0.9,
  509. intent: '预算确认',
  510. reason: '客户之前说“两个两个买”。',
  511. requiresHuman: false,
  512. }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧');
  513. assert.equal(guarded.requiresHuman, true);
  514. assert(guarded.confidence <= 0.68);
  515. assert.doesNotMatch(guarded.reply, /两个两个买|两套/);
  516. assert.match(guarded.reply, /新北区/);
  517. const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {
  518. profileUpdates: { purchaseQuantity: 2, budgetWan: 200 },
  519. tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }],
  520. alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }],
  521. });
  522. assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined);
  523. assert.equal(intelligence.profileUpdates.budgetWan, 200);
  524. assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/);
  525. });
  526. await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => {
  527. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-guide-'));
  528. try {
  529. const sessionFile = path.join(dir, 'sessions.json');
  530. fs.writeFileSync(sessionFile, JSON.stringify({
  531. version: 1,
  532. project: {},
  533. sessions: {
  534. 'conversation-a': {
  535. id: '22222222-2222-4222-8222-222222222222',
  536. role: 'customer-agent',
  537. initialized: true,
  538. displayName: '企微客户-王刚-a001',
  539. },
  540. },
  541. }), 'utf8');
  542. const guide = getCustomerSessionGuide({ id: 'conversation-a', contact_name: '王刚' }, { sessionFile });
  543. assert.equal(guide.ready, true);
  544. assert.equal(guide.displayName, '企微客户-王刚-a001');
  545. assert.match(guide.openCommand, /agent:session/);
  546. assert.match(guide.openCommand, /王刚/);
  547. assert.doesNotMatch(JSON.stringify(guide), /22222222/);
  548. assert.equal(guide.productionSessionProtected, true);
  549. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  550. });
  551. await check('监听消息持续沉淀客户画像、内部待办和预警', async () => {
  552. const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {});
  553. assert.equal(housing.profileUpdates.preferredRegion, '新北区');
  554. assert.equal(housing.profileUpdates.layout, '三室');
  555. const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {});
  556. assert.equal(explicit.profileUpdates.budgetWan, 200);
  557. assert(explicit.tasks.some(item => item.type === 'recommendation'));
  558. assert(explicit.alerts.some(item => item.type === 'high_intent'));
  559. const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {});
  560. assert.equal(purpose.profileUpdates.purpose, '自住');
  561. const ctx = setup({ agentRun: async () => ({
  562. content: '好的,我再确认一下您的用途和时间计划。',
  563. confidence: 0.82,
  564. intent: '预算确认',
  565. reason: '客户给出明确预算,需要补齐用途和时间。',
  566. requiresHuman: false,
  567. profileUpdates: { budgetWan: 200, budgetType: '待确认' },
  568. tasks: [{ type: 'qualification', title: '确认用途与时间计划', owner: '待分配', dueAt: '', priority: 'high', reason: '关键信息待补齐', evidence: '200万吧' }],
  569. alerts: [{ type: 'high_intent', severity: 'high', title: '预算已明确', detail: '可以进入需求收敛阶段', evidence: '200万吧', recommendedAction: '确认用途与时间' }],
  570. citations: [],
  571. toolTrace: [],
  572. }) });
  573. try {
  574. const result = await ctx.service.ingestInbound({ externalId: 'm-intel', contactId: 'contact-1', contactName: '王刚', content: '200万吧' });
  575. assert.equal(result.status, 'pending_review');
  576. const detail = ctx.service.conversationDetail(result.conversation.id);
  577. assert.equal(detail.profile.profile.budgetWan, 200);
  578. assert.equal(detail.tasks.length, 1);
  579. assert.equal(detail.alerts.length, 1);
  580. assert.equal(ctx.sent.length, 0);
  581. } finally { ctx.close(); }
  582. });
  583. await check('监听重启后仍接收停机期间的白名单积压消息', async () => {
  584. const candidate = evaluatePolledMessage({
  585. msgType: 1,
  586. senderId: 'contact-1',
  587. timestamp: Math.floor(Date.now() / 1000) - 600,
  588. msgData: { content: '自己住吧' },
  589. }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
  590. assert.equal(candidate.eligible, true);
  591. assert.equal(candidate.content, '自己住吧');
  592. });
  593. await check('群聊与本账号消息不会串入白名单客户私聊', async () => {
  594. const group = evaluatePolledMessage({
  595. msgType: 2,
  596. senderId: 'contact-1',
  597. receiverId: 'self',
  598. fromRoomId: 'room-123',
  599. timestamp: Math.floor(Date.now() / 1000),
  600. msgData: { content: '@同事 请发会议录屏' },
  601. }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
  602. assert.equal(group.eligible, false);
  603. assert.equal(group.reason, 'group_message');
  604. assert.equal(group.roomId, 'room-123');
  605. assert.equal(roomIdOf({ fromRoomId: 0, roomId: '' }), '');
  606. const self = evaluatePolledMessage({
  607. msgType: 1,
  608. senderId: 'self',
  609. receiverId: 'contact-1',
  610. timestamp: Math.floor(Date.now() / 1000),
  611. msgData: { content: '我发出的私聊' },
  612. }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
  613. assert.equal(self.eligible, false);
  614. assert.equal(self.reason, 'self_message');
  615. const ctx = setup();
  616. try {
  617. const ignored = await ctx.service.ingestInbound({
  618. externalId: 'group-direct-entry',
  619. contactId: 'contact-1',
  620. contactName: '王刚',
  621. content: '群里的消息',
  622. raw: { fromRoomId: 'room-123', senderId: 'contact-1' },
  623. });
  624. assert.equal(ignored.status, 'ignored_group_message');
  625. assert.equal(ctx.db.listConversations().length, 0);
  626. const legacyGroup = ctx.db.ensureConversation('contact-1', '历史群聊');
  627. ctx.db.insertMessage({
  628. conversationId: legacyGroup.id,
  629. externalId: 'legacy-group-message',
  630. direction: 'inbound',
  631. senderType: 'customer',
  632. content: '旧数据中的群消息',
  633. raw: { fromRoomId: 'room-legacy', senderId: 'contact-1' },
  634. });
  635. await assert.rejects(() => ctx.service.manualSend(legacyGroup.id, '不应发送'), /群聊仅用于监控/);
  636. assert.equal(ctx.sent.length, 0);
  637. } finally { ctx.close(); }
  638. });
  639. await check('客户群支持人工审核与无视风险全自动两种独立模式', async () => {
  640. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-agent-smoke-'));
  641. const messages = [{
  642. msgId: 'group-message-1',
  643. senderId: 'customer-1',
  644. senderName: '张三',
  645. fromRoomId: 'r-1',
  646. content: '明天下午可以看房吗',
  647. timestamp: '2026-07-23T08:00:00.000Z',
  648. }];
  649. const sent = [];
  650. const sendAttempts = [];
  651. const agentInputs = [];
  652. let sendShouldFail = false;
  653. let agentResponse = {
  654. content: '可以的,请问您明天下午几点方便?',
  655. confidence: 0.88,
  656. intent: '预约看房',
  657. reason: '客户明确询问看房时间',
  658. requiresHuman: false,
  659. citations: [],
  660. toolTrace: [],
  661. };
  662. const runtime = {
  663. config: { qiwei: { selfUserId: 'self-1', nickname: '王顾问' } },
  664. agent: {
  665. async run(input) {
  666. agentInputs.push(input);
  667. return agentResponse;
  668. },
  669. },
  670. qiwei: {
  671. async sendText(toId, content) {
  672. sendAttempts.push({ toId, content });
  673. if (sendShouldFail) return { isSendSuccess: false };
  674. sent.push({ toId, content });
  675. return { isSendSuccess: true };
  676. },
  677. },
  678. };
  679. const service = new GroupAgentService({
  680. projectRoot: dir,
  681. statePath: path.join(dir, 'group-agent.json'),
  682. runtime,
  683. getAccount: () => ({ uid: 'account-1', userId: 'self-1', nickname: '王顾问' }),
  684. loadGroups: () => ({ 'r-1': { roomName: '张三买房服务群', customerName: '张三' } }),
  685. loadMessages: () => messages,
  686. appendMessage: (roomId, message) => { messages.push({ ...message, fromRoomId: roomId }); return 'memory'; },
  687. });
  688. try {
  689. assert.equal(service.publicState('r-1').mode, 'review');
  690. const generated = await service.generate('r-1');
  691. assert.equal(generated.status, 'pending_review');
  692. assert.equal(generated.draft.requiresHuman, true);
  693. assert.equal(sent.length, 0);
  694. assert.equal(agentInputs[0].channelType, 'group');
  695. assert.match(agentInputs[0].directPrompt, /企业微信群聊客服 Agent/);
  696. const approved = await service.approve('r-1', generated.draft.id, '可以的,张三,请问您明天下午几点方便?');
  697. assert.equal(approved.status, 'sent');
  698. assert.deepEqual(sent, [{ toId: 'r-1', content: '可以的,张三,请问您明天下午几点方便?' }]);
  699. assert.equal(service.publicState('r-1').pendingReply, null);
  700. assert.equal(service.publicState('r-1').messages.at(-1).role, 'human');
  701. await assert.rejects(() => service.approve('r-1', generated.draft.id, '重复发送'), /已经是 sent/);
  702. await assert.rejects(() => service.generate('unconfirmed-room'), /尚未确认为客户群/);
  703. assert.throws(() => service.setMode('r-1', 'auto'), /需要明确确认/);
  704. assert.equal(service.setMode('r-1', 'auto', 'AUTO_SEND_GROUP_MESSAGES').mode, 'auto');
  705. agentResponse = {
  706. content: '三点可以,我先为您登记。',
  707. confidence: 0.1,
  708. intent: '预约看房',
  709. reason: '低置信回复仍由全自动模式放行',
  710. requiresHuman: true,
  711. citations: [],
  712. toolTrace: [],
  713. };
  714. const ingested = await service.ingestPolledMessage({
  715. msgType: 1,
  716. msgServerId: 'group-message-2',
  717. seq: 2,
  718. senderId: 'customer-1',
  719. senderName: '张三',
  720. fromRoomId: 'r-1',
  721. msgData: { content: '三点可以吗' },
  722. timestamp: Math.floor(Date.now() / 1000),
  723. }, { selfUserId: 'self-1' });
  724. assert.equal(ingested.status, 'auto_sent');
  725. assert.equal(agentInputs.length, 2);
  726. assert.equal(sent.length, 2);
  727. assert.deepEqual(sent.at(-1), { toId: 'r-1', content: '三点可以,我先为您登记。' });
  728. assert.equal(service.publicState('r-1').pendingReply, null);
  729. assert.equal(service.publicState('r-1').lastOutcome.action, 'group_message_auto_sent');
  730. assert.equal(messages.at(-1).rawData.source, 'group_agent_auto');
  731. sendShouldFail = true;
  732. const failed = await service.ingestPolledMessage({
  733. msgType: 1,
  734. msgServerId: 'group-message-3',
  735. seq: 3,
  736. senderId: 'customer-1',
  737. senderName: '张三',
  738. fromRoomId: 'r-1',
  739. msgData: { content: '能发个定位吗' },
  740. timestamp: Math.floor(Date.now() / 1000) + 1,
  741. }, { selfUserId: 'self-1' });
  742. assert.equal(failed.status, 'pending_review');
  743. assert.equal(failed.autoSendFailed, true);
  744. assert.equal(sent.length, 2);
  745. assert.equal(sendAttempts.length, 3);
  746. assert(service.publicState('r-1').pendingReply);
  747. assert(service.publicState('r-1').sendError);
  748. sendShouldFail = false;
  749. assert.equal(service.setMode('r-1', 'review').mode, 'review');
  750. const reviewed = await service.ingestPolledMessage({
  751. msgType: 1,
  752. msgServerId: 'group-message-4',
  753. seq: 4,
  754. senderId: 'customer-1',
  755. senderName: '张三',
  756. fromRoomId: 'r-1',
  757. msgData: { content: '四点也可以' },
  758. timestamp: Math.floor(Date.now() / 1000) + 2,
  759. }, { selfUserId: 'self-1' });
  760. assert.equal(reviewed.status, 'pending_review');
  761. assert.equal(sent.length, 2);
  762. assert.equal(service.publicState('r-1').mode, 'review');
  763. } finally {
  764. fs.rmSync(dir, { recursive: true, force: true });
  765. }
  766. });
  767. await check('同一业务待办只保留一张卡并聚合多条依据', async () => {
  768. const ctx = setup();
  769. try {
  770. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  771. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a');
  772. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b');
  773. const tasks = ctx.db.listCustomerTasks(conversation.id);
  774. assert.equal(tasks.length, 1);
  775. assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']);
  776. } finally { ctx.close(); }
  777. });
  778. await check('用途和购置时间补齐后资格确认待办自动完成', async () => {
  779. let turn = 0;
  780. const ctx = setup({ agentRun: async () => {
  781. turn += 1;
  782. return {
  783. content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false,
  784. profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' },
  785. tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [],
  786. alerts: [], citations: [], toolTrace: [],
  787. };
  788. } });
  789. try {
  790. const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' });
  791. assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open');
  792. await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' });
  793. const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline');
  794. assert.equal(qualification.status, 'done');
  795. assert.equal(qualification.resolution_reason, 'profile_condition_resolved');
  796. } finally { ctx.close(); }
  797. });
  798. await check('发送房源方案后重点方案待办自动完成', async () => {
  799. const ctx = setup();
  800. try {
  801. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  802. ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c');
  803. await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。');
  804. const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist');
  805. assert.equal(task.status, 'done');
  806. assert.equal(task.resolution_reason, 'manual_recommendation_sent');
  807. } finally { ctx.close(); }
  808. });
  809. await check('旧数据库导入时合并重复业务项且不丢依据', async () => {
  810. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-import-'));
  811. const sourcePath = path.join(dir, 'legacy.db');
  812. const targetPath = path.join(dir, 'target.db');
  813. let source = new AgentWorkbenchDb(sourcePath, { defaultMode: 'review' });
  814. const conversation = source.ensureConversation('legacy-contact', '历史客户');
  815. source.close();
  816. const raw = new DatabaseSync(sourcePath);
  817. raw.exec('DROP INDEX IF EXISTS idx_customer_tasks_business_key');
  818. const timestamp = new Date().toISOString();
  819. 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)
  820. VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
  821. insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp);
  822. insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp);
  823. raw.close();
  824. const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' });
  825. try {
  826. const result = target.importCompatibleDatabase(sourcePath);
  827. assert.equal(result.imported, true);
  828. const tasks = target.listCustomerTasks(conversation.id);
  829. assert.equal(tasks.length, 1);
  830. assert.equal(tasks[0].business_key, 'qualification:purpose_and_timeline');
  831. assert.equal(JSON.parse(tasks[0].evidence_json).length, 2);
  832. } finally {
  833. target.close();
  834. fs.rmSync(dir, { recursive: true, force: true });
  835. }
  836. });
  837. await check('Claude Code 提示词读取统一待办和预警主账', async () => {
  838. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-'));
  839. try {
  840. const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') });
  841. const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: {
  842. tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }],
  843. alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
  844. } });
  845. assert.match(prompt, /当前未完成待办/);
  846. assert.match(prompt, /recommendation:shortlist/);
  847. assert.match(prompt, /当前未解决预警/);
  848. assert.match(prompt, /high_intent:core_demand_ready/);
  849. } finally { fs.rmSync(dir, { recursive: true, force: true }); }
  850. });
  851. await check('企微官方待办同步使用可注入 stub 并保持幂等', async () => {
  852. const ctx = setup();
  853. try {
  854. const conversation = ctx.db.ensureConversation('contact-1', '王刚');
  855. const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]);
  856. let createCalls = 0;
  857. const sync = createCustomerTaskOfficialSync({
  858. db: ctx.db,
  859. searchTodoUsers: async ({ keyword }) => ({ status: 'ok', data: { users: [{ id: 'internal-user-1', name: keyword, alias: '' }] } }),
  860. createTodoKnowledge: async input => {
  861. createCalls += 1;
  862. assert.deepEqual(input.followerIds, ['internal-user-1']);
  863. return { status: 'ok', summary: { todoId: 'official-todo-stub' }, data: { todo: { id: 'official-todo-stub' } } };
  864. },
  865. });
  866. await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
  867. await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
  868. const updated = ctx.db.getCustomerTask(task.id);
  869. assert.equal(createCalls, 1);
  870. assert.equal(updated.official_todo_id, 'official-todo-stub');
  871. assert.equal(updated.official_sync_status, 'synced');
  872. assert.equal(updated.status, 'in_progress');
  873. } finally { ctx.close(); }
  874. });
  875. process.stdout.write(`${JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2)}\n`);
  876. }
  877. main().catch(error => {
  878. process.stderr.write(`${error.stack || error.message}\n`);
  879. process.exitCode = 1;
  880. });