#!/usr/bin/env node /** * 验证容器 typing 信号时序(2026-11-11) * * 要回答两个问题: * ① adapter 自带的 typing 循环是否已关闭(配置 typing_indicator: false) * ② typing=false 是否**晚于**最后一条 agent 消息 * * 🔴 必须用 /sync 监听:`m.typing` 是 ephemeral 事件,**不写进 timeline**, * 用 /messages 读永远读不到(2026-11-11 踩过这个坑,误判过一次)。 * * 用法:node scripts/verify-typing-order.mjs */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; const F = 'https://server.fmode.cn/api/functions'; const TARGET = 'agent-node-lj-test'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const tok = JSON.parse(readFileSync(join(homedir(), '.fmode/config/user.json'), 'utf8')).sessionToken; async function prov(id) { const r = await fetch(F, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: 'RP5Ht55NeX', token: tok, params: { agentId: id } }), }); const j = await r.json(); const d = j.result ?? j; if (!d?.accessToken) throw new Error('provision 失败'); return d; } const p = await prov(TARGET); const a = await prov(TARGET); const mx = await import('matrix-js-sdk'); const c = mx.createClient({ baseUrl: p.homeserver, accessToken: p.accessToken, userId: p.userMxId }); await c.startClient({ initialSyncLimit: 1 }); await sleep(3000); const hs = p.homeserver.replace(/\/$/, ''); const auth = { Authorization: `Bearer ${p.accessToken}` }; const room = ( await c.createRoom({ invite: [a.agentMxId], name: `typing-order-${Date.now()}`, preset: mx.Preset.TrustedPrivateChat, }) ).room_id; for (let i = 0; i < 30; i++) { await sleep(2000); const r = await fetch(`${hs}/_matrix/client/v3/rooms/${encodeURIComponent(room)}/joined_members`, { headers: auth, }).catch(() => null); const j = r ? await r.json().catch(() => ({})) : {}; if ((j.joined ?? {})[a.agentMxId]) break; } const events = []; const T0 = Date.now(); const rel = (t) => (t - T0) / 1000; c.on('RoomMember.typing', (ev, m) => { if (m?.roomId !== room) return; events.push({ t: Date.now(), kind: 'typing', value: m.typing }); }); c.on('Room.timeline', (ev, rm) => { if (rm?.roomId !== room) return; const type = ev.getType(); if (type === 'm.reaction' && ev.getSender() === a.agentMxId) { const key = ev.getContent()?.['m.relates_to']?.key ?? ''; events.push({ t: Date.now(), kind: 'reaction', value: key }); } if (type === 'm.room.message' && ev.getSender() === a.agentMxId) { events.push({ t: Date.now(), kind: 'msg', value: String(ev.getContent()?.body ?? '').slice(0, 40) }); } }); await c.sendEvent(room, 'm.room.message', { msgtype: 'm.text', body: '用一句话回答:3 乘以 7 等于多少?不要调用工具。', 'm.mentions': { user_ids: [a.agentMxId] }, }); console.log('已发送,监听 100 秒…\n'); await sleep(100_000); console.log('=== 事件时序(数字 = 距发送的秒数)==='); for (const e of events) { const tag = e.kind === 'typing' ? `typing=${e.value}` : e.kind === 'reaction' ? `reaction ${e.value}` : `消息「${e.value}」`; console.log(` +${rel(e.t).toFixed(1)}s ${tag}`); } const msgs = events.filter((e) => e.kind === 'msg'); const lastMsg = msgs.at(-1); const typingFalse = events.filter((e) => e.kind === 'typing' && e.value === false); const typingTrue = events.filter((e) => e.kind === 'typing' && e.value === true); console.log('\n=== 判定 ==='); console.log(`typing=true ${typingTrue.length} 次`); console.log(`typing=false ${typingFalse.length} 次`); console.log(`agent 消息 ${msgs.length} 条`); if (lastMsg && typingFalse.length) { const lastFalse = typingFalse.at(-1); const diff = rel(lastFalse.t) - rel(lastMsg.t); console.log(`\n最后一条消息在 +${rel(lastMsg.t).toFixed(1)}s,最后一个 typing=false 在 +${rel(lastFalse.t).toFixed(1)}s`); console.log(diff >= 0 ? `✅ false 晚于末条消息 ${diff.toFixed(1)}s(正确)` : `❌ false 早于末条消息 ${(-diff).toFixed(1)}s(仍然抢跑)`); } else if (msgs.length && !typingFalse.length) { console.log('\n⚠️ 没有任何 typing=false —— 插件/adapter 都没发结束信号'); } else { console.log('\n⚠️ 数据不足,无法判定'); } const rej = events.filter((e) => e.kind === 'reaction'); console.log(`\n另:✅/❌ reaction ${rej.length} 次 ${rej.length ? '(这本身也是可靠的结束信号)' : ''}`); process.exit(0);