Files
MailUI4Agents/plugins/dsh-mail-bridge/test/session-events-source.test.mjs
JianFeeeee caffe2f9fe fix(dsh): agents.get() 返回 agent 本体,当 handle 用缺一层 .agent
## 症状(上一版修复后暴露的新错)

    会话 mail-515c0c70… 已在运行,直接 followup(不重复 resume)
    new_mail 处理失败: Cannot read properties of undefined (reading 'followup')

去重生效了(不再撞 flock),但下一步炸。

## 根因:两个来源返回的不是同一种东西

    ctx.agents.get(id)   → **agent 本体**(.followup / .session 直接在上面)
    startAgent()         → handle,agent 在 **handle.agent**

证据在同文件 findLiveDshSession(约 1059 行)——它自己也要包一层:

    const live = ctx.agents.get(bound.dshSessionId);
    if (live) return { id: bound.dshSessionId, agent: live };

而去重那段把 `get()` 的返回值**直接赋给 handle** ⇒ `handle.agent` 是 undefined
⇒ 一取 followup 就炸。

⇒ 两条路径拿的是同一个对象,只是用法不同:一个包了一层、一个没包。

## 修法

`handle = { agent: alreadyLive }`。

## 判据(+2 格)

钉「从 `agents.get()` 取到的东西,必须包成 `{ agent: … }` 才能当 handle 用」。

★ 写这格时又被自己的两个假设绊住:
  1. 先写的是「看 `handle = …` 那一行有没有 `agents.get`」——
     实际代码是**两步**(先绑变量 `alreadyLive`,再 `handle = { agent: alreadyLive }`),
     赋值式里根本没有 `get(` 字样 ⇒ 判据报「应至少有一处…」而自己红了。
     改成**追变量来源**。
  2. 追来源时正则先匹配到接管路径的 `live`(它的用法本来就对),
     于是抓不到新加的那处 ⇒ 收紧成只匹配**双可选链** `?.get?.(` 那一处。

dsh 444 格全绿 · tsc 零错 · 部署成功(current → 20261004-094533)· 心跳正常。
变异:去掉 `{ agent }` 包装 → 红 2 格。
2026-10-04 12:03:58 +08:00

362 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
DSH 会话事件数据源的**接线**判据(2026-10-04)。
# 为什么需要这一格(纯函数测试抓不到)
`lastAssistantText` 是纯函数,它的单元测试全绿 —— 而生产上「模型写了完整回信,
插件却发『处理失败:空回复』」。根因不在纯函数,在**调用点读了不存在的字段**:
agent.session?.events // dsh 0.2.0-rc.2 的 session 没有这个键
// ⇒ ?? [] 永远兜成空数组
// ⇒ 每一次都判空
实测键表(由插件自己在 idle 时打印,见 git log 里的 24f7ed9 后续提交):
[log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …]
没有 `events`。事件序列是 **`log`**(Array(26),末条 turn/end,键
[type,seq,time,data],与磁盘 session.v4.jsonl.zstd 逐字一致)。
`eventsSnapshot` 名字最像,**实测是 null** —— 照名字写代码会第二次踩坑。
# 这一格钉住什么
**源码里任何「读 session 的事件」的地方都必须经过 sessionEvents()**。
为什么用源码形状判据而不用行为判据:
· 行为判据(跑一轮真实会话)代价太高,且要真人发信才触发得到;
· 这一类缺陷的本质是「读错字段名」,它**一定**在源码里留下痕迹。
形状判据是这类缺陷的直接证据,不是「我打算写的写法」。
# 历史:同一个错犯过两次
`modelTitle(a?.session?.events ?? [])`(会话标题同步)与
`lastAssistantText(agent.session?.events)`(自动转发)**各写各的**,
修了一处另一处还错着 —— 所以本判据禁的不只是某个字段,而是「绕过辅助函数」。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = join(HERE, '..', 'src', 'index.ts');
/**
* 每次调用都现读文件。
*
* ★ 踩过的坑:初版写的是模块级 `const code = readFileSync(...)`,于是
* 「改坏源码 → 跑判据 → 应红」这类变异**永远红不了** —— 模块已加载,
* `code` 还是旧值。两次变异都 0 红,而我一度以为判据有效。
* 判据自己读文件,就必须**在判据里现读**,不能缓存。
*/
function readCode() {
return readFileSync(SRC, 'utf8');
}
/** 去掉块注释与行注释,避免注释里的字段名被当成真实调用。 */
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
}
/**
* 现读并剥注释。
*
* ★ 同样的坑踩了两次:初版是 `const live = stripComments(code)`,
* 而 `code` 又是模块级 readFileSync ⇒ 变异改文件后判据仍读旧值。
* **判据读外部文件就必须每次现读**,任何一层缓存都会让它对变异免疫。
*/
const liveNow = () => stripComments(readCode());
/**
* 提取辅助函数体。
*
* 初版正则假设「无返回类型 + 4 空格缩进」,而实际是
* `function sessionEvents(agentLike: any): any[] {` + 2 空格缩进
* ⇒ 三格全红,而代码本来是对的。**判据自己的匹配范围比语义窄**
* 也是本轮反复出现的那一族错误,所以这里以真实签名为准并写死注释。
*/
const HELPER_RE = /function sessionEvents\(agentLike: any\): any\[\] \{[\s\S]*?\n \}/;
function helperBodyOf(src) {
return src.match(HELPER_RE)?.[0] ?? '';
}
const helperBodyText = () => helperBodyOf(liveNow());
test('★ 事件数据源:辅助函数读 session.log(不是 session.events)', () => {
// 辅助函数本体必须存在,且 log 优先
// ★ 必须判**顺序**,不能只判「log 出现过」。
// `log ?? events ?? []` 与 `events ?? log ?? []` 两边都含 `.session?.log ??`,
// 所以 `includes` 两种写法都为 true ⇒ 判据对这次变异免疫(实测两轮 0 红)。
const hb = helperBodyText();
const logFirst = hb.indexOf('.session?.log ??');
const eventsFirst = hb.indexOf('.session?.events ??');
assert.ok(logFirst >= 0, '★ 必须读 session.log —— dsh 0.2 的 session 没有 events 键');
assert.ok(
eventsFirst < 0 || logFirst < eventsFirst,
'★ session.log 必须排在 session.events 之前(log 优先)—— ' +
`实测 log@${logFirst} events@${eventsFirst}。` +
'读成 events 优先就退回到生产事故的形状:`events` 不存在 ⇒ 恒取空。',
);
});
test('★ 任何读会话事件的地方都必须走 sessionEvents(),不得直接读字段', () => {
// 逐个匹配点判断「它是否位于辅助函数体内」——
// ★ 不能用 `helperBody.includes(frag)` 豁免:
// 辅助函数体内**本来就含** `.session?.log` 与 `.session?.events` 两处回退,
// 用 includes 豁免会把代码里**任何位置**的同名字段都一并放过
// (实测:把 `modelTitle(a?.session?.events)` 改回去,offenders 仍是 []
// ⇒ 这一格对它完全免疫)。
const src = liveNow();
const hb = helperBodyText();
const hbStart = src.indexOf(hb);
const hbEnd = hbStart + hb.length;
const offenders = [];
for (const m of src.matchAll(/[^\w]session\??\.(?:events|log)\b/g)) {
const inside = m.index >= hbStart && m.index < hbEnd;
if (!inside) offenders.push(m[0].trim());
}
assert.deepEqual(
offenders,
[],
'★ 这些地方直接读了 session 的事件字段,绕过 sessionEvents():' +
offenders.join(' | ') +
'\n ⇒ dsh 0.2.0-rc.2 的 session 没有 events 键(实测键表见文件头注释),' +
'直接读得到 undefined ⇒ ?? [] 兜成空 ⇒ 每次都判「空回复」。' +
'\n 另一处(会话标题同步)曾因此长期静默失败 —— 两处各写各的、修一处漏一处。',
);
});
test('★ 回退链保留 events(万一是更老的运行时字段名不同,不比原来更差)', () => {
assert.match(
helperBodyText(),
/\.session\?\.events\s*\?\?/,
'回退链应保留 session.events —— 只修当前运行时、不给老版本留路,会让「修 bug」变成「换一个版本就坏」',
);
});
test('★ 判据自检:把辅助函数改成优先读 events,第一格必须能红', () => {
// 复现生产事故的精确形状:辅助函数优先读 events(而不是 log)
const broken = readCode().replace('.session?.log ??', '.session?.events ??');
assert.notEqual(broken, readCode(), '变异必须真的改到源码(否则下面的转红是假的)');
// ★ 用第一格**同一条判据**去判坏源码,它必须判红。
// 初版这格数的是「helperBody 之外的直接访问」,而变异把两处都留在
// 辅助函数体内 ⇒ offenders 为空 ⇒ 自检自己红了。形状错了:
// 要验的是「第一格会不会转红」,不是「另一个数法会不会发现东西」。
const brokenHelper = helperBodyOf(stripComments(broken));
const bLog = brokenHelper.indexOf('.session?.log ??');
const bEvents = brokenHelper.indexOf('.session?.events ??');
assert.ok(bEvents >= 0, '变异方向错:坏源码里应有 events 优先');
const prefersLog = bLog >= 0 && (bEvents < 0 || bLog < bEvents);
assert.equal(
prefersLog,
false,
'★ 第一格判据在「辅助函数优先读 events」这个真实事故形状下仍然判绿 —— ' +
'它抓不到自己要抓的东西,等于没有',
);
});
/*
「resume 前必须查 live」判据(2026-10-04)。
# 症状
用户报「dsh 秒回错误」—— 每封来信立刻收到 `处理失败`,一封都没进去:
会话 mail-515c0c70… 已在磁盘上(cwd=未记录),改为 resume 续谈
建会话失败 llmsproxy/AUTO: session "mail-515c0c70…" is already owned
by an active write handle
# 根因
dsh 的会话持久化用 **flock 独占锁**
(`dsh-session-persistence-jsonl`:`tryLockExclusive` 失败即抛 `SessionAlreadyOwnedError`)。
`startAgent` 在磁盘上已有该 id 时走 `ctx.agents.resume()`,它会**再申请一次写 lease**,
撞上同一进程已持有的那个锁。lsof 确认持有者就是 dsh 自己。
⇒ 不是残留锁、不是别的进程,是**自己撞自己**。而 flock 随进程退出释放,
所以**重启 dsh 也治不好** —— 下次 resume 照样撞(实测:重启后仍秒失败)。
# 为什么判据盯「所有 startAgent 调用点」而不是某一行
同一文件里两处调 `startAgent`:
· 接管路径(约 1187 行)—— **早有** `ctx.agents.get()` 去重,
注释写明「同一条会话两个 handle 会各自往日志里写,replay 校验不过」
· 普通投递路径 —— 漏了 ⇒ 同一个陷阱只在一条路上发作
所以判据是「**每个** `startAgent` 调用点前面都必须有 live 检查」,
少一处就红。这比钉某一行的形状更能防住「修好一处、漏另一处」
(那个错误已经犯过一次:先修 `modelTitle` 的字段名,漏了 `lastAssistantText`)。
*/
test('★ 每个 startAgent 调用点都必须先查 live(否则撞自己的 flock)', () => {
const src = liveNow();
const lines = src.split('\n');
// ★ 只认**调用点**,不认函数定义:
// `startAgent` 函数体内有 `return await ctx.agents.resume(...)`,
// 而 `await startAgent(` 这个形状会连它一起匹配上(实测剥注释后行号
// 925/1090 都是函数体,真正的调用点是 1187/1352)⇒ 窗口里当然找不到
// live 检查,判据对着一段不该检查的代码报红。
// 判据:**赋值给变量**的才是调用点(`const x = await startAgent(...)` /
// `const { handle } = await startAgent(...)`),函数体内是 `return await …`。
const callIdx = [];
lines.forEach((l, i) => {
// `async function startAgent(` 里没有 `=`,`[^=]*` 会一路匹配过去 ⇒ 定义被当成调用。
// 收紧成「必须有 = 」,即 `const x = await startAgent(...)` / `const {h} = await startAgent(...)`。
if (/(?:const|let|var)\b[^=]*=\s*await startAgent\(/.test(l) && !/function\s+startAgent/.test(l)) callIdx.push(i);
});
assert.ok(callIdx.length >= 2, `应至少有两个 startAgent 调用点(实际 ${callIdx.length})`);
const missing = [];
for (const i of callIdx) {
// ★ 窗口从调用点**往上找到最近的 `agents.get(`**,不用固定行数:
// 两处 live 检查的位置差很多(接管路径那条紧贴调用点,普通路径那条在循环外),
// 固定 30 行在剥离注释后行号偏移时会算错 —— 实测接管路径被误判成「没有检查」。
// 找「最近的 get(」而不是「窗口内有没有」:只要在这次调用之前查过 live 即可。
let window = '';
for (let k = i - 1; k >= Math.max(0, i - 60); k--) {
if (/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(lines[k])) {
window = lines.slice(k, i).join('\n');
break;
}
}
// ★ 只看**代码**(liveNow 已剥注释),且**不限定变量名**:
// - 含 `alreadyLive` 时那段注释里也出现该词 ⇒ 抽掉代码判据照样绿;
// - 限定变量名又会漏掉接管路径的 `const live = ctx.agents.get(adoptedID)`
// (写法本来就不同,那是更早写的)。
// ⇒ 判据只问「有没有查 live」,不问「叫什么名字、怎么写的」。
if (!/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(window)) {
missing.push(`第 ${i + 1} 行`);
}
}
assert.deepEqual(
missing,
[],
'★ 这些 startAgent 调用点前面没有 live 检查:' + missing.join('、') +
'\\n ⇒ 会话已在磁盘上时走 resume,会**再申请一次写 lease**,' +
'撞上自己已持有的 flock ⇒ 每封来信秒回「处理失败」。' +
'\\n 接管路径早就有这个检查(注释:同一条会话两个 handle 会各自往日志里写);' +
'普通投递路径漏了。重启 dsh 治不好 —— flock 随进程退出释放,下次照样撞。',
);
});
test('判据自检:抽掉 live 检查必须能红', () => {
const src = liveNow();
// 抽掉**普通路径**那处 live 检查(接管路径的还在,自检必须针对新加的那处)
const broken = src.replace(
/const alreadyLive = ctx\.agents[^\n]*\n/,
'const alreadyLive: any = undefined;\n',
);
assert.notEqual(broken, src, '变异必须真的改到源码(否则下面的转红是假的)');
const lines = broken.split('\n');
const callIdx = [];
lines.forEach((l, i) => {
if (/(?:const|let|var)\b[^=]*=\s*await startAgent\(/.test(l) && !/function\s+startAgent/.test(l)) callIdx.push(i);
});
const missing = callIdx.filter((i) => {
let window = '';
for (let k = i - 1; k >= Math.max(0, i - 60); k--) {
if (/ctx\.agents(?:\?\.|\.)?get(?:\?\.)?\s*\(/.test(lines[k])) {
window = lines.slice(k, i).join('\n');
break;
}
}
return !window;
});
assert.ok(
missing.length > 0,
'★ 判据在「live 检查被抽掉」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西',
);
});
/*
handle 形状一致性判据(2026-10-04)。
# 症状
去重命中了(不再撞 flock),但下一步立刻炸:
会话 mail-515c0c70… 已在运行,直接 followup(不重复 resume)
new_mail 处理失败: Cannot read properties of undefined (reading 'followup')
# 根因:`ctx.agents.get()` 与 `startAgent()` 返回的**不是同一种东西**
ctx.agents.get(id) → **agent 本体**(有 .followup / .session)
startAgent() → handle,agent 在 **handle.agent**(.agent.followup)
证据在同文件 findLiveDshSession(约 1059 行)——它自己也要包一层:
const live = ctx.agents.get(bound.dshSessionId);
if (live) return { id: bound.dshSessionId, agent: live };
而去重那段把 `get()` 的返回值直接当 handle 用 ⇒ `handle.agent` 是 undefined
⇒ 一取 followup 就炸。
# 为什么单测抓不到
`startAgent` 要 dsh 宿主才能跑,纯函数测试够不着。而这个缺陷的形状是
「两个来源返回两种形状,被当成同一种」——纯看类型(都是 `any`)看不出来。
⇒ 判据钉**赋值点的形状**:从 `get()` 取到的东西,必须包成 `{ agent: … }`
才能当 handle 用。这一格就是当初该有、而没有的那一格。
# 顺带说明:接管路径为什么没炸
它写的是 `live.followup(...)`(直接用 agent 本体),**形状本来就对**。
两条路径拿的是同一个对象,只是用法不同 —— 一个包了一层、一个没包。
本判据不判断「哪种用法对」,只判断「从 get() 取出后有没有包」。
*/
test('★ 从 agents.get() 取到的东西必须包成 { agent } 才能当 handle 用', () => {
const src = liveNow();
// ★ 要**追变量来源**,不能只看赋值式本身:
// 实际代码是两步 —— `const alreadyLive = ctx.agents?.get?.(id)` 然后
// `handle = { agent: alreadyLive }`。只看 `handle = …` 那一行永远看不到
// `get(`,第一版这么写直接报「应至少有一处 handle = agents.get(...)」——
// 判据没看见它本该看的东西。
// ⇒ 两步:先把 `agents.get(…)` 的返回值绑到哪个变量,再看该变量被怎么用。
const getVar = src.match(/const\s+([A-Za-z_][\w]*)\s*=\s*ctx\.agents\?\.get\?\./);
assert.ok(getVar, '应有一处「从 agents.get() 取值并绑定到变量」');
const assigns = [...src.matchAll(/handle\s*=\s*([^;\n]+);/g)].map((m) => m[1].trim());
const fromGet = assigns.filter((a) => a.includes(getVar[1]));
assert.ok(fromGet.length >= 1, `应至少有一处 handle 用到 ${getVar[1]}`);
const bad = fromGet.filter((a) => !/^\{[^}]*\bagent\s*:/.test(a));
assert.deepEqual(
bad,
[],
'★ 这些地方把 agents.get() 的返回值直接当 handle 用:' + bad.join(' | ') +
'\\n ⇒ get() 返回 agent 本体,而 handle 上才有 .agent ⇒' +
' handle.agent 是 undefined ⇒ 一取 followup 就报' +
'「Cannot read properties of undefined」。(实测踩过)',
);
});
test('判据自检:去掉 { agent } 包装必须能红', () => {
const src = liveNow();
const broken = src.replace(/handle\s*=\s*\{\s*agent:\s*([a-zA-Z_][\w]*)\s*\s*\};/,
'handle = $1;');
assert.notEqual(broken, src, '变异必须真的改到源码(否则下面的转红是假的)');
const getVar = broken.match(/const\s+([A-Za-z_][\w]*)\s*=\s*ctx\.agents\?\.get\?\./);
const assigns = [...broken.matchAll(/handle\s*=\s*([^;\n]+);/g)].map((m) => m[1].trim());
const fromGet = assigns.filter((a) => a.includes(getVar[1]));
const bad = fromGet.filter((a) => !/^\{[^}]*\bagent\s*:/.test(a));
assert.ok(
bad.length > 0,
'★ 判据在「去掉 { agent } 包装」这个真实事故形状下仍判绿 —— 它抓不到自己要抓的东西',
);
});