'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; });