import { test } from 'node:test'; import assert from 'node:assert/strict'; import { renderNameSuggestions, renderPathSuggestions, renderSessionSuggestions, renderParticipants, renderContacts, renderThread, } from '../lib/discovery.js'; // 这一组渲染的唯一目的是让模型**不要自己拼地址**。 // 所以断言集中在两点:给出的地址能原样使用;以及模型知道下一步该查什么。 test('renderNameSuggestions 只给名字并指向下一步', () => { // 此时还不知道 path 与 session,硬拼裸名字地址会投到「默认会话」—— // 那不一定是调用方想要的那条。 const got = renderNameSuggestions(['opencode', 'admin']); assert.match(got, /opencode/); assert.match(got, /admin/); assert.match(got, /suggest_address/, '要告诉模型下一步查什么'); }); test('renderNameSuggestions 空列表给明确文案', () => { assert.match(renderNameSuggestions([]), /没有可投递的收件人/); assert.match(renderNameSuggestions(undefined), /没有可投递的收件人/); }); test('renderPathSuggestions 空列表要说清「仍然能发」', () => { // 不解释的话模型会卡在这一步,或者编一个路径出来。 const got = renderPathSuggestions([], 'admin'); assert.match(got, /可以留空/); assert.match(got, /admin/); }); test('renderPathSuggestions 列出目录并指向下一步', () => { const got = renderPathSuggestions(['/home', '/home/program/agentmail'], 'opencode'); assert.match(got, /\/home\/program\/agentmail/); assert.match(got, /最近使用/); assert.match(got, /suggest_address\(name="opencode", path="/); }); const sessionData = { kind: 'session', suggestions: ['silent-harbor', 'happy-tiger', 'new'], addresses: [ 'opencode@/home.silent-harbor', 'opencode@/home.happy-tiger', 'opencode@/home.new', ], candidates: [ { alias: 'silent-harbor', title: '联调 llmsproxy', source: 'mail', unread: 2 }, { alias: 'happy-tiger', title: '补投验证', source: 'mail', unread: 0 }, { alias: 'new', title: '新建会话', source: 'new' }, ], }; test('renderSessionSuggestions 用服务端拼好的完整地址', () => { // 插件自己拼过一次,拼错了(空 path 时漏掉 @)。addresses 与 suggestions // 同序由服务端保证,直接用。 const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); assert.match(got, /opencode@\/home\.silent-harbor/); assert.match(got, /opencode@\/home\.happy-tiger/); assert.match(got, /原样填进 send_mail 的 to/); }); test('renderSessionSuggestions 带出标题与未读数', () => { const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); assert.match(got, /联调 llmsproxy/); assert.match(got, /2 封未读/); }); test('不变量:new 不与已存在会话混列,且带警告', () => { // new 排在前面会让模型在想续谈时顺手开出一条新线索 —— 生产上已经发生过。 const got = renderSessionSuggestions(sessionData, 'opencode', '/home'); const lines = got.split('\n'); const newLineIdx = lines.findIndex(l => l.includes('.new')); const harborIdx = lines.findIndex(l => l.includes('silent-harbor')); assert.ok(harborIdx >= 0 && newLineIdx > harborIdx, 'new 必须排在已存在会话之后'); assert.match(got, /新\*\*线索|新\*\*/, 'new 要带「这是开新线索」的提示'); }); test('renderSessionSuggestions 无已存在会话时引导命名', () => { // 这是关键引导:开新会话时传 session_alias,之后才能按名字续谈。 // 不传的话服务端会自动命名,但模型不知道那个名字。 const got = renderSessionSuggestions( { suggestions: ['new'], addresses: ['dsh@/tmp.new'], candidates: [{ alias: 'new', source: 'new' }] }, 'dsh', '/tmp', ); assert.match(got, /还没有可续谈的会话/); assert.match(got, /session_alias/); }); const participantData = { session_id: 'f3d824ce', session_alias: 'silent-harbor', participants: [ { name: 'admin', path: '', roles: ['from'], is_self: false, mail_count: 1, address: 'admin@.silent-harbor' }, { name: 'dsh', path: '/home/program/llmsproxy', roles: ['to'], is_self: true, mail_count: 0, address: 'dsh@/home/program/llmsproxy.silent-harbor' }, { name: 'opencode', path: '/home', roles: ['cc'], is_self: false, mail_count: 0, address: 'opencode@/home.silent-harbor' }, ], }; test('renderParticipants 给出每个参与方的地址', () => { const got = renderParticipants(participantData); assert.match(got, /opencode@\/home\.silent-harbor/); assert.match(got, /admin@\.silent-harbor/); assert.match(got, /原样填进 send_mail 的 to/); }); test('不变量:标出「尚未回应」的人', () => { // mail_count 为 0 就是还没开口的人。服务端只数「作为发件人」的邮件, // 正是为了让这个判断成立。 const got = renderParticipants(participantData); const line = got.split('\n').find(l => l.includes('opencode')); assert.match(line, /尚未回应/); // 自己不该被标「尚未回应」—— 自己正在处理这封 const selfLine = got.split('\n').find(l => l.includes('dsh')); assert.ok(!selfLine.includes('尚未回应')); assert.match(selfLine, /就是你/); }); test('renderParticipants 用中文角色标签', () => { // 模型读到「抄送方」比读到 cc 更容易判对分工。 const got = renderParticipants(participantData); assert.match(got, /抄送方/); assert.match(got, /发件人/); }); test('renderParticipants 无地址时说明原因', () => { const got = renderParticipants({ session_alias: '', participants: [{ name: 'x', roles: ['to'], mail_count: 0, address: '' }], }); assert.match(got, /尚未命名/); }); test('renderParticipants 空会话不崩', () => { assert.match(renderParticipants({ participants: [] }), /还没有参与方/); assert.match(renderParticipants({}), /还没有参与方/); }); test('renderContacts 未读优先排序', () => { // 模型问「我还有什么没处理」时,有未读的那些才是答案。 const got = renderContacts({ contacts: [ { address: 'a@.x', unread_count: 0, last_activity: '2026-09-03T02:00:00Z' }, { address: 'b@.y', unread_count: 3, last_activity: '2026-09-01T00:00:00Z' }, ], }); const lines = got.split('\n').filter(l => l.startsWith('- ')); assert.match(lines[0], /b@\.y/, '有未读的应排在最前'); assert.match(lines[0], /3 封未读/); }); test('renderContacts 带出剩余预算', () => { const got = renderContacts({ contacts: [{ address: 'a@.x', unread_count: 0, max_rounds: 20, used_rounds: 17 }], }); assert.match(got, /剩 3\/20 个来回/); }); test('renderContacts 未命名会话说明只能 reply_to', () => { const got = renderContacts({ contacts: [{ address: '', unread_count: 1 }] }); assert.match(got, /reply_to/); }); test('renderContacts 空列表', () => { assert.match(renderContacts({ contacts: [] }), /还没有任何往来会话/); }); const threadData = { anchor_mail_id: 'm-2', total: 3, hidden: 1, nodes: [ { mail_id: 'm-1', from_name: 'admin', to_name: 'dsh', subject: '抄收联调', depth: 0 }, { mail_id: 'm-2', from_name: 'dsh', to_name: 'opencode', subject: '[联调] 请提供部署现状', depth: 1 }, { mail_id: 'm-3', from_name: 'opencode', to_name: 'dsh', subject: 'Re: 联调', depth: 2, detached: true, parent_hidden: true }, ], }; test('renderThread 用缩进表示层级', () => { const got = renderThread(threadData, 'dsh'); const lines = got.split('\n'); const l1 = lines.find(l => l.includes('m-1')); const l2 = lines.find(l => l.includes('m-2')); assert.ok(l2.indexOf('- ') > l1.indexOf('- '), '子节点应更深缩进'); }); test('不变量:detached 必须标出来', () => { // 不标的话模型会以为这是一条独立线索,而它其实挂在一封看不到的邮件下面。 const got = renderThread(threadData, 'dsh'); const line = got.split('\n').find(l => l.includes('m-3')); assert.match(line, /父邮件无权查看/); }); test('renderThread 标出自己发的与当前这封', () => { const got = renderThread(threadData, 'dsh'); assert.match(got.split('\n').find(l => l.includes('m-2')), /你发的/); assert.match(got.split('\n').find(l => l.includes('m-2')), /当前这封/); }); test('renderThread 报告不可见数量', () => { // 「共 3 封」与实际列出 3 条一致,但另有 1 封无权查看 —— // 不说的话模型会以为自己看到了全貌。 assert.match(renderThread(threadData), /另有 1 封无权查看/); }); test('renderThread 有更多时给出 offset', () => { const got = renderThread({ ...threadData, has_more: true, next_offset: 60 }); assert.match(got, /offset=60/); }); test('renderThread 空线索不崩', () => { assert.match(renderThread({ nodes: [] }), /没有可见的邮件/); assert.match(renderThread({}), /没有可见的邮件/); });