agent-autoreply-fallback-smoke-test.js 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. 'use strict';
  2. // Regression coverage for the private autopilot path when model generation is
  3. // unavailable. The fixture intentionally exercises AgentWorkbenchService via
  4. // ingestInbound so delivery gates, stale-message checks, and audit writes are
  5. // covered together.
  6. const assert = require('assert/strict');
  7. const fs = require('fs');
  8. const os = require('os');
  9. const path = require('path');
  10. const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
  11. const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
  12. const results = [];
  13. function fixture() {
  14. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-autoreply-fallback-'));
  15. const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), {
  16. defaultMode: 'autopilot',
  17. autoSendConfidence: 0.88,
  18. });
  19. const sent = [];
  20. let sendRun = async (toId, content) => ({ isSendSuccess: true, msgServerId: `fixture-out-${sent.length + 1}` });
  21. let modelCalls = 0;
  22. let modelRun = async () => {
  23. modelCalls += 1;
  24. const error = new Error('Claude Code 本次运行超时,请稍后重试');
  25. error.code = 'timeout';
  26. throw error;
  27. };
  28. const service = new AgentWorkbenchService({
  29. db,
  30. agent: {
  31. modelClient: { isConfigured: () => true },
  32. async run(input) {
  33. return modelRun(input);
  34. },
  35. },
  36. qiwei: {
  37. isConfigured: () => true,
  38. async sendText(toId, content) {
  39. sent.push({ toId, content });
  40. return sendRun(toId, content);
  41. },
  42. },
  43. config: {
  44. accountKey: 'autoreply-fallback-smoke',
  45. agent: {
  46. provider: 'fixture',
  47. model: 'fixture',
  48. apiKey: 'fixture',
  49. // Keep this smoke test fast while still exercising retryable errors.
  50. generationRetryAttempts: 1,
  51. generationRetryBaseMs: 0,
  52. qualityPassScore: 60,
  53. },
  54. qiwei: { allowedSenders: ['contact-fallback'] },
  55. memory: { enabled: false },
  56. },
  57. });
  58. return {
  59. dir,
  60. db,
  61. service,
  62. sent,
  63. get modelCalls() { return modelCalls; },
  64. setModelRun(fn) { modelRun = fn; },
  65. setSendRun(fn) { sendRun = fn; },
  66. close() {
  67. service.stopBackgroundWorkers();
  68. db.close();
  69. fs.rmSync(dir, { recursive: true, force: true });
  70. },
  71. };
  72. }
  73. function auditActions(ctx, conversationId) {
  74. return ctx.db.listAudit(200, conversationId).map(item => item.action);
  75. }
  76. function hasFallbackAudit(actions) {
  77. return actions.some(action => /generation_fallback/.test(String(action)));
  78. }
  79. async function check(name, fn) {
  80. await fn();
  81. results.push({ name, status: 'passed' });
  82. }
  83. async function testTimeoutUsesConservativeAutopilotFallback() {
  84. const ctx = fixture();
  85. try {
  86. const result = await ctx.service.ingestInbound({
  87. externalId: 'fallback-timeout-1',
  88. contactId: 'contact-fallback',
  89. contactName: '超时兜底测试',
  90. content: '我想了解一下你们的服务',
  91. });
  92. assert.equal(result.status, 'autopilot_sent');
  93. assert.equal(ctx.modelCalls, 1, '模型应先尝试一次,超时后再走兜底');
  94. assert.equal(ctx.sent.length, 1, '普通私聊兜底只允许发送一次');
  95. assert.match(ctx.sent[0].content, /您好|收到/);
  96. assert(ctx.sent[0].content.length >= 8, '兜底回复应为完整的保守确认,而非空消息');
  97. const actions = auditActions(ctx, result.conversation.id);
  98. assert.equal(hasFallbackAudit(actions), true, `缺少 generation_fallback 审计:${actions.join(',')}`);
  99. assert.equal(dbOutboundCount(ctx, result.conversation.id), 1);
  100. } finally {
  101. ctx.close();
  102. }
  103. }
  104. async function testRiskTimeoutNeverAutoSends() {
  105. const ctx = fixture();
  106. try {
  107. const result = await ctx.service.ingestInbound({
  108. externalId: 'fallback-risk-1',
  109. contactId: 'contact-fallback',
  110. contactName: '风险兜底测试',
  111. content: '最低报价能确认吗?',
  112. });
  113. assert(['pending_review', 'agent_failed'].includes(result.status), `风险超时应停留审核或错误态,实际:${result.status}`);
  114. assert.equal(ctx.sent.length, 0, '风险消息即使触发兜底也不能自动发送');
  115. assert.equal(dbOutboundCount(ctx, result.conversation.id), 0);
  116. if (result.status === 'pending_review') {
  117. assert.equal(result.draft.requires_human, true, '风险兜底草稿必须标记人工处理');
  118. }
  119. const actions = auditActions(ctx, result.conversation.id);
  120. assert.equal(actions.some(action => /agent_failed|draft_created|generation_fallback/.test(String(action))), true);
  121. } finally {
  122. ctx.close();
  123. }
  124. }
  125. async function testGreetingBypassesModelAndRepliesImmediately() {
  126. const ctx = fixture();
  127. try {
  128. ctx.setModelRun(async () => {
  129. throw new Error('纯问候不得调用模型');
  130. });
  131. const result = await ctx.service.ingestInbound({
  132. externalId: 'fallback-greeting-1',
  133. contactId: 'contact-fallback',
  134. contactName: '问候测试',
  135. content: '你好',
  136. });
  137. assert.equal(result.status, 'autopilot_sent');
  138. assert.equal(ctx.modelCalls, 0, '纯问候应走确定性快速路径,不调用 Claude');
  139. assert.equal(ctx.sent.length, 1);
  140. assert.match(ctx.sent[0].content, /您好|请问|收到/);
  141. assert.equal(dbOutboundCount(ctx, result.conversation.id), 1);
  142. const actions = auditActions(ctx, result.conversation.id);
  143. assert.equal(actions.some(action => /greeting|deterministic|generation_fallback/.test(String(action))), true,
  144. `缺少问候快速路径审计:${actions.join(',')}`);
  145. } finally {
  146. ctx.close();
  147. }
  148. }
  149. async function testSendFailureRetriesBeforeRecordingSent() {
  150. const ctx = fixture();
  151. try {
  152. let attempts = 0;
  153. ctx.setSendRun(async () => {
  154. attempts += 1;
  155. if (attempts === 1) return { isSendSuccess: false, msg: 'temporary gateway failure' };
  156. return { isSendSuccess: true, msgServerId: 'fixture-out-retry-success' };
  157. });
  158. ctx.service.config.qiwei.sendRetryAttempts = 2;
  159. ctx.service.config.qiwei.sendRetryBaseMs = 0;
  160. const result = await ctx.service.ingestInbound({
  161. externalId: 'fallback-send-retry-1',
  162. contactId: 'contact-fallback',
  163. contactName: '发送重试测试',
  164. content: '请介绍一下服务',
  165. });
  166. assert.equal(result.status, 'autopilot_sent');
  167. assert.equal(attempts, 2, '上游否定确认后应重试一次');
  168. assert.equal(ctx.sent.length, 2);
  169. assert.equal(dbOutboundCount(ctx, result.conversation.id), 1, '只有最终确认成功才写入出站记录');
  170. const actions = auditActions(ctx, result.conversation.id);
  171. assert(actions.includes('autopilot_send_retry_scheduled'));
  172. assert(actions.includes('autopilot_message_sent'));
  173. } finally {
  174. ctx.close();
  175. }
  176. }
  177. async function testSendFailureDoesNotCreateSentRecord() {
  178. const ctx = fixture();
  179. try {
  180. ctx.setSendRun(async () => ({ isSendSuccess: false, msg: 'capacity temporarily unavailable' }));
  181. ctx.service.config.qiwei.sendRetryAttempts = 2;
  182. ctx.service.config.qiwei.sendRetryBaseMs = 0;
  183. const result = await ctx.service.ingestInbound({
  184. externalId: 'fallback-send-fail-1',
  185. contactId: 'contact-fallback',
  186. contactName: '发送失败测试',
  187. content: '请介绍一下服务',
  188. });
  189. assert.equal(result.status, 'autopilot_send_failed');
  190. assert.equal(ctx.sent.length, 2);
  191. assert.equal(dbOutboundCount(ctx, result.conversation.id), 0, '发送未确认时不得伪造 sent 记录');
  192. const claim = ctx.db.db.prepare('SELECT status FROM outbound_delivery_claims WHERE conversation_id=? AND inbound_message_id=?')
  193. .get(result.conversation.id, result.message.id);
  194. assert.equal(claim.status, 'failed');
  195. } finally {
  196. ctx.close();
  197. }
  198. }
  199. function dbOutboundCount(ctx, conversationId) {
  200. return ctx.db.listMessages(conversationId, 100).filter(item => item.direction === 'outbound').length;
  201. }
  202. async function main() {
  203. await check('模型超时后普通私聊发送保守兜底', testTimeoutUsesConservativeAutopilotFallback);
  204. await check('风险消息超时后保持人工审核且不出站', testRiskTimeoutNeverAutoSends);
  205. await check('纯问候不调用模型并立即自动回复', testGreetingBypassesModelAndRepliesImmediately);
  206. await check('上游否定确认后重试并只记录一次成功出站', testSendFailureRetriesBeforeRecordingSent);
  207. await check('连续发送失败不生成伪造 sent 记录', testSendFailureDoesNotCreateSentRecord);
  208. process.stdout.write(`${JSON.stringify({ status: 'passed', results }, null, 2)}\n`);
  209. }
  210. main().catch(error => {
  211. process.stderr.write(`${JSON.stringify({ status: 'failed', message: error.message, stack: error.stack }, null, 2)}\n`);
  212. process.exitCode = 1;
  213. });