session-reply-delivery-regression-smoke-test.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. 'use strict';
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const os = require('os');
  5. const path = require('path');
  6. const RUN_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-delivery-'));
  7. process.env.QIWEI_MESSAGES_DIR = path.join(RUN_ROOT, 'messages');
  8. process.env.QIWEI_MESSAGE_ARCHIVE_ENABLED = '1';
  9. const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
  10. const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
  11. const { appendChatRecord, drainQueues } = require('../mcp/src/core/message-archive');
  12. const results = [];
  13. function check(name, fn) {
  14. return Promise.resolve().then(fn).then(() => {
  15. results.push({ name, status: 'passed' });
  16. });
  17. }
  18. function findFiles(root) {
  19. if (!fs.existsSync(root)) return [];
  20. const files = [];
  21. for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
  22. const target = path.join(root, entry.name);
  23. if (entry.isDirectory()) files.push(...findFiles(target));
  24. else files.push(target);
  25. }
  26. return files;
  27. }
  28. function qualifiedOutput(content) {
  29. return {
  30. content,
  31. confidence: 0.96,
  32. intent: 'session_delivery_regression',
  33. reason: 'Deterministic delivery fixture.',
  34. requiresHuman: false,
  35. qualityPassed: true,
  36. qualityScore: 96,
  37. qualityChecks: [],
  38. citations: [],
  39. toolTrace: [],
  40. };
  41. }
  42. async function testLocalOutboundMergesRemoteEcho() {
  43. const dbPath = path.join(RUN_ROOT, 'merge', 'workbench.db');
  44. fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  45. const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
  46. try {
  47. const conversation = db.ensureConversation('contact-merge', 'Fixture');
  48. const createdAt = '2026-08-18T10:00:00.000Z';
  49. const local = db.insertMessage({
  50. conversationId: conversation.id,
  51. direction: 'outbound',
  52. senderType: 'agent',
  53. content: '这条方案我先给您记下,您看周六上午方便吗?',
  54. status: 'sent',
  55. createdAt,
  56. raw: { source: 'autopilot', inboundMessageId: 'inbound-1' },
  57. }).message;
  58. const match = db.findRecentOutboundMatch(conversation.id, local.content, '2026-08-18T10:00:12.000Z', 300);
  59. assert.equal(match.id, local.id);
  60. const merged = db.attachExternalId(local.id, 'remote-outbound-1', { source: 'manual_sync' });
  61. assert.equal(merged.external_id, 'remote-outbound-1');
  62. const replay = db.insertMessage({
  63. conversationId: conversation.id,
  64. externalId: 'remote-outbound-1',
  65. direction: 'outbound',
  66. senderType: 'human',
  67. content: local.content,
  68. status: 'sent',
  69. createdAt: '2026-08-18T10:00:12.000Z',
  70. raw: { source: 'manual_sync' },
  71. });
  72. assert.equal(replay.created, false);
  73. assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
  74. } finally {
  75. db.close();
  76. }
  77. }
  78. async function testConcurrentAutopilotSendsOnce() {
  79. const dbPath = path.join(RUN_ROOT, 'concurrency', 'workbench.db');
  80. fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  81. const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
  82. const sent = [];
  83. const service = new AgentWorkbenchService({
  84. db,
  85. agent: { modelClient: { isConfigured: () => true } },
  86. qiwei: {
  87. isConfigured: () => true,
  88. async sendText(toId, content) {
  89. sent.push({ toId, content });
  90. return { isSendSuccess: true, msgServerId: 'remote-concurrent-1' };
  91. },
  92. },
  93. config: {
  94. accountKey: 'session-delivery-regression',
  95. agent: { qualityPassScore: 82 },
  96. qiwei: { allowedSenders: ['contact-concurrent'] },
  97. memory: { enabled: false },
  98. },
  99. });
  100. try {
  101. const conversation = db.ensureConversation('contact-concurrent', 'Fixture');
  102. const inbound = db.insertMessage({
  103. conversationId: conversation.id,
  104. externalId: 'remote-inbound-1',
  105. direction: 'inbound',
  106. senderType: 'customer',
  107. content: '请把当前最合适的方案发我',
  108. }).message;
  109. const output = qualifiedOutput('按当前条件,这个方案先给您参考;您看周六上午方便吗?');
  110. const [first, second] = await Promise.all([
  111. service.sendAutopilotReply(conversation, inbound, output),
  112. service.sendAutopilotReply(conversation, inbound, output),
  113. ]);
  114. assert.equal(sent.length, 1);
  115. assert.equal(first.status, 'autopilot_sent');
  116. assert.equal(second.status, 'autopilot_already_sent');
  117. assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
  118. assert.equal(db.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_duplicate_send_suppressed').length, 1);
  119. } finally {
  120. if (typeof service.stopBackgroundWorkers === 'function') service.stopBackgroundWorkers();
  121. db.close();
  122. }
  123. }
  124. async function testCrossProcessDeliveryClaimSendsOnce() {
  125. const dbPath = path.join(RUN_ROOT, 'cross-process', 'workbench.db');
  126. fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  127. const dbA = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
  128. const dbB = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
  129. const sent = [];
  130. const makeService = db => new AgentWorkbenchService({
  131. db,
  132. agent: { modelClient: { isConfigured: () => true } },
  133. qiwei: {
  134. isConfigured: () => true,
  135. async sendText(toId, content) {
  136. sent.push({ toId, content });
  137. await new Promise(resolve => setTimeout(resolve, 30));
  138. return { isSendSuccess: true, msgServerId: 'remote-cross-process-1' };
  139. },
  140. },
  141. config: {
  142. accountKey: 'session-delivery-cross-process',
  143. agent: { qualityPassScore: 82 },
  144. qiwei: { allowedSenders: ['contact-cross-process'] },
  145. memory: { enabled: false },
  146. },
  147. });
  148. const serviceA = makeService(dbA);
  149. const serviceB = makeService(dbB);
  150. try {
  151. const conversation = dbA.ensureConversation('contact-cross-process', 'Fixture');
  152. const inbound = dbA.insertMessage({
  153. conversationId: conversation.id,
  154. externalId: 'remote-inbound-cross-process-1',
  155. direction: 'inbound',
  156. senderType: 'customer',
  157. content: '请把当前最合适的方案发我',
  158. }).message;
  159. const output = qualifiedOutput('按当前条件,这个方案先给您参考;您看周六上午方便吗?');
  160. const [first, second] = await Promise.all([
  161. serviceA.sendAutopilotReply(conversation, inbound, output),
  162. serviceB.sendAutopilotReply(conversation, inbound, output),
  163. ]);
  164. assert.equal(sent.length, 1);
  165. assert.deepEqual([first.status, second.status].sort(), ['autopilot_already_sent', 'autopilot_sent']);
  166. assert.equal(dbA.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 1);
  167. assert.equal(dbA.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_delivery_in_flight_suppressed').length, 1);
  168. } finally {
  169. if (typeof serviceA.stopBackgroundWorkers === 'function') serviceA.stopBackgroundWorkers();
  170. if (typeof serviceB.stopBackgroundWorkers === 'function') serviceB.stopBackgroundWorkers();
  171. dbA.close();
  172. dbB.close();
  173. }
  174. }
  175. async function testGlobalPauseSuppressesQueuedAutopilotDelivery() {
  176. const dbPath = path.join(RUN_ROOT, 'global-pause', 'workbench.db');
  177. fs.mkdirSync(path.dirname(dbPath), { recursive: true });
  178. const db = new AgentWorkbenchDb(dbPath, { globalPaused: false, defaultMode: 'autopilot' });
  179. const sent = [];
  180. const service = new AgentWorkbenchService({
  181. db,
  182. agent: { modelClient: { isConfigured: () => true } },
  183. qiwei: {
  184. isConfigured: () => true,
  185. async sendText(toId, content) {
  186. sent.push({ toId, content });
  187. return { isSendSuccess: true, msgServerId: 'remote-global-pause-1' };
  188. },
  189. },
  190. config: {
  191. accountKey: 'session-delivery-global-pause',
  192. agent: { qualityPassScore: 82 },
  193. qiwei: { allowedSenders: ['contact-global-pause'] },
  194. memory: { enabled: false },
  195. },
  196. });
  197. try {
  198. const conversation = db.ensureConversation('contact-global-pause', 'Fixture');
  199. db.setConversationMode(conversation.id, 'autopilot');
  200. const inbound = db.insertMessage({
  201. conversationId: conversation.id,
  202. externalId: 'remote-inbound-global-pause-1',
  203. direction: 'inbound',
  204. senderType: 'customer',
  205. content: '暂停后不要自动发出',
  206. }).message;
  207. service.setGlobal({ paused: true }, 'regression');
  208. const result = await service.sendAutopilotReply(conversation, inbound, qualifiedOutput('这条不应出站'));
  209. assert.equal(result.status, 'paused');
  210. assert.equal(sent.length, 0);
  211. assert.equal(db.listMessages(conversation.id, 20).filter(item => item.direction === 'outbound').length, 0);
  212. assert.equal(db.listAudit(50, conversation.id).filter(item => item.action === 'autopilot_paused_delivery_suppressed').length, 1);
  213. } finally {
  214. if (typeof service.stopBackgroundWorkers === 'function') service.stopBackgroundWorkers();
  215. db.close();
  216. }
  217. }
  218. async function testArchiveDeduplicatesSameVisibleMessage() {
  219. const createdAt = '2026-08-18T10:00:00.000Z';
  220. const payload = {
  221. wxid: 'contact-archive',
  222. messageId: 'local-outbound-archive',
  223. externalId: 'remote-outbound-archive',
  224. dir: 'out',
  225. senderType: 'agent',
  226. content: '同一条可见消息只应归档一次。',
  227. createdAt,
  228. };
  229. await appendChatRecord(payload);
  230. await appendChatRecord({ ...payload, messageId: 'remote-outbound-archive', source: 'manual_sync' });
  231. await drainQueues();
  232. const files = findFiles(path.join(RUN_ROOT, 'messages'));
  233. const rows = files.flatMap(file => fs.readFileSync(file, 'utf8').split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line)));
  234. assert.equal(rows.filter(row => row.content === payload.content && row.dir === 'out').length, 1);
  235. }
  236. async function main() {
  237. await check('local outbound is merged with the remote echo', testLocalOutboundMergesRemoteEcho);
  238. await check('concurrent autopilot delivery emits one visible outbound', testConcurrentAutopilotSendsOnce);
  239. await check('cross-process delivery claim emits one visible outbound', testCrossProcessDeliveryClaimSendsOnce);
  240. await check('global pause suppresses queued autopilot delivery', testGlobalPauseSuppressesQueuedAutopilotDelivery);
  241. await check('chat archive suppresses duplicate visible rows', testArchiveDeduplicatesSameVisibleMessage);
  242. process.stdout.write(`${JSON.stringify({ status: 'ok', checks: results.length, results }, null, 2)}\n`);
  243. }
  244. main().catch(error => {
  245. console.error(error);
  246. process.exitCode = 1;
  247. });