/** * 会话命名一致性的测试。 * * 这是 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, '回写触发的事件必须被指纹挡住,否则无限循环'); });