| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228 |
- 'use strict';
- // Regression coverage for the private autopilot path when model generation is
- // unavailable. The fixture intentionally exercises AgentWorkbenchService via
- // ingestInbound so delivery gates, stale-message checks, and audit writes are
- // covered together.
- const assert = require('assert/strict');
- const fs = require('fs');
- const os = require('os');
- const path = require('path');
- const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
- const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
- const results = [];
- function fixture() {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-autoreply-fallback-'));
- const db = new AgentWorkbenchDb(path.join(dir, 'workbench.db'), {
- defaultMode: 'autopilot',
- autoSendConfidence: 0.88,
- });
- const sent = [];
- let sendRun = async (toId, content) => ({ isSendSuccess: true, msgServerId: `fixture-out-${sent.length + 1}` });
- let modelCalls = 0;
- let modelRun = async () => {
- modelCalls += 1;
- const error = new Error('Claude Code 本次运行超时,请稍后重试');
- error.code = 'timeout';
- throw error;
- };
- const service = new AgentWorkbenchService({
- db,
- agent: {
- modelClient: { isConfigured: () => true },
- async run(input) {
- return modelRun(input);
- },
- },
- qiwei: {
- isConfigured: () => true,
- async sendText(toId, content) {
- sent.push({ toId, content });
- return sendRun(toId, content);
- },
- },
- config: {
- accountKey: 'autoreply-fallback-smoke',
- agent: {
- provider: 'fixture',
- model: 'fixture',
- apiKey: 'fixture',
- // Keep this smoke test fast while still exercising retryable errors.
- generationRetryAttempts: 1,
- generationRetryBaseMs: 0,
- qualityPassScore: 60,
- },
- qiwei: { allowedSenders: ['contact-fallback'] },
- memory: { enabled: false },
- },
- });
- return {
- dir,
- db,
- service,
- sent,
- get modelCalls() { return modelCalls; },
- setModelRun(fn) { modelRun = fn; },
- setSendRun(fn) { sendRun = fn; },
- close() {
- service.stopBackgroundWorkers();
- db.close();
- fs.rmSync(dir, { recursive: true, force: true });
- },
- };
- }
- function auditActions(ctx, conversationId) {
- return ctx.db.listAudit(200, conversationId).map(item => item.action);
- }
- function hasFallbackAudit(actions) {
- return actions.some(action => /generation_fallback/.test(String(action)));
- }
- async function check(name, fn) {
- await fn();
- results.push({ name, status: 'passed' });
- }
- async function testTimeoutUsesConservativeAutopilotFallback() {
- const ctx = fixture();
- try {
- const result = await ctx.service.ingestInbound({
- externalId: 'fallback-timeout-1',
- contactId: 'contact-fallback',
- contactName: '超时兜底测试',
- content: '我想了解一下你们的服务',
- });
- assert.equal(result.status, 'autopilot_sent');
- assert.equal(ctx.modelCalls, 1, '模型应先尝试一次,超时后再走兜底');
- assert.equal(ctx.sent.length, 1, '普通私聊兜底只允许发送一次');
- assert.match(ctx.sent[0].content, /您好|收到/);
- assert(ctx.sent[0].content.length >= 8, '兜底回复应为完整的保守确认,而非空消息');
- const actions = auditActions(ctx, result.conversation.id);
- assert.equal(hasFallbackAudit(actions), true, `缺少 generation_fallback 审计:${actions.join(',')}`);
- assert.equal(dbOutboundCount(ctx, result.conversation.id), 1);
- } finally {
- ctx.close();
- }
- }
- async function testRiskTimeoutNeverAutoSends() {
- const ctx = fixture();
- try {
- const result = await ctx.service.ingestInbound({
- externalId: 'fallback-risk-1',
- contactId: 'contact-fallback',
- contactName: '风险兜底测试',
- content: '最低报价能确认吗?',
- });
- assert(['pending_review', 'agent_failed'].includes(result.status), `风险超时应停留审核或错误态,实际:${result.status}`);
- assert.equal(ctx.sent.length, 0, '风险消息即使触发兜底也不能自动发送');
- assert.equal(dbOutboundCount(ctx, result.conversation.id), 0);
- if (result.status === 'pending_review') {
- assert.equal(result.draft.requires_human, true, '风险兜底草稿必须标记人工处理');
- }
- const actions = auditActions(ctx, result.conversation.id);
- assert.equal(actions.some(action => /agent_failed|draft_created|generation_fallback/.test(String(action))), true);
- } finally {
- ctx.close();
- }
- }
- async function testGreetingBypassesModelAndRepliesImmediately() {
- const ctx = fixture();
- try {
- ctx.setModelRun(async () => {
- throw new Error('纯问候不得调用模型');
- });
- const result = await ctx.service.ingestInbound({
- externalId: 'fallback-greeting-1',
- contactId: 'contact-fallback',
- contactName: '问候测试',
- content: '你好',
- });
- assert.equal(result.status, 'autopilot_sent');
- assert.equal(ctx.modelCalls, 0, '纯问候应走确定性快速路径,不调用 Claude');
- assert.equal(ctx.sent.length, 1);
- assert.match(ctx.sent[0].content, /您好|请问|收到/);
- assert.equal(dbOutboundCount(ctx, result.conversation.id), 1);
- const actions = auditActions(ctx, result.conversation.id);
- assert.equal(actions.some(action => /greeting|deterministic|generation_fallback/.test(String(action))), true,
- `缺少问候快速路径审计:${actions.join(',')}`);
- } finally {
- ctx.close();
- }
- }
- async function testSendFailureRetriesBeforeRecordingSent() {
- const ctx = fixture();
- try {
- let attempts = 0;
- ctx.setSendRun(async () => {
- attempts += 1;
- if (attempts === 1) return { isSendSuccess: false, msg: 'temporary gateway failure' };
- return { isSendSuccess: true, msgServerId: 'fixture-out-retry-success' };
- });
- ctx.service.config.qiwei.sendRetryAttempts = 2;
- ctx.service.config.qiwei.sendRetryBaseMs = 0;
- const result = await ctx.service.ingestInbound({
- externalId: 'fallback-send-retry-1',
- contactId: 'contact-fallback',
- contactName: '发送重试测试',
- content: '请介绍一下服务',
- });
- assert.equal(result.status, 'autopilot_sent');
- assert.equal(attempts, 2, '上游否定确认后应重试一次');
- assert.equal(ctx.sent.length, 2);
- assert.equal(dbOutboundCount(ctx, result.conversation.id), 1, '只有最终确认成功才写入出站记录');
- const actions = auditActions(ctx, result.conversation.id);
- assert(actions.includes('autopilot_send_retry_scheduled'));
- assert(actions.includes('autopilot_message_sent'));
- } finally {
- ctx.close();
- }
- }
- async function testSendFailureDoesNotCreateSentRecord() {
- const ctx = fixture();
- try {
- ctx.setSendRun(async () => ({ isSendSuccess: false, msg: 'capacity temporarily unavailable' }));
- ctx.service.config.qiwei.sendRetryAttempts = 2;
- ctx.service.config.qiwei.sendRetryBaseMs = 0;
- const result = await ctx.service.ingestInbound({
- externalId: 'fallback-send-fail-1',
- contactId: 'contact-fallback',
- contactName: '发送失败测试',
- content: '请介绍一下服务',
- });
- assert.equal(result.status, 'autopilot_send_failed');
- assert.equal(ctx.sent.length, 2);
- assert.equal(dbOutboundCount(ctx, result.conversation.id), 0, '发送未确认时不得伪造 sent 记录');
- const claim = ctx.db.db.prepare('SELECT status FROM outbound_delivery_claims WHERE conversation_id=? AND inbound_message_id=?')
- .get(result.conversation.id, result.message.id);
- assert.equal(claim.status, 'failed');
- } finally {
- ctx.close();
- }
- }
- function dbOutboundCount(ctx, conversationId) {
- return ctx.db.listMessages(conversationId, 100).filter(item => item.direction === 'outbound').length;
- }
- async function main() {
- await check('模型超时后普通私聊发送保守兜底', testTimeoutUsesConservativeAutopilotFallback);
- await check('风险消息超时后保持人工审核且不出站', testRiskTimeoutNeverAutoSends);
- await check('纯问候不调用模型并立即自动回复', testGreetingBypassesModelAndRepliesImmediately);
- await check('上游否定确认后重试并只记录一次成功出站', testSendFailureRetriesBeforeRecordingSent);
- await check('连续发送失败不生成伪造 sent 记录', testSendFailureDoesNotCreateSentRecord);
- process.stdout.write(`${JSON.stringify({ status: 'passed', results }, null, 2)}\n`);
- }
- main().catch(error => {
- process.stderr.write(`${JSON.stringify({ status: 'failed', message: error.message, stack: error.stack }, null, 2)}\n`);
- process.exitCode = 1;
- });
|