## 别名替换(让 .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),未手工拼写
186 lines
9.2 KiB
JavaScript
186 lines
9.2 KiB
JavaScript
/**
|
||
* 会话命名一致性的测试。
|
||
*
|
||
* 这是 pi 接入里最容易做错的一块,因为「两边各自命名然后指望撞上」看起来能用:
|
||
* 单条会话、不撞名、没人手工改过名时,两边确实一致。上面任何一条不成立就分叉。
|
||
*
|
||
* 因此这里的每个 test 都对应一条**分叉场景**。
|
||
*
|
||
* node --test 'test/*.test.mjs'
|
||
*/
|
||
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { planNamingSync, planWriteBack } from '../src/naming.mjs';
|
||
|
||
// ─── 向 Gateway 提交(W-7)───
|
||
|
||
test('平台名字正常时派生别名并原样带标题', () => {
|
||
const got = planNamingSync({ platformName: '排查连接泄漏', mailSubject: '别用我', lastSynced: '' });
|
||
assert.equal(got.skip, false);
|
||
assert.equal(got.alias, '排查连接泄漏');
|
||
assert.equal(got.title, '排查连接泄漏');
|
||
assert.equal(got.source, 'platform');
|
||
});
|
||
|
||
test('不变量:标题原样提交,不清洗不派生', () => {
|
||
// I-4:插件只搬运。标题那一列不负责寻址,没有字符限制,
|
||
// 改写它等于让邮箱里显示的和 pi 里显示的是两个东西。
|
||
const name = 'Fix: cache TTL (v2) — 缓存/过期';
|
||
const got = planNamingSync({ platformName: name, mailSubject: '', lastSynced: '' });
|
||
assert.equal(got.title, name);
|
||
// 别名要按寻址规则剥掉分隔符
|
||
assert.doesNotMatch(got.alias, /[.@/]/);
|
||
});
|
||
|
||
test('不变量:内容没变就不重复提交', () => {
|
||
// setSessionName 会触发 session_info_changed,钩子又去 sync,
|
||
// 不判「与上次相同」就是自激循环 —— 每 30 秒刷一次 Gateway。
|
||
const first = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: '' });
|
||
assert.equal(first.skip, false);
|
||
const again = planNamingSync({ platformName: '同一个名字', mailSubject: '', lastSynced: first.signature });
|
||
assert.equal(again.skip, true);
|
||
assert.equal(again.reason, 'unchanged');
|
||
});
|
||
|
||
test('不变量:pi 没有名字时退到邮件主题(SDK 路径的常态)', () => {
|
||
// 桥用 SDK 起的会话不经过 pi-web 的标题生成器,sessionName 一直是 undefined。
|
||
// 只等平台命名的话别名永远是空的,`name@path.<别名>` 续谈无从下手 ——
|
||
// 第一次端到端跑通时就是这个结果(sessions.session_alias 是空串)。
|
||
const got = planNamingSync({ platformName: undefined, mailSubject: '主链路验证', lastSynced: '' });
|
||
assert.equal(got.skip, false);
|
||
assert.equal(got.alias, '主链路验证');
|
||
assert.equal(got.source, 'mail-subject');
|
||
});
|
||
|
||
test('不变量:无名字 + 主题未变时也要判 unchanged', () => {
|
||
// 用平台名字本身充当「上次提交了什么」的记录时,名字为空就无法区分
|
||
// 「还没提交过」与「提交过、内容没变」,于是每轮心跳都白打一次 sync。
|
||
const first = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: '' });
|
||
const again = planNamingSync({ platformName: '', mailSubject: '固定主题', lastSynced: first.signature });
|
||
assert.equal(again.skip, true, '空名字场景同样要能判出「没变化」');
|
||
});
|
||
|
||
test('思维链泄漏的名字退到邮件主题派生别名', () => {
|
||
// pi-web 的标题生成器不防这个(cleanSessionName 只取首行 + 截 60 字符)。
|
||
// 本机 81 条会话里实测捞到过这条。
|
||
const got = planNamingSync({
|
||
platformName: 'The user is asking me to generate a title for a coding-agent',
|
||
mailSubject: '排查连接泄漏',
|
||
lastSynced: '',
|
||
});
|
||
assert.equal(got.skip, false);
|
||
assert.equal(got.alias, '排查连接泄漏');
|
||
assert.equal(got.source, 'mail-subject');
|
||
});
|
||
|
||
test('不变量:退到邮件主题时不写标题', () => {
|
||
// 标题那一列的语义是「平台生成的会话标题」。把邮件主题填进去会让
|
||
// 邮箱里看起来像是 pi 生成了这个标题,而 pi 侧其实是另一个名字(或没有)。
|
||
const got = planNamingSync({
|
||
platformName: '我们只需要生成标题,不包含其他内容。标题应反映请求内容:测试。简短:测试。或者更简',
|
||
mailSubject: '压测报告',
|
||
lastSynced: '',
|
||
});
|
||
assert.equal(got.title, '');
|
||
assert.equal(got.alias, '压测报告');
|
||
});
|
||
|
||
test('不变量:无可用名字时什么都不写(W-7.2)', () => {
|
||
// 宁可让会话保持无别名(session_alias 允许 NULL),也不要写 "session-123"
|
||
// 这种占位值 —— 它对人毫无指代作用,而且一旦落库就把 alias 位占住了,
|
||
// 真正的名字来了只能追 -2 后缀。
|
||
const got = planNamingSync({ platformName: '', mailSubject: '', lastSynced: '' });
|
||
assert.equal(got.skip, true);
|
||
assert.equal(got.reason, 'no-usable-name');
|
||
});
|
||
|
||
test('纯符号名字:写标题但不写别名', () => {
|
||
// slugFromTitle('...') 是空串,作为别名非法(Gateway 会 400),
|
||
// 但这个名字本身是平台产出,标题列该照实反映。
|
||
const got = planNamingSync({ platformName: '...', mailSubject: '', lastSynced: '' });
|
||
assert.equal(got.skip, false);
|
||
assert.equal(got.alias, '');
|
||
assert.equal(got.title, '...');
|
||
assert.equal(got.source, 'platform-title-only');
|
||
});
|
||
|
||
test('主题也派生不出别名时不写', () => {
|
||
const got = planNamingSync({ platformName: 'The user is asking me to', mailSubject: '@@@', lastSynced: '' });
|
||
assert.equal(got.skip, true);
|
||
});
|
||
|
||
// ─── 回写进 pi(D-5 / 一致性的关键)───
|
||
|
||
test('不变量:撞名后缀必须回写进 pi', () => {
|
||
// Gateway 侧别名负有寻址唯一性义务(partial unique index),撞名自动追 -2。
|
||
// pi 侧没有这个约束。不回写的话:邮箱里是 fix-leak-2、pi-web 里是 fix-leak,
|
||
// 用户按界面上看到的名字发信会 404。
|
||
const got = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||
assert.equal(got.write, true);
|
||
assert.equal(got.name, 'fix-leak-2');
|
||
assert.equal(got.reason, 'diverged');
|
||
});
|
||
|
||
test('不变量:manual 别名(人手工改过)优先,回写进 pi', () => {
|
||
// SyncSessionAlias 遇到 alias_source='manual' 时不覆盖,**原样返回当前别名**。
|
||
// 于是「人在 AgentMail 界面上定的名字」赢,pi 侧要跟着改 —— 这是有意的:
|
||
// 人的意图优先于模型生成的标题。
|
||
const got = planWriteBack({ finalAlias: '紧急排查', currentPiName: 'connection-leak' });
|
||
assert.equal(got.write, true);
|
||
assert.equal(got.name, '紧急排查');
|
||
});
|
||
|
||
test('规范化改写过的别名也要回写', () => {
|
||
// normalizeAlias 把 . / @ 空白换成 -。提议 "a.b c" 会变成 "a-b-c"。
|
||
const got = planWriteBack({ finalAlias: 'a-b-c', currentPiName: 'a.b c' });
|
||
assert.equal(got.write, true);
|
||
assert.equal(got.name, 'a-b-c');
|
||
});
|
||
|
||
test('两边已经一致就不回写', () => {
|
||
// 回写会 append 一条 session_info 并触发 session_info_changed。
|
||
// 无条件回写 = 每轮多一条无意义的历史条目 + 一次多余的 sync。
|
||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: 'fix-leak' });
|
||
assert.equal(got.write, false);
|
||
assert.equal(got.reason, 'already-equal');
|
||
});
|
||
|
||
test('pi 侧还没有名字时也要回写', () => {
|
||
// 桥用 SDK 起的会话没有名字(pi-web 的生成器不在这条链路上),
|
||
// 此时 Gateway 定稿的别名就是这条会话的第一个名字。
|
||
const got = planWriteBack({ finalAlias: 'fix-leak', currentPiName: undefined });
|
||
assert.equal(got.write, true);
|
||
assert.equal(got.reason, 'pi-unnamed');
|
||
});
|
||
|
||
test('不变量:响应没带别名时不回写', () => {
|
||
// 本次只同步了标题(planNamingSync 的 platform-title-only 分支)→ 没有定稿值。
|
||
// 拿空串去 setSessionName 是**清除**语义(实测 appendSessionInfo(" ")
|
||
// 之后 getSessionName() 变 undefined),会把 pi 侧原有的名字抹掉。
|
||
assert.equal(planWriteBack({ finalAlias: '', currentPiName: 'keep-me' }).write, false);
|
||
assert.equal(planWriteBack({ finalAlias: undefined, currentPiName: 'keep-me' }).write, false);
|
||
assert.equal(planWriteBack({ finalAlias: ' ', currentPiName: 'keep-me' }).write, false);
|
||
});
|
||
|
||
// ─── 端到端的一致性推理 ───
|
||
|
||
test('完整链路:提议 → 撞名定稿 → 回写 → 再观测不再动', () => {
|
||
// 这个 test 钉住「不会自激循环」这条性质,它是分四步的:
|
||
// 1. pi 有了名字 fix-leak,提交
|
||
// 2. Gateway 撞名,定稿 fix-leak-2
|
||
// 3. 回写进 pi,pi 的名字变成 fix-leak-2
|
||
// 4. session_info_changed 再次触发 → 必须 skip,否则无限循环
|
||
const step1 = planNamingSync({ platformName: 'fix-leak', mailSubject: '', lastSynced: '' });
|
||
assert.equal(step1.alias, 'fix-leak');
|
||
|
||
const step3 = planWriteBack({ finalAlias: 'fix-leak-2', currentPiName: 'fix-leak' });
|
||
assert.equal(step3.write, true);
|
||
|
||
// 桥在回写后把指纹更新成「定稿别名当作平台名字」会算出的那个值,
|
||
// 因此第 4 步(回写触发的事件)看到的指纹与它相同。
|
||
const afterWriteBack = `platform:${step3.name}|${step3.name}`;
|
||
const step4 = planNamingSync({ platformName: 'fix-leak-2', mailSubject: '', lastSynced: afterWriteBack });
|
||
assert.equal(step4.skip, true, '回写触发的事件必须被指纹挡住,否则无限循环');
|
||
});
|