/** * 工具层的测试。 * * 用假客户端,不发真请求 —— 这里要验的是**参数处理与渲染**, * 那才是各平台容易走样的地方(真请求由端到端演练覆盖)。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFile, writeFile, mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { buildTools, indexTools } from '../lib/tools.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); /** 造一个假客户端:记录调用,按需返回。 */ function fakeClient({ config = [], responses = {} } = {}) { const calls = []; return { calls, baseURL: 'http://fake', agentName: 'zcode', checkConfig: () => config, async get(path) { calls.push({ method: 'GET', path }); for (const key of Object.keys(responses)) { if (path.startsWith(key)) return responses[key]; } return {}; }, async post(path, body) { calls.push({ method: 'POST', path, body }); return { mail_id: 'sent-1', session_id: 'sess-1' }; }, async uploadFile() { return { attachment_id: 'att-1', filename: 'a.txt', size_bytes: 12 }; }, async downloadFile() { return Buffer.from('hello'); } }; } const toolsOf = client => indexTools(buildTools({ client, agentName: 'zcode' })); test('工具数量与名称稳定(改名会破坏跨平台一致性)', () => { const t = toolsOf(fakeClient()); assert.deepEqual([...t.keys()].sort(), [ 'connect_to_server', 'download_attachment', 'forward_mail', 'list_contacts', 'read_inbox', 'read_mail', 'read_thread', 'send_mail', 'session_participants', 'suggest_address', 'upload_attachment' ]); }); test('★ 与 pi 桥的工具名逐一对齐(少一个就会让某平台「不会回信」)', async () => { // 跨平台一致性是被真实问题逼出来的约定:模型在某个平台上找不到 // 熟悉的工具名,行为就与其它平台不同。这条断言让「改名」在 CI 里红, // 而不是等到某个平台的演练才发现。 const piSrc = await readFile( join(HERE, '../../pi-mail-bridge/src/tools.mjs'), 'utf8' ).catch(() => null); if (piSrc === null) { // pi 桥不在旁边(例如插件被单独拷走)时无法对照 —— // 明确说明跳过,而不是假装通过。 assert.ok(true, '跳过:找不到 pi 桥源码用于对照'); return; } const piNames = new Set([...piSrc.matchAll(/name:\s*'([a-z_]+)'/g)].map(m => m[1])); const mine = new Set(toolsOf(fakeClient()).keys()); const missing = [...piNames].filter(n => !mine.has(n)); assert.deepEqual(missing, [], `本插件缺少 pi 桥有的工具:${missing.join(', ')}`); }); // ─── send_mail 的参数处理 ───────────────────────────────────────── test('★ send_mail 接受 JSON 字符串形式的 attachment_ids(opencode 上连试 6 次失败的形状)', async () => { const c = fakeClient(); await toolsOf(c).get('send_mail').run({ to: 'admin@/tmp', subject: 's', body: 'b', attachment_ids: '["10e73e9f-1"]' // ← 模型实际会这么写 }); const sent = c.calls.find(x => x.path === '/mail/send'); assert.deepEqual(sent.body.attachment_ids, ['10e73e9f-1']); }); test('send_mail 也接受数组、单 id、逗号分隔', async () => { for (const [input, want] of [ [['a', 'b'], ['a', 'b']], ['a', ['a']], ['a, b', ['a', 'b']], ['a b', ['a', 'b']] ]) { const c = fakeClient(); await toolsOf(c).get('send_mail').run({ to: 'x@/p', subject: 's', body: 'b', attachment_ids: input }); assert.deepEqual(c.calls.find(x => x.path === '/mail/send').body.attachment_ids, want); } }); test('★ 没有附件时不带 attachment_ids 字段(带空数组会被服务端当「要挂附件」)', async () => { for (const input of [undefined, null, '', [], ['', null]]) { const c = fakeClient(); await toolsOf(c).get('send_mail').run({ to: 'x@/p', subject: 's', body: 'b', attachment_ids: input }); const body = c.calls.find(x => x.path === '/mail/send').body; assert.equal('attachment_ids' in body, false, `输入 ${JSON.stringify(input)} 时不该带`); } }); test('send_mail 缺必填字段时明确报错(且不发请求)', async () => { const t = toolsOf(fakeClient()); for (const args of [{}, { to: 'x@/p' }, { to: 'x@/p', subject: 's' }]) { await assert.rejects(() => t.get('send_mail').run(args), /缺少必填字段/); } }); test('send_mail 只透传有值的可选字段', async () => { const c = fakeClient(); await toolsOf(c).get('send_mail').run({ to: 'x@/p', subject: 's', body: 'b', cc: '', reply_to: 'm1', session_alias: '', max_rounds: 5 }); const body = c.calls.find(x => x.path === '/mail/send').body; assert.deepEqual(Object.keys(body).sort(), ['body', 'max_rounds', 'reply_to', 'subject', 'to']); }); // ─── read_inbox ─────────────────────────────────────────────────── test('read_inbox 只把本次列出来的未读标为已读', async () => { const c = fakeClient({ responses: { '/mail/inbox': { mails: [ { mail_id: 'm1', subject: '一', body: 'x', from: 'pi' }, { mail_id: 'm2', subject: '二', body: 'y', from: 'dsh' } ] } } }); const out = await toolsOf(c).get('read_inbox').run({}); assert.match(out, /一/); const mark = c.calls.find(x => x.path === '/mail/read'); assert.ok(mark, '应该标记已读'); assert.deepEqual(mark.body.mail_ids, ['m1', 'm2']); }); test('read_inbox 空收件箱给出可读文本', async () => { const c = fakeClient({ responses: { '/mail/inbox': { mails: [] } } }); assert.match(await toolsOf(c).get('read_inbox').run({}), /收件箱为空/); }); test('★ 标记已读失败不影响读取结果', async () => { // 正文已经拿到了,代价只是下次重复看到 —— 比丢掉这次读取轻得多。 const c = fakeClient({ responses: { '/mail/inbox': { mails: [{ mail_id: 'm1', subject: '一', body: 'x' }] } } }); c.post = async () => { throw new Error('500'); }; const out = await toolsOf(c).get('read_inbox').run({}); assert.match(out, /一/); }); // ─── 配置缺失 ───────────────────────────────────────────────────── test('★ 未配置密钥时每次调用都明确报错(而不是收到 401 再猜)', async () => { const c = fakeClient({ config: ['AGENTMAIL_AGENT_KEY'] }); const t = toolsOf(c); await assert.rejects( () => t.get('read_inbox').run({}), /未配置完成.*AGENTMAIL_AGENT_KEY/ ); // 关键:真的一次请求都没发出去 assert.equal(c.calls.length, 0); }); test('每个工具都受配置校验保护(漏一个就会发出匿名请求)', async () => { const c = fakeClient({ config: ['AGENTMAIL_AGENT_NAME'] }); const t = toolsOf(c); const argsByName = { read_inbox: {}, read_mail: { mail_id: 'm' }, read_thread: { mail_id: 'm' }, send_mail: { to: 'x@/p', subject: 's', body: 'b' }, forward_mail: { mail_id: 'm', to: 'x@/p' }, upload_attachment: { file_path: '/tmp/x' }, download_attachment: { attachment_id: 'a', save_path: '/tmp/y' }, suggest_address: {}, list_contacts: {}, connect_to_server: {}, session_participants: { session_id: 's' } }; for (const [name, tool] of t) { if (name === 'connect_to_server') { // 它是唯一**刻意**绕过 guard 的工具:配置缺失时它负责说清楚缺什么 // (见 lib/tools.mjs 里的注释),所以要断言另一种行为。 const out = await tool.run({}); assert.match(out, /未配置完成|已连接/, name); continue; } await assert.rejects(() => tool.run(argsByName[name]), /未配置完成/, name); } assert.equal(c.calls.length, 0, '任何工具都不该在缺配置时发出请求'); }); // ─── 附件 ───────────────────────────────────────────────────────── test('upload_attachment 提示必须把 id 带进 send_mail 才发得出去', async () => { // 用真文件:这里要连真实路径一起验(读文件 → multipart 上传), // 把 uploadLocalFile 抹掉就测不到「路径写错」这种最常见的失败。 const dir = await mkdtemp(join(tmpdir(), 'zc-upload-')); const filePath = join(dir, 'a.txt'); await writeFile(filePath, 'hello'); const out = await toolsOf(fakeClient()).get('upload_attachment').run({ file_path: filePath }); assert.match(out, /att-1/); assert.match(out, /attachment_ids/); }); test('download_attachment 报告落盘路径与大小', async () => { const out = await toolsOf(fakeClient()) .get('download_attachment') .run({ attachment_id: 'a1', save_path: '/tmp/out.bin' }); assert.match(out, /\/tmp\/out\.bin/); });