fix(dsh)★★: 事件数据源字段名错 —— session.events 不存在,真实是 session.log
## 用户报「dsh 插件还是没有正确回复邮件」(第二版)
519bdf7 改了 `lastAssistantText` 的**解析逻辑**(从后往前找带 text 的那条),
生产仍复现。**解析逻辑本来就是对的**,错的是**数据源字段名**。
## 根因(运行时实测,不是推断)
在 idle 回调里打印 agent/session 的实际键表,得到:
session 键:[log, surfaceManager, header, inheritedEventCount,
firstLiveSeq, firstLifecycleSeq, eventsSnapshot,
headerFold, headerFoldSeq, contextFold, contextFoldSeq,
toolHistoryProjection, toolHistorySeq, derived, …]
**没有 `events`。** ⇒ `agent.session?.events ?? []` **永远**兜成空数组
⇒ 每一次都判「空回复」⇒ 给人类发件人发「处理失败」通知。
真正的事件序列是 **`log`**:实测 `Array(26)`、末条 `type=turn/end`、
键 `[type,seq,time,data]` —— 与磁盘 `session.v4.jsonl.zstd` 逐字一致
(磁盘 27 条 / 内存 26 条,差 1 条是 idle 瞬间最后一条尚未写入)。
★ `eventsSnapshot` 名字最像,**实测是 null** —— 照名字写代码会第二次踩坑。
## 顺带修掉第二处同源缺陷
`modelTitle(a?.session?.events ?? [])`(会话标题同步)**也是同一个错字段**,
所以会话标题一直同步不上(静默失败)。
两处曾各写各的、修一处漏一处 ⇒ 收敛为单一函数 `sessionEvents()`,
判据禁止任何地方绕过它。
## 被观测否定的两个推断(都记下来)
1. **「idle 时最后一条事件还没落入内存」** ⇒ 我为此加了 3×250ms 重试
(现已保留,成本极低且无害)。观测 `events=0`(重试 750ms 后**仍为 0**)
直接否定:不是没到,是**压根没有那个字段**。
2. **「根因是 resume 路径」** ⇒ 新会话与 resume 两次都「正常」,因为 dsh
都主动 `send_mail` 而在 `shouldSkipAutoRelay` 早退,压根没走到取文本那段。
## 判据(新增 4 格,含接线层)
纯函数测试抓不到这一类缺陷(`lastAssistantText` 的单元测试全绿)。
新增 `test/session-events-source.test.mjs` 钉住**调用点**:
* 辅助函数必须存在且 **log 排在 events 之前**(判**顺序**不是判存在 ——
`events ?? log` 也含 `.session?.log`,用 `includes` 会免疫)
* 任何读会话事件的地方都必须走 `sessionEvents()`,豁免**按位置**判定
(用 `helperBody.includes(frag)` 豁免会放过代码里**任何位置**的同名字段)
* 回退链保留 `events`(万一是更老的运行时,不比原来更差)
* 自检:合成坏源码必须判红
★ 这份判据自身也踩了两次「对变异免疫」:
`const code = readFileSync()` 模块级缓存、以及上面那个 includes 豁免。
两处都改成**每次现读 / 按位置判定**后才真正有区分力。
**变异验证**:辅助函数改回 events 优先 → 红;把调用点改回直接读字段 → 红。
## 验证状态
dsh 440 格全绿 · tsc 零错 · 部署成功(current → 20261004-080331)· 心跳正常。
⚠ **最终行为验证未完成**:需要一次真人发信(`from_human=true`)才会走到
自动转发那段。我的所有身份都是 Agent,会被「不自动转发」早退。
判空时的 `[diag]` 行已长期保留 —— 下次复现可直接看出是「没有文本」还是「没有事件」。
This commit is contained in:
@ -499,7 +499,7 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
return live.map((a: any) => ({
|
||||
id: String(a?.id ?? ''),
|
||||
cwd: a?.session?.header?.cwd ?? '',
|
||||
title: modelTitle(a?.session?.events ?? []),
|
||||
title: modelTitle(sessionEvents(a)),
|
||||
updatedAt: a?.session?.header?.createdAt,
|
||||
origin: a?.session?.header?.origin,
|
||||
delegationDepth: a?.session?.header?.delegationDepth,
|
||||
@ -1030,6 +1030,29 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
return `${path}${sep}session_id=${encodeURIComponent(sid)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 DSH 会话的事件序列。
|
||||
*
|
||||
* ★★ 2026-10-04 实测定位(两处同类缺陷的共同根因):
|
||||
* dsh 0.2.0-rc.2 的 session 对象**没有 `events` 字段**。实测键表:
|
||||
* [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
|
||||
* firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
|
||||
* contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …]
|
||||
* 真正的事件序列是 **`log`**(实测 Array(26),末条 type=turn/end、键
|
||||
* [type,seq,time,data],与磁盘 session.v4.jsonl.zstd 逐字一致)。
|
||||
* `eventsSnapshot` 名字像但实测是 **null**。
|
||||
*
|
||||
* ⇒ `session?.events ?? []` **永远**兜成空数组。生产后果:
|
||||
* ① 自动转发取不到 assistant 文本 ⇒ 每封人类来信都被发「处理失败:空回复」
|
||||
* ② 会话标题同步取不到 ⇒ title 永远空
|
||||
*
|
||||
* 为什么要有这个函数而不是就地写:这两处曾经**各写各的**(一处已修、另一处还错着),
|
||||
* 而纯函数测试抓不到「调用点用了哪个字段」。集中一处 + 下方判据钉住调用点。
|
||||
*/
|
||||
function sessionEvents(agentLike: any): any[] {
|
||||
return agentLike?.session?.log ?? agentLike?.session?.events ?? [];
|
||||
}
|
||||
|
||||
function findLiveDshSession(mailSessionID: string): { id: string; agent: any } | undefined {
|
||||
const bound = sessionMap.peek(mailSessionID);
|
||||
if (bound) {
|
||||
@ -1935,8 +1958,31 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
// ⇒ 修法:**idle 时取不到文本就稍等再取**,而不是当场判失败。
|
||||
// events 是会补上的(磁盘已有 ⇒ 内存最终也会有),重试即可命中。
|
||||
// 下面的诊断日志会打出重试前后的 events 长度,验证这个推断。
|
||||
// events 这个绑定后面还要用(relay_key 与 modelTitle),故保留。
|
||||
const events = (): any[] => agent.session?.events ?? [];
|
||||
// ★★ 2026-10-04 实测定位(用户报「dsh 还是不回信」,生产复现两次):
|
||||
//
|
||||
// 根因:**`agent.session.events` 这个字段不存在**。dsh 0.2.0-rc.2 的 session
|
||||
// 实测键表(由本插件在 idle 时打印):
|
||||
// [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
|
||||
// firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
|
||||
// contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq,
|
||||
// derived, derivedNodes, derivedGeneration]
|
||||
// 没有 `events`。⇒ `agent.session?.events ?? []` **永远**兜成空数组
|
||||
// ⇒ 每一次都判「空回复」⇒ 给人类发件人发「处理失败」通知。
|
||||
//
|
||||
// 真正的事件序列在 **`session.log`**(实测 Array(26),末条 type=turn/end、
|
||||
// 键 [type,seq,time,data] —— 与磁盘 session.v4.jsonl.zstd 的结构逐字一致,
|
||||
// 磁盘 27 条 / 内存 26 条,差 1 条是 idle 瞬间最后一条尚未写入)。
|
||||
// `eventsSnapshot` 名字像但实测是 **null**,别被它骗。
|
||||
//
|
||||
// ⇒ 本行改为读 `session.log`。同时保留 events 作为回退:万一是更老的
|
||||
// 运行时(字段名不同),至少不会比原来更差。
|
||||
//
|
||||
// ★ 为什么前面的 519bdf7 没能修好:那一版改的是 `lastAssistantText` 的
|
||||
// **解析逻辑**(从后往前找带 text 的那条),而这里错的是**数据源字段名** ——
|
||||
// 解析逻辑本来就是对的。中间我还误判成「idle 时事件还没到」并加了重试,
|
||||
// 那是错的:不是没到,是压根没有那个字段。观测 events=0(重试 750ms 后仍为 0)
|
||||
// 直接否定了时间假设。
|
||||
const events = (): any[] => sessionEvents(agent);
|
||||
const readAssistantText = (): string => lastAssistantText(events());
|
||||
let lastText = readAssistantText();
|
||||
let retryNote = '';
|
||||
@ -1949,16 +1995,19 @@ export function apply(ctx: any, config: PluginConfig): void {
|
||||
if (lastText) retryNote = ` (第 ${attempt} 次重试才拿到文本)`;
|
||||
}
|
||||
}
|
||||
{
|
||||
const evs = agent.session?.events ?? [];
|
||||
if (!lastText) {
|
||||
// 长期观测(保留):判空时打出事件序列的实况,便于下次直接看出是「没有文本」
|
||||
// 还是「没有事件」。2026-10-04 那次根因就是靠它才定案的 —— 之前只有
|
||||
// 「已发空回复失败通知」这一行,看不出到底是取不到还是取到空。
|
||||
const evs = events();
|
||||
const tail = evs.slice(-4).map((e: any) => {
|
||||
const c = (e as any)?.data?.message?.content;
|
||||
const kinds = Array.isArray(c) ? c.map((b: any) => b?.type).join('+') : typeof c;
|
||||
return `${(e as any)?.type ?? '?'}(${kinds})`;
|
||||
});
|
||||
console.error(
|
||||
`[dsh-mail-bridge] [diag] events=${evs.length} 末尾=[${tail.join(' ')}] ` +
|
||||
`hasText=${lastText.length > 0}${retryNote}`,
|
||||
`[dsh-mail-bridge] [diag] 无文本:events=${evs.length} ` +
|
||||
`末尾=[${tail.join(' ')}]${retryNote}`,
|
||||
);
|
||||
}
|
||||
if (!lastText) {
|
||||
|
||||
@ -189,6 +189,45 @@ test('lastAssistantText 只有 tool-call 时返回空串(没有可回信的内
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* ★★ 2026-10-04 实测回归:**事件不在 `session.events`,而在 `session.log`**
|
||||
*
|
||||
* 现象:模型明明写了完整回信(磁盘 session.v4.jsonl.zstd 里
|
||||
* `assistant/message 块=['text']`),插件却每次都发「处理失败:空回复」。
|
||||
*
|
||||
* 根因:dsh 0.2.0-rc.2 的 session 对象实测键表为
|
||||
* [log, surfaceManager, header, inheritedEventCount, firstLiveSeq,
|
||||
* firstLifecycleSeq, eventsSnapshot, headerFold, headerFoldSeq,
|
||||
* contextFold, contextFoldSeq, toolHistoryProjection, toolHistorySeq, …]
|
||||
* **没有 `events`** ⇒ `agent.session?.events ?? []` 永远兜成空数组。
|
||||
* 注意 `eventsSnapshot` 名字像但实测是 **null**。
|
||||
*
|
||||
* ⇒ 这格钉住「从 session.log 取得到、从 session.events 取不到」,
|
||||
* 防止数据源再漂回那个不存在的字段。
|
||||
*/
|
||||
test('★ 数据源是 session.log(session.events 不存在 ⇒ 旧写法必然取空)', () => {
|
||||
const log = [
|
||||
{ type: 'step/start', seq: 1, time: 'now', data: {} },
|
||||
assistantMsg([{ type: 'tool-call', id: 'c1', name: 'read_mail', arguments: '{}' }]),
|
||||
assistantMsg([{ type: 'text', text: '这是模型写的回信文本' }]),
|
||||
{ type: 'turn/end', seq: 4, time: 'now', data: {} },
|
||||
];
|
||||
const realSession = { log, eventsSnapshot: null }; // ← dsh 0.2 的真实形状
|
||||
|
||||
// 复现旧写法的后果(这就是生产上「模型回了信却报空回复」的形状)
|
||||
assert.equal(
|
||||
lastAssistantText(realSession.events ?? []),
|
||||
'',
|
||||
'★ session.events 不存在 ⇒ 旧写法必然取空',
|
||||
);
|
||||
// 修法:从 log 取
|
||||
assert.equal(
|
||||
lastAssistantText(realSession.log ?? realSession.events ?? []),
|
||||
'这是模型写的回信文本',
|
||||
'★ 必须能从 session.log 取到 assistant 文本',
|
||||
);
|
||||
});
|
||||
|
||||
test('lastAssistantText 容错:空日志、非数组、结构缺失', () => {
|
||||
assert.equal(lastAssistantText([]), '');
|
||||
assert.equal(lastAssistantText(undefined), '');
|
||||
|
||||
164
plugins/dsh-mail-bridge/test/session-events-source.test.mjs
Normal file
164
plugins/dsh-mail-bridge/test/session-events-source.test.mjs
Normal file
@ -0,0 +1,164 @@
|
||||
/*
|
||||
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」这个真实事故形状下仍然判绿 —— ' +
|
||||
'它抓不到自己要抓的东西,等于没有',
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user