| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010 |
- const assert = require('assert/strict');
- const fs = require('fs');
- const os = require('os');
- const path = require('path');
- const { DatabaseSync } = require('node:sqlite');
- const { AgentWorkbenchDb } = require('../mcp/src/core/agent-workbench-db');
- const { AgentWorkbenchService } = require('../mcp/src/core/agent-workbench-service');
- const { createCustomerTaskOfficialSync } = require('../mcp/src/core/customer-task-official-sync');
- const { evaluatePolledMessage, roomIdOf } = require('../mcp/src/core/agent-poller-policy');
- const {
- ClaudeCodeClient,
- ClaudeCodeSessionStore,
- buildClaudeSessionName,
- claudeSessionResetReason,
- enforceAuthoritativeGrounding,
- extractExplicitCustomerIntelligence,
- isNoReplyNeededMessage,
- parseClaudeProcessResult,
- resolveClaudeExecutable,
- selectAuthoritativeHistory,
- } = require('../mcp/src/core/agent-runtime');
- const { getCustomerSessionGuide } = require('../mcp/src/core/agent-session-guide');
- const { FmodeQiweiClient } = require('../mcp/src/providers/fmode-agent-transport');
- const { normalizeAllowlistIds, normalizeAllowlistContact } = require('../mcp/src/core/allowlist-config');
- const { GroupAgentService } = require('../mcp/src/dashboard/group-agent-service');
- const { friendlyAgentError } = require('../mcp/src/core/agent-error-message');
- const results = [];
- const PACKAGE_ROOT = path.resolve(__dirname, '..');
- function setup({ paused = false, defaultMode = 'review', agentRun } = {}) {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-smoke-'));
- const db = new AgentWorkbenchDb(path.join(dir, 'test.db'), {
- globalPaused: paused,
- defaultMode,
- autoSendConfidence: 0.88,
- });
- const sent = [];
- const qiwei = {
- isConfigured: () => true,
- async sendText(toId, content) {
- sent.push({ toId, content });
- return { isSendSuccess: true };
- },
- };
- const agent = {
- async run(input) {
- if (agentRun) return agentRun(input);
- return {
- content: '这是 Agent 基于知识检索生成的草稿',
- confidence: 0.91,
- intent: '购房咨询',
- reason: '命中企业规则与 FAQ',
- requiresHuman: false,
- profileUpdates: { intent: '购房' },
- citations: [{ id: 'faq.md#1', source: 'faq.md', heading: 'Agent 能做什么' }],
- toolTrace: [{ tool: 'search_knowledge', args: { query: '购房咨询' }, result: [] }],
- };
- },
- };
- const config = {
- agent: { apiKey: 'smoke-only', model: 'stub-model', provider: 'stub' },
- qiwei: { allowedSenders: ['contact-1'] },
- };
- const service = new AgentWorkbenchService({ db, agent, qiwei, config });
- return {
- dir,
- db,
- sent,
- service,
- close() {
- db.close();
- fs.rmSync(dir, { recursive: true, force: true });
- },
- };
- }
- async function check(name, fn) {
- await fn();
- results.push({ name, status: 'passed' });
- }
- async function main() {
- await check('invalid contact names cannot overwrite a real customer name', async () => {
- const ctx = setup();
- try {
- const original = ctx.db.ensureConversation('contact-name-test', 'Valid Customer');
- const corrupted = ctx.db.ensureConversation('contact-name-test', '??????');
- assert.equal(corrupted.id, original.id);
- assert.equal(corrupted.contact_name, 'Valid Customer');
- const renamed = ctx.db.ensureConversation('contact-name-test', 'Renamed Customer');
- assert.equal(renamed.contact_name, 'Renamed Customer');
- const unnamed = ctx.db.ensureConversation('contact-unnamed-test', '????');
- assert.equal(unnamed.contact_name, '');
- } finally { ctx.close(); }
- });
- await check('Claude Code 原始错误会转换为可操作的用户提示', async () => {
- assert.equal(friendlyAgentError('403 reached your usage limit for this billing cycle').code, 'quota_exhausted');
- assert.match(friendlyAgentError('403 reached your usage limit for this billing cycle').message, /额度不足/);
- assert.equal(friendlyAgentError('Failed to authenticate: invalid API key').code, 'authentication_failed');
- assert.equal(friendlyAgentError('spawn claude ENOENT').code, 'cli_not_found');
- assert.equal(friendlyAgentError('request timed out').code, 'timeout');
- });
- await check('白名单选择会去重并拒绝不安全的联系人 ID', async () => {
- assert.deepEqual(normalizeAllowlistIds(['contact-1', ' contact-1 ', 'wm_test:2']), ['contact-1', 'wm_test:2']);
- assert.throws(() => normalizeAllowlistIds(['contact-1\nINJECTED=true']), /联系人 ID 格式无效/);
- assert.deepEqual(normalizeAllowlistContact({ userId: 'contact-1', remark: '刘总', corpName: '示例公司' }), {
- id: 'contact-1', displayName: '刘总', remark: '刘总', company: '示例公司'
- });
- });
- await check('Agent 企微传输统一走 Fmode 网关与登录专用端点', async () => {
- const calls = [];
- const originalFetch = global.fetch;
- global.fetch = async (url, options = {}) => {
- const parsedBody = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : null;
- calls.push({ url: String(url), options, body: parsedBody });
- if (String(url).endsWith('/doFileApi')) {
- return { ok: true, status: 200, async text() { return JSON.stringify({ code: 0, data: { data: { fileId: 'file-voice', fileAesKey: 'aes-voice', fileSize: 128 } } }); } };
- }
- const loginStatus = String(url).includes('/login/status');
- let payload;
- if (loginStatus) {
- payload = { code: 0, data: { configured: true, online: true, statusCode: 2, detail: { nickname: '演示账号' } } };
- } else if (parsedBody?.method === '/contact/getWxContactList') {
- payload = { code: 0, data: { data: { currentSeq: 9, contactCount: 1, hasMore: false, contactList: [{ userId: 'contact-1' }] } } };
- } else if (parsedBody?.method === '/contact/batchGetUserinfo') {
- payload = { code: 0, data: { data: { contactList: [{ userId: 'contact-1', nickname: '测试客户' }] } } };
- } else {
- payload = { code: 0, data: { data: { isSendSuccess: true, syncMsgList: [], travelSyncKey: 9 } } };
- }
- return {
- ok: true,
- status: 200,
- async text() { return JSON.stringify(payload); },
- };
- };
- try {
- const client = new FmodeQiweiClient({
- authToken: 'test-fmode-token',
- uid: 'uid-smoke',
- guid: 'guid-smoke',
- apiBase: 'https://gateway.example/api/qiwei',
- });
- const account = await client.checkLogin();
- await client.syncMessages(8, 50);
- const contacts = await client.listExternalContacts();
- await client.sendText('external-contact-1', '测试回复');
- await client.sendLocation('external-contact-1', {
- title: '会面地点', address: '示例路 1 号', latitude: 31.23, longitude: 121.47,
- });
- await client.sendWeapp('external-contact-1', {
- appId: 'wx-demo-app', username: 'gh_demo', title: '服务入口', pagePath: '/pages/home',
- coverFileId: 'cover-file', coverFileAesKey: 'cover-key', coverFileSize: 64,
- });
- const voiceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-transport-'));
- const voicePath = path.join(voiceDir, 'voice.silk');
- fs.writeFileSync(voicePath, Buffer.from('#!SILK_V3'));
- const uploaded = await client.uploadVoiceFile(voicePath);
- await client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 });
- fs.rmSync(voiceDir, { recursive: true, force: true });
- assert.equal(account.online, true);
- assert.equal(account.nickname, '演示账号');
- assert.match(calls[0].url, /\/login\/status\?uid=uid-smoke$/);
- assert.equal(calls[0].options.method, 'GET');
- assert.equal(calls[1].body.uid, 'uid-smoke');
- assert.equal(calls[1].body.method, '/msg/syncMsg');
- assert.equal(calls[1].body.params.guid, 'guid-smoke');
- assert.equal(contacts.contacts[0].nickname, '测试客户');
- assert.equal(calls[2].body.method, '/contact/getWxContactList');
- assert.equal(calls[3].body.method, '/contact/batchGetUserinfo');
- assert.equal(calls[4].body.method, '/msg/sendText');
- assert.equal(calls[4].options.headers.Authorization, 'Bearer test-fmode-token');
- assert.equal(calls[5].body.method, '/msg/sendLocation');
- assert.equal(calls[5].body.params.latitude, 31.23);
- assert.equal(calls[6].body.method, '/msg/sendWeapp');
- assert.equal(calls[6].body.params.username, 'gh_demo@app');
- assert.match(calls[7].url, /\/doFileApi$/);
- assert.equal(calls[8].body.method, '/msg/sendVoice');
- assert.equal(calls[8].body.params.voiceTime, 2);
- let failedSendAttempts = 0;
- global.fetch = async () => {
- failedSendAttempts += 1;
- throw new Error('ambiguous network failure');
- };
- await assert.rejects(() => client.sendVoice('external-contact-1', { ...uploaded, voiceTime: 2 }), /网络请求失败/);
- assert.equal(failedSendAttempts, 1);
- } finally {
- global.fetch = originalFetch;
- }
- });
- await check('多企微账号使用独立工作台数据库和 Claude Session', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const accountA = { uid: 'account-a', guid: 'guid-a', nickname: '账号 A' };
- const accountB = { uid: 'account-b', guid: 'guid-b', nickname: '账号 B' };
- const keyA = __testing.accountRuntimeKey(accountA);
- const keyB = __testing.accountRuntimeKey(accountB);
- const configA = __testing.accountWorkbenchOverrides(accountA);
- const configB = __testing.accountWorkbenchOverrides(accountB);
- assert.notEqual(keyA, keyB);
- assert.notEqual(configA.dbPath, configB.dbPath);
- assert.notEqual(configA.agent.claudeSessionFile, configB.agent.claudeSessionFile);
- assert.equal(configA.qiwei.uid, accountA.uid);
- assert.equal(configB.qiwei.guid, accountB.guid);
- });
- await check('自动监听热加载白名单并尊重人工关闭状态', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-listener-default-'));
- const envFile = path.join(dir, '.env.local');
- fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-new\n', 'utf8');
- const settings = new Map();
- const calls = [];
- let starts = 0;
- const target = {
- config: { qiwei: { allowedSenders: ['contact-old'] } },
- db: {
- getSetting(name, fallback) { return settings.has(name) ? settings.get(name) : fallback; },
- setSetting(name, value) { settings.set(name, value); },
- globalState() { return { defaultMode: 'review' }; },
- },
- service: { setGlobal(input, actor) { calls.push({ input, actor }); } },
- poller: {
- async start() {
- starts += 1;
- assert.deepEqual(target.config.qiwei.allowedSenders, ['contact-new']);
- return { running: true, syncKey: starts };
- },
- },
- };
- try {
- const started = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
- assert.equal(started.data.running, true);
- assert.equal(settings.get('listener_enabled'), 'true');
- assert.deepEqual(calls.at(-1).input, { paused: false });
- settings.set('listener_enabled', 'false');
- const disabled = await __testing.startListenerForWorkbench(target, { online: true }, { automatic: true, envFile });
- assert.equal(disabled.data.disabled, true);
- assert.equal(starts, 1);
- const manual = await __testing.startListenerForWorkbench(target, { online: true }, { envFile });
- assert.equal(manual.data.running, true);
- assert.equal(starts, 2);
- assert.equal(settings.get('listener_enabled'), 'true');
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
- await check('当前会话采集仅允许白名单个人聊天', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const conversations = new Map([
- ['private-1', { id: 'private-1', contact_id: 'contact-1' }],
- ['private-2', { id: 'private-2', contact_id: 'contact-2' }],
- ['group-1', { id: 'group-1', contact_id: 'room-1@chatroom' }],
- ]);
- const db = {
- getConversation(id) { return conversations.get(id) || null; },
- listMessages() { return []; },
- };
- const allowlist = new Set(['contact-1']);
- const scope = __testing.resolveConversationSyncScope({ conversationId: 'private-1' }, allowlist, db);
- assert.equal(scope.scope, 'conversation');
- assert.deepEqual([...scope.contacts], ['contact-1']);
- assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'private-2' }, allowlist, db), /白名单/);
- assert.throws(() => __testing.resolveConversationSyncScope({ conversationId: 'group-1' }, allowlist, db), /个人聊天/);
- });
- await check('Dashboard 和工具公开精准采集及测试好友白名单契约', async () => {
- const appSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'dashboard', 'app.js'), 'utf8');
- const serverSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'mcp', 'src', 'server.js'), 'utf8');
- const bridgeSource = fs.readFileSync(path.join(PACKAGE_ROOT, 'runtime', 'callback-service', 'src', 'processor-bridge.mjs'), 'utf8');
- assert.match(appSource, /data-agent-action="sync-current-conversation"/);
- assert.match(appSource, /补采全部白名单/);
- assert.match(serverSource, /addToAllowlist:\s*z\.boolean\(\)/);
- assert.match(bridgeSource, /startListener\(\{ automatic: true \}\)/);
- });
- await check('白名单文件变更会在监听期间热加载', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-allowlist-reload-'));
- try {
- const envFile = path.join(dir, '.env.local');
- fs.writeFileSync(envFile, 'QIWEI_AUTO_REPLY_ALLOWED_SENDERS=contact-1,contact-2\n', 'utf8');
- const config = { selfUserId: 'self', allowedSenders: ['contact-1'] };
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const refreshed = __testing.refreshAllowedSendersFromEnv(config, envFile);
- assert.deepEqual(refreshed, { changed: true, count: 2 });
- assert.deepEqual(config.allowedSenders, ['contact-1', 'contact-2']);
- const candidate = evaluatePolledMessage({
- msgType: 1,
- senderId: 'contact-2',
- receiverId: 'self',
- timestamp: Math.floor(Date.now() / 1000),
- msgData: { content: '新加入白名单后的首条消息' },
- }, config);
- assert.equal(candidate.eligible, true);
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
- await check('监听恢复可保留现有 Agent 模式', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const calls = [];
- const target = {
- service: {
- setGlobal(input) { calls.push(['global', input]); },
- setConversationMode(id, mode) { calls.push(['conversation', id, mode]); },
- },
- db: { listConversations: () => [{ id: 'conversation-a' }] },
- };
- // preserveAgentState=true 时 stopListener 不调用该接管逻辑;普通人工停止仍调用。
- assert.equal(calls.length, 0);
- __testing.applyManualTakeover(target);
- assert.deepEqual(calls, [
- ['global', { paused: true, defaultMode: 'review' }],
- ['conversation', 'conversation-a', 'human'],
- ]);
- });
- await check('发送语音后会结算当前待审核草稿并关联语音消息', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const draft = {
- id: 'draft-voice-1',
- conversation_id: 'conversation-1',
- status: 'pending',
- content: '原草稿',
- };
- const updates = [];
- const db = {
- getDraft(id) { return id === draft.id ? draft : null; },
- listDrafts() { return [draft]; },
- updateDraft(id, fields) {
- updates.push({ id, fields });
- return { ...draft, ...fields };
- },
- };
- const selected = __testing.pendingVoiceDraft(db, 'conversation-1', draft.id);
- const resolved = __testing.markVoiceDraftSent(db, selected, {
- content: '实际发送的语音内容',
- messageId: 'message-voice-1',
- });
- assert.equal(resolved.status, 'sent');
- assert.equal(resolved.content, '实际发送的语音内容');
- assert.equal(resolved.sent_message_id, 'message-voice-1');
- assert.equal(resolved.reviewer, 'human:voice');
- assert.equal(updates.length, 1);
- assert.throws(() => __testing.pendingVoiceDraft(db, 'another-conversation', draft.id), /当前会话不匹配/);
- });
- await check('启动监听保留当前审核策略,不自动切换会话模式', async () => {
- const { __testing } = require('../mcp/src/dashboard/agent-service');
- const calls = [];
- const target = {
- config: { qiwei: { allowedSenders: ['contact-1'] } },
- db: {
- getSetting(_name, fallback) { return fallback; },
- setSetting() {},
- },
- service: { setGlobal() { calls.push('service.setGlobal'); } },
- poller: {
- async start() {
- calls.push('poller.start');
- return { running: true, syncKey: 7 };
- }
- }
- };
- const result = await __testing.startListenerForWorkbench(target, { online: true, nickname: '测试账号' });
- assert.deepEqual(calls, ['service.setGlobal', 'poller.start']);
- assert.equal(result.status, 'ok');
- assert.equal(result.data.running, true);
- });
- await check('Claude Code 可从 Fmode Studio npm-global PATH 中发现', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-path-'));
- const executable = process.platform === 'win32'
- ? path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe')
- : path.join(dir, 'claude');
- fs.mkdirSync(path.dirname(executable), { recursive: true });
- fs.writeFileSync(executable, 'smoke');
- const previousPath = process.env.PATH;
- try {
- process.env.PATH = `${dir}${path.delimiter}${previousPath || ''}`;
- assert.equal(resolveClaudeExecutable({}), executable);
- } finally {
- process.env.PATH = previousPath;
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
- await check('不同消息 ID 的同内容在 60 秒内只入库一次', async () => {
- const ctx = setup();
- try {
- const first = await ctx.service.ingestInbound({ externalId: 'm1', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
- const duplicate = await ctx.service.ingestInbound({ externalId: 'm1-copy', contactId: 'contact-1', contactName: '王刚', content: '我想咨询房源' });
- assert.equal(first.status, 'pending_review');
- assert.equal(duplicate.status, 'duplicate_content');
- assert.equal(ctx.db.listMessages(first.conversation.id).length, 1);
- } finally { ctx.close(); }
- });
- await check('审核模式生成草稿但不自动外发', async () => {
- const ctx = setup();
- try {
- const result = await ctx.service.ingestInbound({ externalId: 'm2', contactId: 'contact-1', contactName: '王刚', content: '预算 150 万,想买三室' });
- assert.equal(result.status, 'pending_review');
- assert.equal(ctx.sent.length, 0);
- assert.equal(ctx.db.getDraft(result.draft.id).status, 'pending');
- assert.equal(result.draft.citations[0].source, 'faq.md');
- assert.equal(result.draft.tool_trace[0].tool, 'search_knowledge');
- } finally { ctx.close(); }
- });
- await check('批准草稿只发送一次,重复批准被拒绝', async () => {
- const ctx = setup();
- try {
- const result = await ctx.service.ingestInbound({ externalId: 'm3', contactId: 'contact-1', contactName: '王刚', content: '请给我一个建议' });
- await ctx.service.approveDraft(result.draft.id, { content: '人工编辑后的回复', actor: 'human' });
- await assert.rejects(() => ctx.service.approveDraft(result.draft.id, { actor: 'human' }), /不能重复发送/);
- assert.deepEqual(ctx.sent, [{ toId: 'contact-1', content: '人工编辑后的回复' }]);
- assert.equal(ctx.db.getDraft(result.draft.id).status, 'sent');
- } finally { ctx.close(); }
- });
- await check('全局暂停与人工接管都抑制 Agent', async () => {
- const ctx = setup({ paused: true });
- try {
- const paused = await ctx.service.ingestInbound({ externalId: 'm4', contactId: 'contact-1', contactName: '王刚', content: '暂停时消息' });
- assert.equal(paused.status, 'paused');
- ctx.service.setGlobal({ paused: false });
- ctx.service.setConversationMode(paused.conversation.id, 'human');
- const human = await ctx.service.ingestInbound({ externalId: 'm5', contactId: 'contact-1', contactName: '王刚', content: '人工接管时消息' });
- assert.equal(human.status, 'human');
- assert.equal(ctx.db.listDrafts().length, 0);
- assert.equal(ctx.sent.length, 0);
- } finally { ctx.close(); }
- });
- await check('Agent 上游失败只留审计,不生成伪回复、不外发', async () => {
- const ctx = setup({ agentRun: async () => { throw new Error('Agent 上游暂时不可用(HTTP 522)'); } });
- try {
- const result = await ctx.service.ingestInbound({ externalId: 'm6', contactId: 'contact-1', contactName: '王刚', content: '请推荐房源' });
- assert.equal(result.status, 'agent_failed');
- assert.equal(ctx.db.listDrafts().length, 0);
- assert.equal(ctx.sent.length, 0);
- assert.equal(ctx.db.latestAgentState(result.conversation.id).action, 'agent_failed');
- assert.match(result.error, /暂时无法完成/);
- const audit = ctx.db.listAudit(20, result.conversation.id).find(item => item.action === 'agent_failed');
- assert.equal(audit.detail.message, result.error);
- assert.equal(audit.detail.rawMessage, 'Agent 上游暂时不可用(HTTP 522)');
- } finally { ctx.close(); }
- });
- await check('确认消息无需调用模型、无需回复且会清除旧错误状态', async () => {
- let agentCalls = 0;
- const ctx = setup({ agentRun: async () => { agentCalls += 1; throw new Error('不应调用模型'); } });
- try {
- const conversation = ctx.db.ensureConversation('contact-1', '王刚');
- ctx.db.audit({ actor: 'agent', action: 'agent_failed', conversationId: conversation.id, detail: { message: '历史上游失败' } });
- const result = await ctx.service.ingestInbound({ externalId: 'm-ack', contactId: 'contact-1', contactName: '王刚', content: '收到' });
- assert.equal(result.status, 'no_reply_needed');
- assert.equal(agentCalls, 0);
- assert.equal(ctx.sent.length, 0);
- assert.equal(ctx.db.latestAgentState(conversation.id), null);
- assert.equal(ctx.db.latestAgentOutcome(conversation.id).action, 'agent_no_reply_needed');
- assert.equal(ctx.db.latestAgentOutcome(conversation.id).entityId, result.message.id);
- assert.equal(isNoReplyNeededMessage('好的。'), true);
- assert.equal(isNoReplyNeededMessage('地址确认好了吗'), false);
- } finally { ctx.close(); }
- });
- await check('Claude Code 预算超限时轮换客户 Session 并只重试一次', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-budget-reset-'));
- try {
- const client = new ClaudeCodeClient({
- claudeWorkdir: dir,
- claudeSessionFile: path.join(dir, 'sessions.json'),
- claudeMaxBudgetUsd: 0.35,
- claudeRetryMaxBudgetUsd: 1,
- });
- const sessionIds = [];
- const invokeOptions = [];
- client.invoke = async (_messages, _context, session, options = {}) => {
- sessionIds.push(session.id);
- invokeOptions.push(options);
- if (sessionIds.length === 1) throw new Error('Claude Code 调用失败(退出码 1):error_max_budget_usd');
- return { content: '{}', claudeCode: { resumed: false } };
- };
- const result = await client.complete([{ role: 'user', content: '请推荐房源' }], [], { conversation: { id: 'conversation-budget', contact_name: '王刚' } });
- assert.equal(sessionIds.length, 2);
- assert.notEqual(sessionIds[0], sessionIds[1]);
- assert.equal(invokeOptions[1].maxBudgetUsd, 1);
- assert.equal(result.claudeCode.sessionResetReason, 'budget_exceeded');
- assert.equal(claudeSessionResetReason(new Error('error_max_budget_usd')), 'budget_exceeded');
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('Claude Code 客服调用使用精简模式与低推理强度', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-claude-bare-'));
- try {
- let capturedArgs = [];
- const client = new ClaudeCodeClient({
- claudeWorkdir: dir,
- claudeSessionFile: path.join(dir, 'sessions.json'),
- claudeBare: true,
- claudeEffort: 'low',
- claudeTools: 'Read,Glob,Grep',
- model: 'deepseek-v4-pro',
- });
- client.runProcess = async args => {
- capturedArgs = args;
- return { structured_output: { reply: '测试草稿' }, duration_ms: 1, total_cost_usd: 0.01 };
- };
- await client.invoke([{ role: 'system', content: '测试' }, { role: 'user', content: '推荐房源' }], {}, { id: '33333333-3333-4333-8333-333333333333', initialized: false });
- assert(capturedArgs.includes('--bare'));
- assert.equal(capturedArgs[capturedArgs.indexOf('--effort') + 1], 'low');
- assert.equal(capturedArgs[capturedArgs.indexOf('--tools') + 1], 'Read,Glob,Grep');
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('Claude Code 已完成结构化输出时不因末尾预算退出码丢弃草稿', async () => {
- const recovered = parseClaudeProcessResult(JSON.stringify({
- is_error: true,
- subtype: 'error_max_budget_usd',
- structured_output: {
- reply: '已经生成的客服草稿',
- confidence: 0.75,
- intent: '继续推荐',
- },
- }), '', 1);
- assert.equal(recovered.error, undefined);
- assert.equal(recovered.payload.is_error, false);
- assert.equal(recovered.payload.structured_output.reply, '已经生成的客服草稿');
- assert.equal(recovered.payload.process_warning.detail, 'error_max_budget_usd');
- const failed = parseClaudeProcessResult(JSON.stringify({ is_error: true, subtype: 'error_max_budget_usd' }), '', 1);
- assert.match(failed.error, /error_max_budget_usd/);
- });
- await check('非白名单联系人被忽略且不能人工发送', async () => {
- const ctx = setup();
- try {
- const ignored = await ctx.service.ingestInbound({ externalId: 'm7', contactId: 'contact-2', contactName: '其他人', content: '你好' });
- assert.equal(ignored.status, 'ignored_not_allowlisted');
- assert.equal(ctx.db.listConversations().length, 0);
- const allowed = ctx.db.ensureConversation('contact-1', '王刚');
- ctx.db.db.prepare('UPDATE conversations SET contact_id=? WHERE id=?').run('contact-2', allowed.id);
- await assert.rejects(() => ctx.service.manualSend(allowed.id, '测试'), /不在测试白名单/);
- assert.equal(ctx.sent.length, 0);
- } finally { ctx.close(); }
- });
- await check('项目主控关联下每个客户绑定独立 Claude Code Session', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-map-'));
- try {
- const filePath = path.join(dir, 'sessions.json');
- const store = new ClaudeCodeSessionStore(filePath, {
- projectId: 'project-smoke',
- projectRoot: dir,
- mainSessionId: '11111111-1111-4111-8111-111111111111',
- });
- const first = store.ensure('conversation-a', { customerName: '王刚', displayName: '企微客户-王刚-a001' });
- const second = store.ensure('conversation-b', { customerName: '李女士', displayName: '企微客户-李女士-b002' });
- assert.notEqual(first.id, second.id);
- assert.equal(first.parentControllerSessionId, second.parentControllerSessionId);
- assert.equal(first.projectId, 'project-smoke');
- assert.equal(first.customerName, '王刚');
- assert.equal(first.displayName, '企微客户-王刚-a001');
- const persisted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
- assert.equal(persisted.project.boundMainSessionId, '11111111-1111-4111-8111-111111111111');
- assert.equal(Object.keys(persisted.sessions).length, 2);
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('Claude Code 只采用本轮权威上下文并使用客户可识别会话名', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-prompt-boundary-'));
- try {
- const messages = [
- { role: 'user', content: '我是两个两个买' },
- { role: 'user', content: '加进去这个api服务就不用管了' },
- { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
- { role: 'user', content: '200万吧' },
- ];
- const authoritative = selectAuthoritativeHistory(messages);
- assert.deepEqual(authoritative.map(item => item.content), [
- '新北区有4套三室房,请问预算是多少?',
- '200万吧',
- ]);
- const client = new ClaudeCodeClient({
- claudeSessionFile: path.join(dir, 'sessions.json'),
- claudeWorkdir: dir,
- });
- const prompt = client.buildPrompt(messages, { profile: { profile: {} } });
- assert.match(prompt, /本轮有效会话/);
- assert.match(prompt, /200万吧/);
- assert.doesNotMatch(prompt, /两个两个买/);
- assert.doesNotMatch(prompt, /api服务/);
- const sessionName = buildClaudeSessionName({ conversation: { contact_name: '王刚' } }, 'conversation-a');
- assert.match(sessionName, /^企微客户-王刚-[a-f0-9]{4}$/);
- assert.doesNotMatch(sessionName, /conversation-a/);
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('Session 残留原话被证据闸门拦截并降级为人工确认', async () => {
- const history = [
- { role: 'assistant', content: '新北区有4套三室房,请问预算是多少?' },
- { role: 'user', content: '200万吧' },
- ];
- const guarded = enforceAuthoritativeGrounding({
- reply: '您之前提到“两个两个买”,是想一次买两套吗?',
- confidence: 0.9,
- intent: '预算确认',
- reason: '客户之前说“两个两个买”。',
- requiresHuman: false,
- }, history, { preferredRegion: '新北区', layout: '三室', budgetWan: 200, budgetType: '待确认' }, '200万吧');
- assert.equal(guarded.requiresHuman, true);
- assert(guarded.confidence <= 0.68);
- assert.doesNotMatch(guarded.reply, /两个两个买|两套/);
- assert.match(guarded.reply, /新北区/);
- const intelligence = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {
- profileUpdates: { purchaseQuantity: 2, budgetWan: 200 },
- tasks: [{ type: 'purchase', title: '准备两套方案', evidence: '两个两个买' }],
- alerts: [{ type: 'high_intent', severity: 'high', title: '两套购买', evidence: '两个两个买' }],
- });
- assert.equal(intelligence.profileUpdates.purchaseQuantity, undefined);
- assert.equal(intelligence.profileUpdates.budgetWan, 200);
- assert.doesNotMatch(JSON.stringify(intelligence), /两个两个买|两套购买|准备两套方案/);
- });
- await check('客户 Session 指引主动返回可识别名称和安全打开命令', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-session-guide-'));
- try {
- const sessionFile = path.join(dir, 'sessions.json');
- fs.writeFileSync(sessionFile, JSON.stringify({
- version: 1,
- project: {},
- sessions: {
- 'conversation-a': {
- id: '22222222-2222-4222-8222-222222222222',
- role: 'customer-agent',
- initialized: true,
- displayName: '企微客户-王刚-a001',
- },
- },
- }), 'utf8');
- const guide = getCustomerSessionGuide({ id: 'conversation-a', contact_name: '王刚' }, { sessionFile });
- assert.equal(guide.ready, true);
- assert.equal(guide.displayName, '企微客户-王刚-a001');
- assert.match(guide.openCommand, /agent:session/);
- assert.match(guide.openCommand, /王刚/);
- assert.doesNotMatch(JSON.stringify(guide), /22222222/);
- assert.equal(guide.productionSessionProtected, true);
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('监听消息持续沉淀客户画像、内部待办和预警', async () => {
- const housing = extractExplicitCustomerIntelligence('我想咨询一下新北区的三室房', {}, {});
- assert.equal(housing.profileUpdates.preferredRegion, '新北区');
- assert.equal(housing.profileUpdates.layout, '三室');
- const explicit = extractExplicitCustomerIntelligence('200万吧', { preferredRegion: '新北区', layout: '三室' }, {});
- assert.equal(explicit.profileUpdates.budgetWan, 200);
- assert(explicit.tasks.some(item => item.type === 'recommendation'));
- assert(explicit.alerts.some(item => item.type === 'high_intent'));
- const purpose = extractExplicitCustomerIntelligence('自己住吧', { budgetWan: 200 }, {});
- assert.equal(purpose.profileUpdates.purpose, '自住');
- const ctx = setup({ agentRun: async () => ({
- content: '好的,我再确认一下您的用途和时间计划。',
- confidence: 0.82,
- intent: '预算确认',
- reason: '客户给出明确预算,需要补齐用途和时间。',
- requiresHuman: false,
- profileUpdates: { budgetWan: 200, budgetType: '待确认' },
- tasks: [{ type: 'qualification', title: '确认用途与时间计划', owner: '待分配', dueAt: '', priority: 'high', reason: '关键信息待补齐', evidence: '200万吧' }],
- alerts: [{ type: 'high_intent', severity: 'high', title: '预算已明确', detail: '可以进入需求收敛阶段', evidence: '200万吧', recommendedAction: '确认用途与时间' }],
- citations: [],
- toolTrace: [],
- }) });
- try {
- const result = await ctx.service.ingestInbound({ externalId: 'm-intel', contactId: 'contact-1', contactName: '王刚', content: '200万吧' });
- assert.equal(result.status, 'pending_review');
- const detail = ctx.service.conversationDetail(result.conversation.id);
- assert.equal(detail.profile.profile.budgetWan, 200);
- assert.equal(detail.tasks.length, 1);
- assert.equal(detail.alerts.length, 1);
- assert.equal(ctx.sent.length, 0);
- } finally { ctx.close(); }
- });
- await check('监听重启后仍接收停机期间的白名单积压消息', async () => {
- const candidate = evaluatePolledMessage({
- msgType: 1,
- senderId: 'contact-1',
- timestamp: Math.floor(Date.now() / 1000) - 600,
- msgData: { content: '自己住吧' },
- }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
- assert.equal(candidate.eligible, true);
- assert.equal(candidate.content, '自己住吧');
- });
- await check('群聊与本账号消息不会串入白名单客户私聊', async () => {
- const group = evaluatePolledMessage({
- msgType: 2,
- senderId: 'contact-1',
- receiverId: 'self',
- fromRoomId: 'room-123',
- timestamp: Math.floor(Date.now() / 1000),
- msgData: { content: '@同事 请发会议录屏' },
- }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
- assert.equal(group.eligible, false);
- assert.equal(group.reason, 'group_message');
- assert.equal(group.roomId, 'room-123');
- assert.equal(roomIdOf({ fromRoomId: 0, roomId: '' }), '');
- const self = evaluatePolledMessage({
- msgType: 1,
- senderId: 'self',
- receiverId: 'contact-1',
- timestamp: Math.floor(Date.now() / 1000),
- msgData: { content: '我发出的私聊' },
- }, { selfUserId: 'self', allowedSenders: ['contact-1'] });
- assert.equal(self.eligible, false);
- assert.equal(self.reason, 'self_message');
- const ctx = setup();
- try {
- const ignored = await ctx.service.ingestInbound({
- externalId: 'group-direct-entry',
- contactId: 'contact-1',
- contactName: '王刚',
- content: '群里的消息',
- raw: { fromRoomId: 'room-123', senderId: 'contact-1' },
- });
- assert.equal(ignored.status, 'ignored_group_message');
- assert.equal(ctx.db.listConversations().length, 0);
- const legacyGroup = ctx.db.ensureConversation('contact-1', '历史群聊');
- ctx.db.insertMessage({
- conversationId: legacyGroup.id,
- externalId: 'legacy-group-message',
- direction: 'inbound',
- senderType: 'customer',
- content: '旧数据中的群消息',
- raw: { fromRoomId: 'room-legacy', senderId: 'contact-1' },
- });
- await assert.rejects(() => ctx.service.manualSend(legacyGroup.id, '不应发送'), /群聊仅用于监控/);
- assert.equal(ctx.sent.length, 0);
- } finally { ctx.close(); }
- });
- await check('客户群支持人工审核与无视风险全自动两种独立模式', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-group-agent-smoke-'));
- const messages = [{
- msgId: 'group-message-1',
- senderId: 'customer-1',
- senderName: '张三',
- fromRoomId: 'r-1',
- content: '明天下午可以看房吗',
- timestamp: '2026-07-23T08:00:00.000Z',
- }];
- const sent = [];
- const sendAttempts = [];
- const agentInputs = [];
- let sendShouldFail = false;
- let agentResponse = {
- content: '可以的,请问您明天下午几点方便?',
- confidence: 0.88,
- intent: '预约看房',
- reason: '客户明确询问看房时间',
- requiresHuman: false,
- citations: [],
- toolTrace: [],
- };
- const runtime = {
- config: { qiwei: { selfUserId: 'self-1', nickname: '王顾问' } },
- agent: {
- async run(input) {
- agentInputs.push(input);
- return agentResponse;
- },
- },
- qiwei: {
- async sendText(toId, content) {
- sendAttempts.push({ toId, content });
- if (sendShouldFail) return { isSendSuccess: false };
- sent.push({ toId, content });
- return { isSendSuccess: true };
- },
- },
- };
- const service = new GroupAgentService({
- projectRoot: dir,
- statePath: path.join(dir, 'group-agent.json'),
- runtime,
- getAccount: () => ({ uid: 'account-1', userId: 'self-1', nickname: '王顾问' }),
- loadGroups: () => ({ 'r-1': { roomName: '张三买房服务群', customerName: '张三' } }),
- loadMessages: () => messages,
- appendMessage: (roomId, message) => { messages.push({ ...message, fromRoomId: roomId }); return 'memory'; },
- });
- try {
- assert.equal(service.publicState('r-1').mode, 'review');
- const generated = await service.generate('r-1');
- assert.equal(generated.status, 'pending_review');
- assert.equal(generated.draft.requiresHuman, true);
- assert.equal(sent.length, 0);
- assert.equal(agentInputs[0].channelType, 'group');
- assert.match(agentInputs[0].directPrompt, /企业微信群聊客服 Agent/);
- const approved = await service.approve('r-1', generated.draft.id, '可以的,张三,请问您明天下午几点方便?');
- assert.equal(approved.status, 'sent');
- assert.deepEqual(sent, [{ toId: 'r-1', content: '可以的,张三,请问您明天下午几点方便?' }]);
- assert.equal(service.publicState('r-1').pendingReply, null);
- assert.equal(service.publicState('r-1').messages.at(-1).role, 'human');
- await assert.rejects(() => service.approve('r-1', generated.draft.id, '重复发送'), /已经是 sent/);
- await assert.rejects(() => service.generate('unconfirmed-room'), /尚未确认为客户群/);
- assert.throws(() => service.setMode('r-1', 'auto'), /需要明确确认/);
- assert.equal(service.setMode('r-1', 'auto', 'AUTO_SEND_GROUP_MESSAGES').mode, 'auto');
- agentResponse = {
- content: '三点可以,我先为您登记。',
- confidence: 0.1,
- intent: '预约看房',
- reason: '低置信回复仍由全自动模式放行',
- requiresHuman: true,
- citations: [],
- toolTrace: [],
- };
- const ingested = await service.ingestPolledMessage({
- msgType: 1,
- msgServerId: 'group-message-2',
- seq: 2,
- senderId: 'customer-1',
- senderName: '张三',
- fromRoomId: 'r-1',
- msgData: { content: '三点可以吗' },
- timestamp: Math.floor(Date.now() / 1000),
- }, { selfUserId: 'self-1' });
- assert.equal(ingested.status, 'auto_sent');
- assert.equal(agentInputs.length, 2);
- assert.equal(sent.length, 2);
- assert.deepEqual(sent.at(-1), { toId: 'r-1', content: '三点可以,我先为您登记。' });
- assert.equal(service.publicState('r-1').pendingReply, null);
- assert.equal(service.publicState('r-1').lastOutcome.action, 'group_message_auto_sent');
- assert.equal(messages.at(-1).rawData.source, 'group_agent_auto');
- sendShouldFail = true;
- const failed = await service.ingestPolledMessage({
- msgType: 1,
- msgServerId: 'group-message-3',
- seq: 3,
- senderId: 'customer-1',
- senderName: '张三',
- fromRoomId: 'r-1',
- msgData: { content: '能发个定位吗' },
- timestamp: Math.floor(Date.now() / 1000) + 1,
- }, { selfUserId: 'self-1' });
- assert.equal(failed.status, 'pending_review');
- assert.equal(failed.autoSendFailed, true);
- assert.equal(sent.length, 2);
- assert.equal(sendAttempts.length, 3);
- assert(service.publicState('r-1').pendingReply);
- assert(service.publicState('r-1').sendError);
- sendShouldFail = false;
- assert.equal(service.setMode('r-1', 'review').mode, 'review');
- const reviewed = await service.ingestPolledMessage({
- msgType: 1,
- msgServerId: 'group-message-4',
- seq: 4,
- senderId: 'customer-1',
- senderName: '张三',
- fromRoomId: 'r-1',
- msgData: { content: '四点也可以' },
- timestamp: Math.floor(Date.now() / 1000) + 2,
- }, { selfUserId: 'self-1' });
- assert.equal(reviewed.status, 'pending_review');
- assert.equal(sent.length, 2);
- assert.equal(service.publicState('r-1').mode, 'review');
- } finally {
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
- await check('同一业务待办只保留一张卡并聚合多条依据', async () => {
- const ctx = setup();
- try {
- const conversation = ctx.db.ensureConversation('contact-1', '王刚');
- ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }], 'message-a');
- ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '区域新北区' }], 'message-b');
- const tasks = ctx.db.listCustomerTasks(conversation.id);
- assert.equal(tasks.length, 1);
- assert.deepEqual(JSON.parse(tasks[0].evidence_json).map(item => item.text), ['预算200万', '区域新北区']);
- } finally { ctx.close(); }
- });
- await check('用途和购置时间补齐后资格确认待办自动完成', async () => {
- let turn = 0;
- const ctx = setup({ agentRun: async () => {
- turn += 1;
- return {
- content: '信息已记录。', confidence: 0.8, intent: '需求确认', reason: '测试', requiresHuman: false,
- profileUpdates: turn === 1 ? { budgetWan: 200 } : { purpose: '自住', timeline: '三个月内' },
- tasks: turn === 1 ? [{ businessKey: 'qualification:purpose_and_timeline', managedBy: 'rule', type: 'qualification', title: '确认客户用途与购置时间', evidence: '预算200万' }] : [],
- alerts: [], citations: [], toolTrace: [],
- };
- } });
- try {
- const first = await ctx.service.ingestInbound({ externalId: 'profile-a', contactId: 'contact-1', contactName: '王刚', content: '预算200万' });
- assert.equal(ctx.db.listCustomerTasks(first.conversation.id)[0].status, 'open');
- await ctx.service.ingestInbound({ externalId: 'profile-b', contactId: 'contact-1', contactName: '王刚', content: '自住,计划三个月内购买' });
- const qualification = ctx.db.listCustomerTasks(first.conversation.id).find(item => item.business_key === 'qualification:purpose_and_timeline');
- assert.equal(qualification.status, 'done');
- assert.equal(qualification.resolution_reason, 'profile_condition_resolved');
- } finally { ctx.close(); }
- });
- await check('发送房源方案后重点方案待办自动完成', async () => {
- const ctx = setup();
- try {
- const conversation = ctx.db.ensureConversation('contact-1', '王刚');
- ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', managedBy: 'rule', type: 'recommendation', title: '按已确认条件筛选并发送重点方案', evidence: '预算、区域、户型已明确' }], 'message-c');
- await ctx.service.manualSend(conversation.id, '已经为您筛选了三套重点房源方案,请查收。');
- const task = ctx.db.listCustomerTasks(conversation.id).find(item => item.business_key === 'recommendation:shortlist');
- assert.equal(task.status, 'done');
- assert.equal(task.resolution_reason, 'manual_recommendation_sent');
- } finally { ctx.close(); }
- });
- await check('旧数据库导入时合并重复业务项且不丢依据', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-import-'));
- const sourcePath = path.join(dir, 'legacy.db');
- const targetPath = path.join(dir, 'target.db');
- let source = new AgentWorkbenchDb(sourcePath, { defaultMode: 'review' });
- const conversation = source.ensureConversation('legacy-contact', '历史客户');
- source.close();
- const raw = new DatabaseSync(sourcePath);
- raw.exec('DROP INDEX IF EXISTS idx_customer_tasks_business_key');
- const timestamp = new Date().toISOString();
- const insert = raw.prepare(`INSERT INTO customer_tasks(id,conversation_id,fingerprint,business_key,managed_by,type,title,status,evidence,evidence_json,created_at,updated_at)
- VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`);
- insert.run('legacy-task-a', conversation.id, 'legacy-fp-a', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '预算200万', '[]', timestamp, timestamp);
- insert.run('legacy-task-b', conversation.id, 'legacy-fp-b', '', 'agent', 'qualification', '确认客户用途与购置时间', 'open', '区域新北区', '[]', timestamp, timestamp);
- raw.close();
- const target = new AgentWorkbenchDb(targetPath, { defaultMode: 'review' });
- try {
- const result = target.importCompatibleDatabase(sourcePath);
- assert.equal(result.imported, true);
- const tasks = target.listCustomerTasks(conversation.id);
- assert.equal(tasks.length, 1);
- assert.equal(tasks[0].business_key, 'qualification:purpose_and_timeline');
- assert.equal(JSON.parse(tasks[0].evidence_json).length, 2);
- } finally {
- target.close();
- fs.rmSync(dir, { recursive: true, force: true });
- }
- });
- await check('Claude Code 提示词读取统一待办和预警主账', async () => {
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-agent-prompt-'));
- try {
- const client = new ClaudeCodeClient({ claudeWorkdir: dir, claudeSessionFile: path.join(dir, 'sessions.json') });
- const prompt = client.buildPrompt([{ role: 'user', content: '继续推荐' }], { customerIntelligence: {
- tasks: [{ businessKey: 'recommendation:shortlist', title: '发送重点方案', status: 'open' }],
- alerts: [{ businessKey: 'high_intent:core_demand_ready', title: '高意向', status: 'open' }],
- } });
- assert.match(prompt, /当前未完成待办/);
- assert.match(prompt, /recommendation:shortlist/);
- assert.match(prompt, /当前未解决预警/);
- assert.match(prompt, /high_intent:core_demand_ready/);
- } finally { fs.rmSync(dir, { recursive: true, force: true }); }
- });
- await check('企微官方待办同步使用可注入 stub 并保持幂等', async () => {
- const ctx = setup();
- try {
- const conversation = ctx.db.ensureConversation('contact-1', '王刚');
- const [task] = ctx.db.upsertCustomerTasks(conversation.id, [{ businessKey: 'recommendation:shortlist', type: 'recommendation', title: '发送重点方案' }]);
- let createCalls = 0;
- const sync = createCustomerTaskOfficialSync({
- db: ctx.db,
- searchTodoUsers: async ({ keyword }) => ({ status: 'ok', data: { users: [{ id: 'internal-user-1', name: keyword, alias: '' }] } }),
- createTodoKnowledge: async input => {
- createCalls += 1;
- assert.deepEqual(input.followerIds, ['internal-user-1']);
- return { status: 'ok', summary: { todoId: 'official-todo-stub' }, data: { todo: { id: 'official-todo-stub' } } };
- },
- });
- await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
- await sync(task.id, { owner: '内部同事', dueAt: '2026-07-20 18:00' });
- const updated = ctx.db.getCustomerTask(task.id);
- assert.equal(createCalls, 1);
- assert.equal(updated.official_todo_id, 'official-todo-stub');
- assert.equal(updated.official_sync_status, 'synced');
- assert.equal(updated.status, 'in_progress');
- } finally { ctx.close(); }
- });
- process.stdout.write(`${JSON.stringify({ status: 'ok', passed: results.length, results }, null, 2)}\n`);
- }
- main().catch(error => {
- process.stderr.write(`${error.stack || error.message}\n`);
- process.exitCode = 1;
- });
|