## 别名替换(让 .new 邮件可寻址)
repo/autoalias.go: AutoAliasFor + EnsureSessionAlias
- .new 建完会话立刻给别名(形如 dsh-重构导入路径)
- 名字与主题都要:只用主题跨 Agent 撞名,只用名字看不出聊什么
- sanitizeAliasPart 只留 unicode.IsLetter/IsDigit,其余折 -
- 撞名追加 -2/-3,全占用退 session-<uuid前8位>
- 不复用 SyncSessionAlias:那个假定已存在且跳过 manual
- 条件写入 WHERE alias IS NULL OR '',并发安全
- resolveTarget 的 .new 与默认会话两条路径都调
notifyRecipients 加三个字段(每个收件方拿到自己那个地址的版本):
- session_alias / reply_address / self_address
- 别名为空时退回省略 session 位,绝不写 new
FormatAddress(name,path,session) 空 path 也必须留 @ 与 .
## Agent 侧寻址发现(五个只读端点)
handler/agent_discovery.go:
- /agent/contacts + /agent/contacts/suggest(三段式补全)
- /agent/mail/{id} + /agent/mail/{id}/thread
- /agent/sessions/{id}/participants
- 不复用人类路由:scope 不同、审计需求不同
- 一律只读:归档/改名/权限决策仍只有人能做
repo/participants.go: SessionParticipants 逐封扫 from/to/cc
- Roles 用集合、MailCount 只数发信(0=还没开口的人)
- 发件人 path 不取 from_workspace(那列存的是 Agent 名)
repo.SuggestPaths 重写:mails.to_workspace(按 MAX(created_at) 倒序)
+ agents.workspaces 并集。原只读 workspaces,官方插件传 [] 永远空
## 共用模块(三插件逐字节相同)
lib/addressing.js: formatAddress/roleOf/replyAddressFor/selfAddressFor/participantsOfMail
lib/discovery.js: renderNameSuggestions/renderPathSuggestions/renderSessionSuggestions/
renderParticipants/renderContacts/renderThread
lib/inbox-format.js: renderMail 新增收件人/身份/可投递地址三段
- selfName 参数(兼容旧调用不传的情况)
check-shared-libs.sh 纳入 addressing + discovery
## 插件侧
opencode: suggest_address + list_contacts + session_participants + read_thread + read_mail
dsh: 同上 + forward_mail(此前只有 opencode 有)+ upload_attachment 改真 multipart
pi: 同上(createMailTools 加 agentName 参数)
dsh: ctx.agents.create id collision 改为 readSession 探测后 resume
dsh: 关键路径日志改 console.error(ctx.logger 不进 journalctl)
## 测试
repo: autoalias_test.go 11 + participants_test.go 7 = 18 例
plugins: addressing.test 17 + discovery.test 23 + inbox-format.test 31 = 71 例
go test ./... + npm test(opencode 155 + dsh 173 + pi 199)全绿
端到端验证:admin 发 dsh@....new 抄送 opencode@....new
→ dsh 用 session_participants 取到地址 → send_mail 给 opencode
→ 地址取自工具返回值(.crisp-planet),未手工拼写
255 lines
9.6 KiB
JavaScript
255 lines
9.6 KiB
JavaScript
/**
|
||
* pi 专属纯逻辑的测试:轮次结论判定、消息文本提取、提示词。
|
||
*
|
||
* 这些不在 lib/ 下(那里的六个文件三平台逐字节相同),因为它们依赖 pi 的
|
||
* 消息形状与 stopReason 语义。但同样是纯函数,因此可以不起模型就钉住。
|
||
*
|
||
* node --test 'test/*.test.mjs'
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import {
|
||
stripRe,
|
||
replySubject,
|
||
lastAssistantText,
|
||
classifyTurnOutcome,
|
||
describeError,
|
||
buildMailPrompt,
|
||
relayKeyFor,
|
||
} from '../src/turn.mjs';
|
||
|
||
// ─── 主题 ───
|
||
|
||
test('stripRe 去掉叠加的 Re: 前缀', () => {
|
||
assert.equal(stripRe('Re: Re: Re: 缓存选型'), '缓存选型');
|
||
assert.equal(stripRe('缓存选型'), '缓存选型');
|
||
assert.equal(stripRe('RE: RE: x'), 'x');
|
||
});
|
||
|
||
test('replySubject 只加一层 Re:', () => {
|
||
assert.equal(replySubject('Re: 缓存选型'), 'Re: 缓存选型');
|
||
assert.equal(replySubject('缓存选型'), 'Re: 缓存选型');
|
||
});
|
||
|
||
test('无主题时回信有兜底主题', () => {
|
||
// 空主题会被 Gateway 拒(400 Missing subject),不兜底就发不出去
|
||
assert.equal(replySubject(''), '本轮工作总结');
|
||
assert.equal(replySubject(undefined), '本轮工作总结');
|
||
});
|
||
|
||
// ─── 消息文本提取 ───
|
||
|
||
const asst = (blocks, over = {}) => ({ role: 'assistant', content: blocks, stopReason: 'stop', ...over });
|
||
|
||
test('只取 text 块,丢掉 thinking', () => {
|
||
// 思考过程不该出现在邮件里(B-5.1 / N-6):它对收件人没有意义,
|
||
// 而且经常包含「我先假设…」这类会被误读为结论的话。
|
||
const got = lastAssistantText([
|
||
asst([
|
||
{ type: 'thinking', thinking: '先看看有没有缓存层' },
|
||
{ type: 'text', text: '已定位到问题:连接池没有复用。' },
|
||
]),
|
||
]);
|
||
assert.equal(got, '已定位到问题:连接池没有复用。');
|
||
});
|
||
|
||
test('不变量:跳过纯工具调用的收尾消息,往前找有文本的那条', () => {
|
||
// 一轮的最后一条 assistant 消息常常只有 toolCall。取到它会得到空串,
|
||
// 于是 B-5.4 判成「无话可说」而漏掉真正的结论 —— 发件人再无音讯。
|
||
const got = lastAssistantText([
|
||
asst([{ type: 'text', text: '结论在这里。' }]),
|
||
asst([{ type: 'toolCall', toolName: 'bash', input: {} }]),
|
||
]);
|
||
assert.equal(got, '结论在这里。');
|
||
});
|
||
|
||
test('多个 text 块按顺序拼接', () => {
|
||
const got = lastAssistantText([asst([
|
||
{ type: 'text', text: '第一段' },
|
||
{ type: 'text', text: '第二段' },
|
||
])]);
|
||
assert.equal(got, '第一段\n第二段');
|
||
});
|
||
|
||
test('忽略 user 消息里的文本', () => {
|
||
const got = lastAssistantText([
|
||
asst([{ type: 'text', text: 'assistant 说的' }]),
|
||
{ role: 'user', content: [{ type: 'text', text: 'user 说的' }] },
|
||
]);
|
||
assert.equal(got, 'assistant 说的');
|
||
});
|
||
|
||
test('没有 assistant 消息时返回空串', () => {
|
||
assert.equal(lastAssistantText([{ role: 'user', content: [{ type: 'text', text: 'x' }] }]), '');
|
||
assert.equal(lastAssistantText([]), '');
|
||
assert.equal(lastAssistantText(undefined), '');
|
||
});
|
||
|
||
// ─── 轮次结论(D-3,两次适配都踩过)───
|
||
|
||
test('不变量:prompt 抛错判为失败', () => {
|
||
// 实测:无凭证的 provider 让 prompt() reject(No API key found for
|
||
// amazon-bedrock.),**一个事件都不发**。只看事件的话这轮会被当成没跑完。
|
||
const got = classifyTurnOutcome({ error: new Error('No API key found for amazon-bedrock.') });
|
||
assert.equal(got.ok, false);
|
||
assert.match(got.error, /No API key/);
|
||
});
|
||
|
||
test('不变量:一条 assistant 消息都没有判为失败', () => {
|
||
// 判成功会让 B-5 转发一个空字符串回去 —— 发件人收到一封空邮件,
|
||
// 而不是错误说明。这是契约里 C-4「必须能区分成功与出错」的核心。
|
||
const got = classifyTurnOutcome({ messages: [{ role: 'user', content: [] }] });
|
||
assert.equal(got.ok, false);
|
||
assert.match(got.error, /没有产出/);
|
||
});
|
||
|
||
test('不变量:stopReason=error 判为失败并带出 errorMessage', () => {
|
||
const got = classifyTurnOutcome({
|
||
messages: [asst([{ type: 'text', text: '半句' }], {
|
||
stopReason: 'error',
|
||
errorMessage: 'upstream 503 rate limited',
|
||
})],
|
||
});
|
||
assert.equal(got.ok, false);
|
||
assert.equal(got.error, 'upstream 503 rate limited');
|
||
});
|
||
|
||
test('stopReason=error 但没给原因也要有话可说', () => {
|
||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'error' })] });
|
||
assert.equal(got.ok, false);
|
||
assert.ok(got.error, '失败原因不能是空串:renderFailureReport 会把它填进邮件');
|
||
});
|
||
|
||
test('正常收尾判为成功', () => {
|
||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '好了' }])] });
|
||
assert.deepEqual(got, { ok: true, error: '', aborted: false });
|
||
});
|
||
|
||
test('不变量:length(被 max tokens 截断)判为成功', () => {
|
||
// 内容不完整,但**是模型的产出**。判失败会让一封「说了一半」的回信
|
||
// 变成「换个模型重试」,那更糟 —— 用户什么都收不到。
|
||
const got = classifyTurnOutcome({ messages: [asst([{ type: 'text', text: '说了一半' }], { stopReason: 'length' })] });
|
||
assert.equal(got.ok, true);
|
||
});
|
||
|
||
test('aborted 判为失败但标记 aborted', () => {
|
||
// 有人主动打断(Esc / dispose),不是模型故障 —— 不该触发换模型重试
|
||
const got = classifyTurnOutcome({ messages: [asst([], { stopReason: 'aborted' })] });
|
||
assert.equal(got.ok, false);
|
||
assert.equal(got.aborted, true);
|
||
});
|
||
|
||
test('取最后一条 assistant 消息判定,不是第一条', () => {
|
||
const got = classifyTurnOutcome({
|
||
messages: [
|
||
asst([{ type: 'text', text: '第一轮好的' }], { stopReason: 'stop' }),
|
||
asst([], { stopReason: 'error', errorMessage: '第二轮炸了' }),
|
||
],
|
||
});
|
||
assert.equal(got.ok, false);
|
||
assert.equal(got.error, '第二轮炸了');
|
||
});
|
||
|
||
// ─── describeError ───
|
||
|
||
test('describeError 只取首行', () => {
|
||
// 报错原文会被填进故障邮件的正文,多行堆栈会把那封信淹掉
|
||
assert.equal(describeError(new Error('炸了\n at foo (bar.js:1)')), '炸了');
|
||
assert.equal(describeError('单行错误'), '单行错误');
|
||
});
|
||
|
||
test('describeError 带上 code', () => {
|
||
const e = new Error('connect failed');
|
||
e.code = 'ECONNREFUSED';
|
||
assert.equal(describeError(e), 'ECONNREFUSED: connect failed');
|
||
});
|
||
|
||
test('describeError 容错', () => {
|
||
assert.equal(describeError(null), '');
|
||
assert.equal(describeError(undefined), '');
|
||
});
|
||
|
||
// ─── 提示词(B-3.4 / B-3.5)───
|
||
|
||
const mailData = {
|
||
mail_id: 'm-1',
|
||
from_name: 'admin',
|
||
subject: '排查连接泄漏',
|
||
to_workspace: '/home/program/agentmail',
|
||
};
|
||
|
||
test('不变量:提示词写明回信由桥自动发', () => {
|
||
// 不说的话模型会自己调 send_mail,而桥在轮次结束时也会转发一次 ——
|
||
// 同一件事两封邮件(生产里真实发生过)。
|
||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||
assert.match(p, /回信不用你自己发/);
|
||
});
|
||
|
||
test('不变量:提示词带 mail_id 与 read_inbox 指引', () => {
|
||
// 事件里只有主题,正文和附件清单都在收件箱里;不给 mail_id 模型无法定位这一封
|
||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||
assert.match(p, /m-1/);
|
||
assert.match(p, /read_inbox/);
|
||
});
|
||
|
||
test('首封带身份,续谈不重复带', () => {
|
||
const first = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||
const again = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: true });
|
||
assert.match(first, /你是 pi/);
|
||
assert.doesNotMatch(again, /你是 pi/);
|
||
assert.match(again, /续谈/);
|
||
});
|
||
|
||
test('补投的邮件在提示词里说明来源', () => {
|
||
// 不说明的话模型会以为这是刚到的、按「立即响应」的语气回
|
||
const p = buildMailPrompt({
|
||
agentName: 'pi',
|
||
data: { ...mailData, catchup: true },
|
||
kind: 'mail',
|
||
reused: false,
|
||
});
|
||
assert.match(p, /积压/);
|
||
});
|
||
|
||
test('不变量:带上服务端算好的 reply_address', () => {
|
||
// 模型确实会自己发信(要抄送第三方、或分多封交代不同的事)。
|
||
// 让它自己拼三维地址的话,`.new` 会被拼进去 —— 回信静默开出一条新会话,
|
||
// 原来的线索里再无下文。服务端在 new_mail 里已经算好了这个地址。
|
||
const p = buildMailPrompt({
|
||
agentName: 'pi',
|
||
data: { ...mailData, reply_address: 'admin@.排查连接泄漏' },
|
||
kind: 'mail',
|
||
reused: false,
|
||
});
|
||
assert.match(p, /admin@\.排查连接泄漏/);
|
||
});
|
||
|
||
test('没有 reply_address 时不留空行占位', () => {
|
||
const p = buildMailPrompt({ agentName: 'pi', data: mailData, kind: 'mail', reused: false });
|
||
assert.doesNotMatch(p, /回信地址/);
|
||
});
|
||
|
||
test('权限决策的提示词带决策与决策人', () => {
|
||
const p = buildMailPrompt({
|
||
agentName: 'pi',
|
||
data: { decision: '同意', decided_by: 'zhang' },
|
||
kind: 'permission',
|
||
reused: true,
|
||
});
|
||
assert.match(p, /同意/);
|
||
assert.match(p, /zhang/);
|
||
});
|
||
|
||
// ─── 幂等键 ───
|
||
|
||
test('relayKey 由会话 id 与叶子 id 组成', () => {
|
||
assert.equal(relayKeyFor('sess-1', 'leaf-9'), 'sess-1:leaf-9');
|
||
});
|
||
|
||
test('不变量:叶子 id 缺失时仍产出稳定键', () => {
|
||
// 返回空串会让服务端把 relay_key 当作「没给」,于是幂等失效、同一轮转两次
|
||
assert.equal(relayKeyFor('sess-1', null), 'sess-1:noleaf');
|
||
assert.equal(relayKeyFor('sess-1', undefined), 'sess-1:noleaf');
|
||
});
|