398 lines
18 KiB
JavaScript
398 lines
18 KiB
JavaScript
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const { createAgent } = require('../src/agent');
|
||
const { createTools } = require('../src/tools');
|
||
|
||
function fakeTools() {
|
||
const tools = [{
|
||
name: 'search_products',
|
||
description: 'fake',
|
||
parameters: { type: 'object', properties: {} },
|
||
handler: async () => ({ products: [] }),
|
||
}];
|
||
tools.leadStore = { get: async () => null, save: async () => null };
|
||
return tools;
|
||
}
|
||
|
||
test('agent: appends lead follow-up when LLM omits company/contact ask', async () => {
|
||
let sent = '';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => ({ content: 'Here are some rings you may like.', toolCalls: [], usage: null }),
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: 'I want 500 pieces of rings', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
},
|
||
tools: fakeTools(),
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
|
||
const conv = { uuid: 'conv-lead-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-1', text_content: 'I want 500 pieces of rings', content: 'I want 500 pieces of rings' });
|
||
assert.match(sent, /company name/i);
|
||
assert.match(sent, /contact email/i);
|
||
});
|
||
|
||
test('agent: output guard blocks fabricated order claim before send', async () => {
|
||
let sent = '';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => ({ content: '已为您创建正式订单草稿,订单号:YH2024-REORDER-1,并同步发送至邮箱。', toolCalls: [], usage: null }),
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: '请下单', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
},
|
||
tools: fakeTools(),
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
const conv = { uuid: 'conv-guard-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-guard-1', text_content: '请下单', content: '请下单' });
|
||
assert.match(sent, /无法在聊天中直接下单/);
|
||
assert.doesNotMatch(sent, /已为您创建正式订单草稿/);
|
||
});
|
||
|
||
test('agent: bare Chinese product word forces search_products before replying', async () => {
|
||
const queries = [];
|
||
const tools = [{
|
||
name: 'search_products',
|
||
description: 'fake',
|
||
parameters: { type: 'object', properties: {} },
|
||
handler: async (args) => {
|
||
queries.push(args.query);
|
||
return { products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }], count: 1 };
|
||
},
|
||
}];
|
||
tools.leadStore = { get: async () => null, save: async () => null };
|
||
let sent = '';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
const conv = { uuid: 'conv-ring-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-ring-1', text_content: '戒指', content: '戒指' });
|
||
assert.deepEqual(queries, ['戒指']);
|
||
assert.match(sent, /Triple Rings Ring/);
|
||
});
|
||
|
||
test('agent: backfills real product links when the LLM omits them', async () => {
|
||
const tools = [{
|
||
name: 'search_products',
|
||
description: 'fake',
|
||
parameters: { type: 'object', properties: {} },
|
||
handler: async () => ({
|
||
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
|
||
count: 1,
|
||
}),
|
||
}];
|
||
tools.leadStore = { get: async () => null, save: async () => null };
|
||
let sent = '';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => ({ content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null }),
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
const conv = { uuid: 'conv-backfill-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-backfill-1', text_content: '戒指', content: '戒指' });
|
||
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
|
||
assert.match(sent, /可直接查看/);
|
||
});
|
||
|
||
test('agent: forces search_products when the LLM answers without tools', async () => {
|
||
const queries = [];
|
||
let calls = 0;
|
||
const tools = [{
|
||
name: 'search_products',
|
||
description: 'fake',
|
||
parameters: { type: 'object', properties: {} },
|
||
handler: async (args) => {
|
||
calls += 1;
|
||
queries.push(args.query);
|
||
if (calls === 1) throw new Error('rpc_timeout'); // 预检索失败 → 走事后强制注入
|
||
return {
|
||
products: [{ name: 'Triple Rings Ring', price: '$2.88', stock: 26, url: 'https://www.yehwang.com/triple-rings-ring-gold-color-17590.html' }],
|
||
count: 1,
|
||
};
|
||
},
|
||
}];
|
||
tools.leadStore = { get: async () => null, save: async () => null };
|
||
let llmCalls = 0;
|
||
let sent = '';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => {
|
||
llmCalls += 1;
|
||
if (llmCalls === 1) {
|
||
return { content: 'Stainless Steel Ring Set — $9.99 → 查看详情:https://www.yehwang.com/fake-ring', toolCalls: [], usage: null };
|
||
}
|
||
return { content: '为您找到 Triple Rings Ring,$2.88,库存 26 件。', toolCalls: [], usage: null };
|
||
},
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: '戒指', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
const conv = { uuid: 'conv-force-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-force-1', text_content: '戒指', content: '戒指' });
|
||
assert.equal(calls, 2, 'pre-tool fails once, forced injection retries');
|
||
assert.deepEqual(queries, ['戒指', '戒指']);
|
||
assert.doesNotMatch(sent, /Stainless Steel Ring Set/);
|
||
assert.doesNotMatch(sent, /fake-ring/);
|
||
assert.match(sent, /https:\/\/www\.yehwang\.com\/triple-rings-ring-gold-color-17590\.html/);
|
||
});
|
||
|
||
// ---- 商机兜底:LLM 只调 collect_info 不调 create_opportunity 时,代码自动建商机 ----
|
||
|
||
function leadTools(twenty) {
|
||
const rpc = { async call() { return []; } };
|
||
return createTools(
|
||
{
|
||
recommendUrl: 'jsonrpc://127.0.0.1:1', orderApiUrl: '', inventoryApiUrl: '', rpcTimeoutMs: 50,
|
||
siteSearchUrl: 'https://www.yehwang.com/search.html?q=',
|
||
twenty: { baseURL: 'http://localhost:1', graphqlPath: '/graphql', workspaceId: 'w', apiKey: 'k' },
|
||
},
|
||
{ rpc, twenty, client: { sendPrivateNote: async () => {}, assignUser: async () => {} } }
|
||
);
|
||
}
|
||
|
||
function leadTwenty() {
|
||
const calls = [];
|
||
return {
|
||
calls,
|
||
async findPersonByEmail(email) { calls.push(['findPersonByEmail', email]); return null; },
|
||
async findOpportunitiesByPersonId() { return []; },
|
||
async createPerson(p) { calls.push(['createPerson', p]); return { id: 'person-1' }; },
|
||
async findCompanyByName() { return null; },
|
||
async createCompany(n) { calls.push(['createCompany', n]); return { id: 'company-1' }; },
|
||
async createOpportunity(o) { calls.push(['createOpportunity', o]); return { id: 'opp-auto-1', name: o.name }; },
|
||
async updateOpportunity(id, patch) { calls.push(['updateOpportunity', id, patch]); return { id }; },
|
||
async updateOpportunityStage(id, stage) { calls.push(['updateOpportunityStage', id, stage]); return { id, stage }; },
|
||
async createNote(c) { calls.push(['createNote', c]); return 'note-1'; },
|
||
async linkNoteToOpportunity(n, o) { calls.push(['linkNoteToOpportunity', n, o]); return { id: 'nt' }; },
|
||
async createTask(t, b, d, o) { calls.push(['createTask', t, o]); return 'task-1'; },
|
||
};
|
||
}
|
||
|
||
function leadAgentDeps(twenty, tools, contact) {
|
||
let llmCalls = 0;
|
||
let sent = '';
|
||
return {
|
||
get sent() { return sent; },
|
||
deps: {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => {
|
||
llmCalls += 1;
|
||
if (llmCalls === 1) {
|
||
return {
|
||
content: '',
|
||
toolCalls: [{
|
||
id: 'tc-collect-1',
|
||
function: {
|
||
name: 'collect_info',
|
||
arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces for our shops', quantity: '500' }),
|
||
},
|
||
}],
|
||
usage: null,
|
||
};
|
||
}
|
||
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
|
||
},
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => (contact.type === 'contact' ? contact.email : null), sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
},
|
||
conv: { uuid: 'conv-auto-opp-1', inbox_id: '1', channel: 'livechat', contact },
|
||
};
|
||
}
|
||
|
||
test('agent: auto-creates opportunity for a verified single-message lead', async () => {
|
||
const twenty = leadTwenty();
|
||
const tools = leadTools(twenty);
|
||
const ctx = leadAgentDeps(twenty, tools, { type: 'contact', email: 'buyer@test-customer.nl' });
|
||
ctx.conv.uuid = 'conv-auto-opp-verified';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
|
||
await agent.run(ctx.conv, { uuid: 'msg-auto-1', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
|
||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||
assert.match(twenty.calls.find((c) => c[0] === 'createOpportunity')[1].name, /Acme GmbH/);
|
||
assert.match(ctx.sent, /noted/i);
|
||
});
|
||
|
||
test('agent: auto-creates opportunity for an unverified single-message lead (policy A)', async () => {
|
||
const twenty = leadTwenty();
|
||
const tools = leadTools(twenty);
|
||
const ctx = leadAgentDeps(twenty, tools, { type: 'visitor' });
|
||
ctx.conv.uuid = 'conv-auto-opp-anon';
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, ctx.deps);
|
||
await agent.run(ctx.conv, { uuid: 'msg-auto-2', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces for our shops.', content: '...' });
|
||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Acme GmbH'));
|
||
});
|
||
|
||
test('agent: does not auto-create when the LLM already created the opportunity', async () => {
|
||
const twenty = leadTwenty();
|
||
const tools = leadTools(twenty);
|
||
let sent = '';
|
||
let llmCalls = 0;
|
||
const deps = {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => {
|
||
llmCalls += 1;
|
||
if (llmCalls === 1) {
|
||
return {
|
||
content: '',
|
||
toolCalls: [{ id: 'tc-1', function: { name: 'collect_info', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
|
||
usage: null,
|
||
};
|
||
}
|
||
if (llmCalls === 2) {
|
||
return {
|
||
content: '',
|
||
toolCalls: [{ id: 'tc-2', function: { name: 'create_opportunity', arguments: JSON.stringify({ company: 'Acme GmbH', contactName: 'John Doe', productNeed: '500 pieces' }) } }],
|
||
usage: null,
|
||
};
|
||
}
|
||
return { content: 'Your request has been noted; a colleague will follow up.', toolCalls: [], usage: null };
|
||
},
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => 'buyer@test-customer.nl', sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
};
|
||
const conv = { uuid: 'conv-opp-once', inbox_id: '1', channel: 'livechat', contact: { type: 'contact', email: 'buyer@test-customer.nl' } };
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
|
||
await agent.run(conv, { uuid: 'msg-once', text_content: 'We are Acme GmbH, contact John Doe, we want 500 pieces.', content: '...' });
|
||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||
});
|
||
|
||
test('agent: extracts lead fields when the LLM skips collect_info (company mentioned, unit-less quantity)', async () => {
|
||
const twenty = leadTwenty();
|
||
const tools = leadTools(twenty);
|
||
let sent = '';
|
||
let llmCalls = 0;
|
||
const deps = {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => {
|
||
llmCalls += 1;
|
||
if (llmCalls === 1) {
|
||
return { content: 'Could you share your contact details so we can follow up?', toolCalls: [], usage: null };
|
||
}
|
||
// extractLeadFields 的调用:返回 JSON
|
||
return { content: JSON.stringify({ company: 'Leadfix GmbH', contactName: 'Anna Klein', productNeed: '500 stainless steel rings', quantity: '500' }), toolCalls: [], usage: null };
|
||
},
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async () => {}, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
};
|
||
const conv = { uuid: 'conv-lead-extract', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, deps);
|
||
await agent.run(conv, { uuid: 'msg-lead-extract', text_content: 'We are Leadfix GmbH, contact Anna Klein. We need 500 stainless steel rings.', content: '...' });
|
||
assert.equal(llmCalls >= 2, true);
|
||
assert.equal(twenty.calls.filter((c) => c[0] === 'createOpportunity').length, 1);
|
||
assert.ok(twenty.calls.some((c) => c[0] === 'createCompany' && c[1] === 'Leadfix GmbH'));
|
||
});
|
||
|
||
test('agent: product pre-retrieval sends cleaned keyword instead of the full sentence', async () => {
|
||
const queries = [];
|
||
const tools = [{
|
||
name: 'search_products',
|
||
description: 'fake',
|
||
parameters: { type: 'object', properties: {} },
|
||
handler: async (args) => {
|
||
queries.push(args.query);
|
||
return { products: [{ name: 'Steel Ring', price: '€2,50', stock: 10, url: 'https://www.yehwang.com/p/steel-ring.html' }], count: 1 };
|
||
},
|
||
}];
|
||
tools.leadStore = { get: async () => null, save: async () => null };
|
||
let sent = '';
|
||
let recorded = null;
|
||
const agent = createAgent({ sites: [], defaultSite: 'www' }, {
|
||
llm: {
|
||
enabled: true,
|
||
model: 'fake',
|
||
chat: async () => ({ content: 'Steel Ring — €2,50,库存 10 件。', toolCalls: [], usage: null }),
|
||
},
|
||
client: {
|
||
getMessages: async () => [{ id: 1, text_content: 'We need 500 stainless steel rings wholesale.', sender_type: 'contact', private: false }],
|
||
sendMessage: async (_uuid, content) => { sent = content; },
|
||
sendPrivateNote: async () => {},
|
||
},
|
||
tools,
|
||
verification: { getVerifiedEmail: async () => null, sendCode: async () => ({}), verifyCode: async () => ({}) },
|
||
metrics: { record: async (m) => { recorded = m; }, enabled: false },
|
||
contextManager: { compressHistory: async (_c, h) => h },
|
||
});
|
||
const conv = { uuid: 'conv-clean-1', inbox_id: '1', channel: 'livechat', contact: { type: 'visitor' } };
|
||
await agent.run(conv, { uuid: 'msg-clean-1', text_content: 'We need 500 stainless steel rings wholesale.', content: '...' });
|
||
assert.deepEqual(queries, ['stainless steel rings']);
|
||
assert.match(sent, /Steel Ring/);
|
||
assert.equal(typeof recorded.audit.timing.llm_ms, 'number');
|
||
assert.equal(typeof recorded.audit.timing.tool_ms, 'number');
|
||
});
|