agent-generation-concurrency-smoke-test.js 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  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 { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
  7. const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
  8. const results = [];
  9. function deferred() {
  10. let resolve;
  11. let reject;
  12. const promise = new Promise((resolvePromise, rejectPromise) => {
  13. resolve = resolvePromise;
  14. reject = rejectPromise;
  15. });
  16. return { promise, resolve, reject };
  17. }
  18. function qualifiedOutput(content, profileUpdates = {}) {
  19. return {
  20. content,
  21. confidence: 0.96,
  22. intent: 'next_step',
  23. reason: 'Deterministic concurrency fixture.',
  24. requiresHuman: false,
  25. profileUpdates,
  26. tasks: [],
  27. alerts: [],
  28. citations: [],
  29. toolTrace: [],
  30. };
  31. }
  32. function setup({ mode = 'review', agentRun, journeyDefinition = null } = {}) {
  33. const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-generation-concurrency-'));
  34. const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), {
  35. defaultMode: mode,
  36. autoSendConfidence: 0.8,
  37. });
  38. const sent = [];
  39. const service = new AgentWorkbenchService({
  40. db,
  41. agent: {
  42. modelClient: { isConfigured: () => true },
  43. run: agentRun,
  44. },
  45. qiwei: {
  46. isConfigured: () => true,
  47. async sendText(toId, content) {
  48. sent.push({ toId, content });
  49. return { isSendSuccess: true };
  50. },
  51. },
  52. config: {
  53. accountKey: 'generation-concurrency-smoke',
  54. agent: {
  55. apiKey: 'fixture',
  56. model: 'fixture',
  57. provider: 'fixture',
  58. qualityPassScore: 82,
  59. journeyDefinition,
  60. },
  61. qiwei: { allowedSenders: ['contact-1', 'contact-2'] },
  62. memory: { enabled: false },
  63. },
  64. });
  65. return {
  66. dir,
  67. db,
  68. sent,
  69. service,
  70. close() {
  71. service.stopBackgroundWorkers();
  72. db.close();
  73. fs.rmSync(dir, { recursive: true, force: true });
  74. },
  75. };
  76. }
  77. async function check(name, fn) {
  78. await fn();
  79. results.push({ name, status: 'passed' });
  80. }
  81. async function runStaleMode(mode) {
  82. const firstStarted = deferred();
  83. const firstReply = deferred();
  84. let calls = 0;
  85. let active = 0;
  86. let maxActive = 0;
  87. const ctx = setup({
  88. mode,
  89. agentRun: async input => {
  90. calls += 1;
  91. active += 1;
  92. maxActive = Math.max(maxActive, active);
  93. try {
  94. if (calls === 1) {
  95. firstStarted.resolve();
  96. await firstReply.promise;
  97. return qualifiedOutput('第一条旧回复不应落库;建议忽略该结果。', { staleField: 'must-not-persist' });
  98. }
  99. assert.match(input.inboundContent, /第二条/);
  100. return qualifiedOutput('第二条信息已作为当前依据;建议按最新内容继续推进。', { latestField: 'persisted' });
  101. } finally {
  102. active -= 1;
  103. }
  104. },
  105. });
  106. try {
  107. const first = ctx.service.ingestInbound({
  108. externalId: `${mode}-first`,
  109. contactId: 'contact-1',
  110. content: '第一条问题,请给建议。',
  111. });
  112. await firstStarted.promise;
  113. const second = ctx.service.ingestInbound({
  114. externalId: `${mode}-second`,
  115. contactId: 'contact-1',
  116. content: '第二条补充,请按最新内容处理。',
  117. });
  118. assert.equal(ctx.sent.length, 0);
  119. firstReply.resolve();
  120. const firstResult = await first;
  121. const secondResult = await second;
  122. assert.equal(firstResult.status, 'stale_discarded');
  123. assert.equal(secondResult.status, mode === 'review' ? 'pending_review' : mode === 'auto' ? 'sent' : 'autopilot_sent');
  124. assert.equal(calls, 2);
  125. assert.equal(maxActive, 1);
  126. assert.equal(ctx.sent.length, mode === 'review' ? 0 : 1);
  127. const conversation = ctx.db.getConversationByContactId('contact-1');
  128. const profile = ctx.db.getProfile(conversation.id).profile;
  129. assert.equal(profile.staleField, undefined);
  130. assert.equal(profile.latestField, 'persisted');
  131. const drafts = ctx.db.listDrafts({ conversationId: conversation.id });
  132. assert.equal(drafts.some(draft => draft.inbound_message_id === firstResult.message.id), false);
  133. assert.equal(ctx.db.listConversationAppliedActions(conversation.id).some(action => (
  134. action.payload?.inboundMessageId === firstResult.message.id
  135. )), false);
  136. assert.equal(ctx.db.listAudit(100, conversation.id).filter(item => item.action === 'agent_response_stale_discarded').length, 1);
  137. } finally {
  138. ctx.close();
  139. }
  140. }
  141. async function main() {
  142. for (const mode of ['review', 'auto', 'autopilot']) {
  143. await check(`${mode} discards a pending stale generation and serializes the next turn`, () => runStaleMode(mode));
  144. }
  145. await check('direct autopilot and automatic approval reject superseded inbound messages', async () => {
  146. const ctx = setup({ mode: 'autopilot', agentRun: async () => qualifiedOutput('当前回复可执行;建议继续推进。') });
  147. try {
  148. const conversation = ctx.db.ensureConversation('contact-1', 'Fixture');
  149. const oldInbound = ctx.db.insertMessage({
  150. conversationId: conversation.id,
  151. direction: 'inbound',
  152. senderType: 'customer',
  153. content: '旧问题',
  154. }).message;
  155. const staleDraft = ctx.db.createDraft({
  156. conversationId: conversation.id,
  157. inboundMessageId: oldInbound.id,
  158. content: '旧草稿',
  159. confidence: 0.99,
  160. requiresHuman: false,
  161. citations: [],
  162. toolTrace: [],
  163. });
  164. ctx.db.insertMessage({
  165. conversationId: conversation.id,
  166. direction: 'inbound',
  167. senderType: 'customer',
  168. content: '新问题',
  169. });
  170. await assert.rejects(() => ctx.service.sendAutopilotReply(conversation, oldInbound, {
  171. content: '旧自动回复',
  172. qualityPassed: true,
  173. requiresHuman: false,
  174. }), error => error.code === 'AGENT_RESPONSE_STALE');
  175. const autoApproval = await ctx.service.approveDraft(staleDraft.id, { actor: 'agent:auto' });
  176. assert.equal(autoApproval.status, 'stale_discarded');
  177. assert.equal(autoApproval.draft.status, 'rejected');
  178. assert.equal(ctx.sent.length, 0);
  179. } finally {
  180. ctx.close();
  181. }
  182. });
  183. await check('human approval remains available for an intentionally reviewed older draft', async () => {
  184. const ctx = setup({ mode: 'review', agentRun: async () => qualifiedOutput('当前回复可执行;建议继续推进。') });
  185. try {
  186. const conversation = ctx.db.ensureConversation('contact-1', 'Fixture');
  187. const oldInbound = ctx.db.insertMessage({ conversationId: conversation.id, direction: 'inbound', senderType: 'customer', content: '旧问题' }).message;
  188. const draft = ctx.db.createDraft({
  189. conversationId: conversation.id,
  190. inboundMessageId: oldInbound.id,
  191. content: '人工确认后的旧问题答复',
  192. confidence: 0.1,
  193. requiresHuman: true,
  194. citations: [],
  195. toolTrace: [],
  196. });
  197. ctx.db.insertMessage({ conversationId: conversation.id, direction: 'inbound', senderType: 'customer', content: '新问题' });
  198. const result = await ctx.service.approveDraft(draft.id, { actor: 'human', content: '人工明确选择发送的内容' });
  199. assert.equal(result.status, 'sent');
  200. assert.equal(ctx.sent.length, 1);
  201. } finally {
  202. ctx.close();
  203. }
  204. });
  205. await check('journey remains planned for a draft and advances only after a real send', async () => {
  206. const journeyDefinition = {
  207. id: 'generic-service-flow',
  208. initialStageId: 'understand',
  209. stages: [
  210. { id: 'understand', requiredFacts: ['goal'], nextStageId: 'qualify' },
  211. { id: 'qualify', requiredFacts: ['budget'], nextStageId: 'act' },
  212. { id: 'act', requiredFacts: [], nextStageId: null },
  213. ],
  214. };
  215. const ctx = setup({
  216. mode: 'review',
  217. journeyDefinition,
  218. agentRun: async () => qualifiedOutput(
  219. '可以围绕当前目标继续推进;建议先整理一个可执行方案。',
  220. { goal: 'improve service', budget: 'confirmed range' },
  221. ),
  222. });
  223. try {
  224. const generated = await ctx.service.ingestInbound({
  225. externalId: 'journey-message',
  226. contactId: 'contact-1',
  227. content: '目标和投入范围都明确了,请给下一步建议。',
  228. });
  229. assert.equal(generated.status, 'pending_review');
  230. let journey = ctx.db.getConversationJourney(generated.conversation.id);
  231. assert.equal(journey.currentStageId, 'understand');
  232. assert.equal(journey.status, 'active');
  233. assert.equal(journey.appliedActions.length, 1);
  234. assert.equal(journey.appliedActions[0].status, 'planned');
  235. await ctx.service.approveDraft(generated.draft.id, { actor: 'human' });
  236. journey = ctx.db.getConversationJourney(generated.conversation.id);
  237. assert.equal(journey.currentStageId, 'act');
  238. assert.equal(journey.status, 'completed');
  239. assert.equal(journey.appliedActions[0].status, 'confirmed');
  240. assert.equal(journey.appliedActions[0].confirmationEvidence.type, 'send');
  241. assert.deepEqual(journey.state.stageHistory.filter(item => item.kind === 'skip').map(item => item.stageId), ['qualify']);
  242. } finally {
  243. ctx.close();
  244. }
  245. });
  246. process.stdout.write(`${JSON.stringify({ status: 'ok', checks: results.length, results }, null, 2)}\n`);
  247. }
  248. main().catch(error => {
  249. console.error(error);
  250. process.exitCode = 1;
  251. });