541 lines
24 KiB
JavaScript
541 lines
24 KiB
JavaScript
// IM agent 接入回归测试(node:test)—— C 单 `交接单/IM群组-C-Agent接入插件.md` §六 ∪ §六.1。
|
||||
|
|
//
|
|||
|
|
// 跑的是**构建产物** `lib/`(`npm test` 先 build)。判据编号直接写在用例名里 ⇒
|
|||
|
|
// 「哪条验收对哪个用例」一眼可查,第三方可复现。
|
|||
|
|
//
|
|||
|
|
// 覆盖面:凭据 200/401/不注入不启动(判据 1)· 只拨出无监听(判据 2/10)· 防自激(判据 3/9)·
|
|||
|
|
// 上下文含窗口内非 @ 那条(判据 4)· 可见性(判据 4)· 四条硬约束(判据 5/9)·
|
|||
|
|
// 代答标识 author/via(判据 6)· 故障隔离(判据 8)· 区判据(§〇-4③)。
|
|||
|
|
//
|
|||
|
|
// ⚠️ 本文件**不碰网络、不碰 DB**:所有判据都断 `lib/im/agent-bridge.js` 与
|
|||
|
|
// `lib/im/instance-token.js` 的纯逻辑,以及插件包 `poc/im-agent-bridge/lib/index.js`
|
|||
|
|
// 的静态形状(无 `listen(`)。端到端(真起 socket)由 B 单的验收脚本覆盖。
|
|||
|
|
|
|||
|
|
import { test } from 'node:test'
|
|||
|
|
import assert from 'node:assert/strict'
|
|||
|
|
import { readFileSync } from 'node:fs'
|
|||
|
|
import { fileURLToPath } from 'node:url'
|
|||
|
|
import { dirname, join } from 'node:path'
|
|||
|
|
import {
|
|||
|
|
AGENT_CONTEXT_MAX_MESSAGES,
|
|||
|
|
AGENT_CONTEXT_WINDOW_MS,
|
|||
|
|
RoomRateLimiter,
|
|||
|
|
buildAgentContext,
|
|||
|
|
mentionsOf,
|
|||
|
|
replyMarkerOf,
|
|||
|
|
replyPayloadOf,
|
|||
|
|
shouldTrigger,
|
|||
|
|
visibleTo,
|
|||
|
|
} from '../lib/im/agent-bridge.js'
|
|||
|
|
import {
|
|||
|
|
IM_INSTANCE_TOKEN_BYTES,
|
|||
|
|
ImInstanceTokenRegistry,
|
|||
|
|
INSTANCE_TOKEN_REASONS,
|
|||
|
|
looksLikeImInstanceToken,
|
|||
|
|
} from '../lib/im/instance-token.js'
|
|||
|
|
import { IM_MAX_CONN_PER_USER } from '../lib/im/hub.js'
|
|||
|
|
|
|||
|
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|||
|
|
const REPO = join(HERE, '..')
|
|||
|
|
const PLUGIN_ENTRY = join(REPO, 'poc/im-agent-bridge/lib/index.js')
|
|||
|
|
|
|||
|
|
/** Windows 上 `import()` 必须用 file:// URL(⛔ 不能传 `D:\...` 裸路径)。 */
|
|||
|
|
function pathToFileUrl(p) {
|
|||
|
|
return `file:///${p.replace(/\\/g, '/')}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 夹具 ────────────────────────────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
/** 造一条消息(形状 = A 单 `MessageEnvelope`)。 */
|
|||
|
|
function msg(seq, over = {}) {
|
|||
|
|
return {
|
|||
|
|
id: `h${seq}`,
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
seq,
|
|||
|
|
ts: over.ts ?? 1_000_000 + seq,
|
|||
|
|
authorId: over.authorId ?? 'u_alice',
|
|||
|
|
authorKind: over.authorKind ?? 'human',
|
|||
|
|
via: over.via ?? 'web',
|
|||
|
|
visibility: over.visibility ?? 'room',
|
|||
|
|
payload: over.payload ?? { text: `第 ${seq} 条` },
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 造一个成员(形状 = A 单 `RoomMember`)。 */
|
|||
|
|
function member(memberId, over = {}) {
|
|||
|
|
return {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
memberId,
|
|||
|
|
kind: over.kind ?? 'human',
|
|||
|
|
role: over.role ?? 'member',
|
|||
|
|
autoReply: over.autoReply ?? false,
|
|||
|
|
agentRef: over.agentRef ?? null,
|
|||
|
|
joinedAt: 0,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 判据 1:凭据 200 / 401 / 不注入则不启动 ─────────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 1 / §五-1:正确 token 通过校验', () => {
|
|||
|
|
const reg = new ImInstanceTokenRegistry()
|
|||
|
|
const { token, record } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
|
|||
|
|
assert.ok(looksLikeImInstanceToken(token), 'token 外形应为 43 字符 base64url')
|
|||
|
|
assert.ok(token.length >= Math.ceil((IM_INSTANCE_TOKEN_BYTES * 4) / 3) - 1)
|
|||
|
|
const v = reg.verify(token)
|
|||
|
|
assert.equal(v.ok, true)
|
|||
|
|
assert.equal(v.record.userId, 'u_alice', '必须能答出"这是谁的实例"(§〇-4②)')
|
|||
|
|
assert.equal(v.record.instanceId, 'inst_1')
|
|||
|
|
assert.equal(record.expiresAt > record.issuedAt, true)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1:错误 / 缺失 / 过期 token 一律拒绝(fail-closed,⛔ 不是放行)', () => {
|
|||
|
|
let clock = 1000
|
|||
|
|
const reg = new ImInstanceTokenRegistry({ now: () => clock })
|
|||
|
|
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', ttlMs: 500 })
|
|||
|
|
// 缺失
|
|||
|
|
assert.deepEqual(reg.verify(undefined), { ok: false, reason: 'missing' })
|
|||
|
|
assert.deepEqual(reg.verify(''), { ok: false, reason: 'missing' })
|
|||
|
|
// 外形不对(长度不对 ⇒ 不认,且⛔不走定长比较)
|
|||
|
|
assert.equal(reg.verify('short').ok, false)
|
|||
|
|
assert.equal(reg.verify('short').reason, 'unknown')
|
|||
|
|
// 未签发过的(合法外形)
|
|||
|
|
assert.equal(reg.verify('A'.repeat(43)).reason, 'unknown')
|
|||
|
|
// 有效期内 ⇒ 通过
|
|||
|
|
assert.equal(reg.verify(token).ok, true)
|
|||
|
|
// 时钟推过 TTL ⇒ 具名过期(⛔ 不是 unknown —— 客户端要能区分)
|
|||
|
|
clock = 1600
|
|||
|
|
const expired = reg.verify(token)
|
|||
|
|
assert.equal(expired.ok, false)
|
|||
|
|
assert.equal(expired.reason, 'expired')
|
|||
|
|
assert.equal(typeof INSTANCE_TOKEN_REASONS.expired, 'string')
|
|||
|
|
// sweepExpired 之后变成 unknown(登记簿不留过期行)
|
|||
|
|
assert.equal(reg.sweepExpired(), 1)
|
|||
|
|
assert.equal(reg.verify(token).reason, 'unknown')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1 / §〇-4③:跨区一律拒(本期单区)', () => {
|
|||
|
|
const reg = new ImInstanceTokenRegistry()
|
|||
|
|
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', region: 'cn-east' })
|
|||
|
|
// 不声明区 ⇒ 不触发区判据
|
|||
|
|
assert.equal(reg.verify(token).ok, true)
|
|||
|
|
// 声明同区 ⇒ 通过
|
|||
|
|
assert.equal(reg.verify(token, { region: 'cn-east' }).ok, true)
|
|||
|
|
// 声明异区 ⇒ 拒(具名)
|
|||
|
|
const bad = reg.verify(token, { region: 'cn-west' })
|
|||
|
|
assert.equal(bad.ok, false)
|
|||
|
|
assert.equal(bad.reason, 'region_mismatch')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1:实例重启即换 token(旧 token 失效)', () => {
|
|||
|
|
const reg = new ImInstanceTokenRegistry()
|
|||
|
|
const first = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
|
|||
|
|
assert.equal(reg.verify(first).ok, true)
|
|||
|
|
// 实例重启:先撤后签(编排器的动作,见 issueImInstanceToken)
|
|||
|
|
assert.equal(reg.revokeByInstance('inst_1'), 1)
|
|||
|
|
const second = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' }).token
|
|||
|
|
assert.equal(reg.verify(first).reason, 'unknown', '旧 token 必须失效')
|
|||
|
|
assert.equal(reg.verify(second).ok, true)
|
|||
|
|
assert.notEqual(first, second)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1 / §五-1:登记簿只存哈希(token 原文不落登记簿)', () => {
|
|||
|
|
const reg = new ImInstanceTokenRegistry()
|
|||
|
|
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice' })
|
|||
|
|
// 白盒:内部 Map 的键、以及整对象的字符串化里都不得出现 token 原文
|
|||
|
|
const keys = Object.keys(reg)
|
|||
|
|
assert.equal(keys.includes('byHash'), true, '内部表名变了 ⇒ 重新审计存储口径')
|
|||
|
|
assert.equal(JSON.stringify(reg).includes(token), false, '⛔ 登记簿里不得有 token 原文')
|
|||
|
|
assert.equal(reg.size(), 1)
|
|||
|
|
assert.equal(reg.sweepExpired(), 0, '未过期 ⇒ 清 0')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1:可达房间是"索引"而非授权判据(未覆盖 ⇒ 具名拒绝)', () => {
|
|||
|
|
const reg = new ImInstanceTokenRegistry()
|
|||
|
|
const { token } = reg.issue({ instanceId: 'inst_1', userId: 'u_alice', rooms: ['imr_a'] })
|
|||
|
|
assert.equal(reg.verify(token, { roomId: 'imr_a' }).ok, true)
|
|||
|
|
const bad = reg.verify(token, { roomId: 'imr_b' })
|
|||
|
|
assert.equal(bad.ok, false)
|
|||
|
|
assert.equal(bad.reason, 'not_in_room')
|
|||
|
|
// 补一个房(成员变更时调用方同步)
|
|||
|
|
assert.equal(reg.allowRoom('inst_1', 'imr_b'), true)
|
|||
|
|
assert.equal(reg.verify(token, { roomId: 'imr_b' }).ok, true)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 3:防自激(硬约束 1)──────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 3 / §五-3:agent 的消息**不触发**任何 agent', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [
|
|||
|
|
member('u_alice'),
|
|||
|
|
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
|
|||
|
|
],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
// bot 自己发的消息 @ 了 bot_a ⇒ 仍不触发
|
|||
|
|
const fromBot = msg(1, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a 你好' } })
|
|||
|
|
const d = shouldTrigger(fromBot, snapshot)
|
|||
|
|
assert.equal(d.trigger, false)
|
|||
|
|
assert.equal(d.reason, 'not-human-author')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 3 / §五-3:human 的 @bot ⇒ 恰好触发 1 次(形态 A)', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
const d = shouldTrigger(msg(2, { payload: { text: '@bot_a 帮我看下' } }), snapshot)
|
|||
|
|
assert.equal(d.trigger, true)
|
|||
|
|
assert.equal(d.form, 'bot')
|
|||
|
|
assert.equal(d.memberId, 'bot_a')
|
|||
|
|
assert.equal(d.agentRef, 'agent_a')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 3 / §五-3:无 @ ⇒ 不触发;结构性 mentions 优先于文本扫描', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
assert.equal(shouldTrigger(msg(3, { payload: { text: '随便说说' } }), snapshot).reason, 'no-mention')
|
|||
|
|
// 结构化 mentions:文本里没 @,也应触发
|
|||
|
|
const structured = msg(4, { payload: { text: '帮我看下', mentions: ['bot_a'] } })
|
|||
|
|
assert.equal(shouldTrigger(structured, snapshot).trigger, true)
|
|||
|
|
// mentions 为空数组 ⇒ 不回落到文本(显式声明了"没有 @")
|
|||
|
|
assert.equal(mentionsOf({ text: '@x', mentions: [] }).length, 0)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 3:形态 B(组员代理)──────────────────────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 3 / §五-3:形态 B 在 autoReply=0 时**不触发**', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: false })],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
const d = shouldTrigger(msg(5, { payload: { text: '@u_alice 在吗' } }), snapshot)
|
|||
|
|
assert.equal(d.trigger, false)
|
|||
|
|
assert.equal(d.reason, 'proxy-autoreply-off')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 3 / §四-1:形态 B 触发时 memberId = **组员本人**(代理不占位)', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true })],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
const d = shouldTrigger(msg(6, { payload: { text: '@u_alice 帮我总结下' } }), snapshot)
|
|||
|
|
assert.equal(d.trigger, true)
|
|||
|
|
assert.equal(d.form, 'proxy')
|
|||
|
|
assert.equal(d.memberId, 'u_alice', '形态 B 的成员落点 = 组员本人')
|
|||
|
|
assert.equal(d.agentRef, 'agent_alice')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 3:开启 agent 互相 @ 时受跳数上限约束(默认关闭)', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: [],
|
|||
|
|
}
|
|||
|
|
const fromBot = msg(7, { authorKind: 'bot', authorId: 'bot_a', payload: { text: '@bot_a hi' } })
|
|||
|
|
// 默认关 ⇒ 不触发
|
|||
|
|
assert.equal(shouldTrigger(fromBot, snapshot).trigger, false)
|
|||
|
|
// 开启且 hop=0 ⇒ 触发
|
|||
|
|
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 0 }).trigger, true)
|
|||
|
|
// 达到跳数上限 ⇒ 停(切断自激环)
|
|||
|
|
assert.equal(shouldTrigger(fromBot, snapshot, { allowAgentToAgent: true, hopLimit: 2, hop: 2 }).trigger, false)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 4:上下文(窗口内非 @ 那条 + 可见性)───────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 4 / §五-4①:能引用**窗口内、但没有 @ 它**的消息', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: [
|
|||
|
|
msg(1, { payload: { text: '项目代号是 ALPHA' } }), // ← 无 @,但窗口内
|
|||
|
|
msg(2, { payload: { text: '排期在周五' } }),
|
|||
|
|
msg(3, { payload: { text: '@bot_a 代号叫什么来着?' } }), // ← 被 @ 那条
|
|||
|
|
],
|
|||
|
|
meta: { name: '测试房', roomType: 'office' },
|
|||
|
|
}
|
|||
|
|
const ctx = buildAgentContext({
|
|||
|
|
snapshot,
|
|||
|
|
memberId: 'bot_a',
|
|||
|
|
agentRef: 'agent_a',
|
|||
|
|
form: 'bot',
|
|||
|
|
viewerId: 'bot_a',
|
|||
|
|
focusSeq: 3,
|
|||
|
|
now: 1_000_003,
|
|||
|
|
})
|
|||
|
|
assert.equal(ctx.focus?.seq, 3, 'focus 必须是在 @ 那条')
|
|||
|
|
const windowSeqs = ctx.window.map((m) => m.seq)
|
|||
|
|
assert.equal(windowSeqs.includes(1), true, '窗口内非 @ 那条必须在上下文里(否则判据 4 失败)')
|
|||
|
|
assert.equal(ctx.prompt.includes('ALPHA'), true, 'prompt 必须真的带上那条内容')
|
|||
|
|
assert.equal(ctx.prompt.includes('排期在周五'), true)
|
|||
|
|
assert.equal(ctx.room.members, 2)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 4 / §四-3:被 @ 那条若落在窗口外 ⇒ **显式补回**(不变量 ①)', () => {
|
|||
|
|
const many = []
|
|||
|
|
for (let i = 1; i <= 60; i += 1) many.push(msg(i, { ts: 1_000_000 + i }))
|
|||
|
|
// 被 @ 的是**最老**那条(第 1 条),必然被"最近 50 条"裁掉
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: many,
|
|||
|
|
meta: {},
|
|||
|
|
}
|
|||
|
|
const ctx = buildAgentContext({
|
|||
|
|
snapshot,
|
|||
|
|
memberId: 'bot_a',
|
|||
|
|
agentRef: 'agent_a',
|
|||
|
|
form: 'bot',
|
|||
|
|
viewerId: 'bot_a',
|
|||
|
|
focusSeq: 1,
|
|||
|
|
now: 1_000_060,
|
|||
|
|
})
|
|||
|
|
assert.equal(ctx.focus?.seq, 1, 'focus 必须还在')
|
|||
|
|
assert.equal(ctx.window.length, AGENT_CONTEXT_MAX_MESSAGES + 1, '窗口 50 + 补回 1')
|
|||
|
|
assert.equal(ctx.window.map((m) => m.seq).includes(1), true)
|
|||
|
|
// 且窗口仍按 seq 严格递增(补回后要重排)
|
|||
|
|
const seqs = ctx.window.map((m) => m.seq)
|
|||
|
|
assert.deepEqual(seqs, [...seqs].sort((a, b) => a - b))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 4 / §四-6:上下文窗口 = 最近 50 条或最近 10 分钟,**取小**', () => {
|
|||
|
|
const old = msg(1, { ts: 0, payload: { text: '十分钟前的旧话' } }) // 远超时间窗
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice'), member('bot_a', { kind: 'bot', agentRef: 'agent_a' })],
|
|||
|
|
messages: [old, msg(2, { ts: 1_000_000 }), msg(3, { ts: 1_000_001 })],
|
|||
|
|
meta: {},
|
|||
|
|
}
|
|||
|
|
const now = 1_000_001
|
|||
|
|
const ctx = buildAgentContext({
|
|||
|
|
snapshot,
|
|||
|
|
memberId: 'bot_a',
|
|||
|
|
agentRef: 'agent_a',
|
|||
|
|
form: 'bot',
|
|||
|
|
viewerId: 'bot_a',
|
|||
|
|
focusSeq: 3,
|
|||
|
|
now,
|
|||
|
|
})
|
|||
|
|
// 旧话被时间窗排除 ⇒ 但因为它**可见且落窗** ⇒ 进 folded 摘要(⛔ 不是硬截断)
|
|||
|
|
assert.equal(ctx.window.map((m) => m.seq).includes(1), false, '旧话不进窗口')
|
|||
|
|
assert.equal(ctx.folded.length, 1, '旧话应折叠为摘要')
|
|||
|
|
assert.equal(ctx.prompt.includes('更早消息摘要'), true)
|
|||
|
|
assert.equal(AGENT_CONTEXT_WINDOW_MS, 10 * 60 * 1000)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 4 / §五-4②:**越权内容不出现**(可见性按接收者判)', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [
|
|||
|
|
member('u_alice'),
|
|||
|
|
member('u_bob'),
|
|||
|
|
member('bot_a', { kind: 'bot', agentRef: 'agent_a' }),
|
|||
|
|
],
|
|||
|
|
messages: [
|
|||
|
|
msg(1, { authorId: 'u_bob', payload: { text: '公开情报' } }),
|
|||
|
|
// bob 与 alice 的私聊(visibility=private,作者 bob)⇒ bot_a 无权看到
|
|||
|
|
msg(2, { authorId: 'u_bob', visibility: 'private', payload: { text: '机密:代号 OMEGA' } }),
|
|||
|
|
msg(3, { payload: { text: '@bot_a 有啥情报' } }),
|
|||
|
|
],
|
|||
|
|
meta: {},
|
|||
|
|
}
|
|||
|
|
const ctx = buildAgentContext({
|
|||
|
|
snapshot,
|
|||
|
|
memberId: 'bot_a',
|
|||
|
|
agentRef: 'agent_a',
|
|||
|
|
form: 'bot',
|
|||
|
|
viewerId: 'bot_a',
|
|||
|
|
focusSeq: 3,
|
|||
|
|
now: 1_000_003,
|
|||
|
|
})
|
|||
|
|
assert.equal(ctx.prompt.includes('机密'), false, '越权内容不得出现(判据 4 最硬的一条)')
|
|||
|
|
assert.equal(ctx.prompt.includes('公开情报'), true)
|
|||
|
|
assert.equal(visibleTo({ visibility: 'private', authorId: 'u_bob' }, 'bot_a'), false)
|
|||
|
|
assert.equal(visibleTo({ visibility: 'private', authorId: 'bot_a' }, 'bot_a'), true, '自己的私聊可见')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 4:形态 B 的上下文以**组员本人**为可见性主体(§五-6 一致)', () => {
|
|||
|
|
const snapshot = {
|
|||
|
|
roomId: 'imr_test',
|
|||
|
|
members: [member('u_alice', { agentRef: 'agent_alice', autoReply: true }), member('u_bob')],
|
|||
|
|
messages: [
|
|||
|
|
// alice 自己的私聊 ⇒ 代答 agent 应能看到(因为它代表 alice)
|
|||
|
|
msg(1, { authorId: 'u_alice', visibility: 'private', payload: { text: '我的私人备注' } }),
|
|||
|
|
msg(2, { authorId: 'u_bob', payload: { text: '@u_alice 看下' } }),
|
|||
|
|
],
|
|||
|
|
meta: {},
|
|||
|
|
}
|
|||
|
|
const ctx = buildAgentContext({
|
|||
|
|
snapshot,
|
|||
|
|
memberId: 'u_alice',
|
|||
|
|
agentRef: 'agent_alice',
|
|||
|
|
form: 'proxy',
|
|||
|
|
viewerId: 'u_alice', // 🔴 以组员本人为可见性主体
|
|||
|
|
focusSeq: 2,
|
|||
|
|
now: 1_000_002,
|
|||
|
|
})
|
|||
|
|
assert.equal(ctx.prompt.includes('我的私人备注'), true, '代表 alice ⇒ 能看 alice 自己的私聊')
|
|||
|
|
assert.equal(ctx.role.includes('代'), true, '形态 B 的职责说明应点出"代答"')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 5 / 9:四条硬约束 + 房间级限速排队 ─────────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 5:房间级限速 —— 超限**排队**(⛔ 不丢消息)', () => {
|
|||
|
|
let clock = 0
|
|||
|
|
const limiter = new RoomRateLimiter(2, 1000, () => clock)
|
|||
|
|
assert.deepEqual(limiter.request('imr_a', 'k1'), { admit: true })
|
|||
|
|
assert.deepEqual(limiter.request('imr_a', 'k2'), { admit: true })
|
|||
|
|
const q3 = limiter.request('imr_a', 'k3')
|
|||
|
|
assert.equal(q3.admit, false)
|
|||
|
|
assert.equal(q3.position, 1, '排队位置按到达序')
|
|||
|
|
const q4 = limiter.request('imr_a', 'k4')
|
|||
|
|
assert.equal(q4.position, 2)
|
|||
|
|
// 重复 key 不重复排队(幂等)
|
|||
|
|
assert.equal(limiter.request('imr_a', 'k3').position, 1)
|
|||
|
|
assert.equal(limiter.state('imr_a').queued, 2)
|
|||
|
|
// 翻窗后按序放行
|
|||
|
|
clock = 1001
|
|||
|
|
assert.deepEqual(limiter.drain('imr_a', 5), ['k3', 'k4'])
|
|||
|
|
assert.equal(limiter.state('imr_a').queued, 0)
|
|||
|
|
assert.equal(limiter.state('imr_a').used, 2)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 5:房间级限速是**按房**隔离的(一个房刷屏不拖累另一个)', () => {
|
|||
|
|
const limiter = new RoomRateLimiter(1, 60_000)
|
|||
|
|
assert.equal(limiter.request('imr_a', 'a1').admit, true)
|
|||
|
|
assert.equal(limiter.request('imr_a', 'a2').admit, false)
|
|||
|
|
assert.equal(limiter.request('imr_b', 'b1').admit, true, '另一个房不受影响')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 6 / §五-6:代答标识 —— 形态 B 的 author 指向组员本人且 via=agent', () => {
|
|||
|
|
const proxy = replyMarkerOf({ form: 'proxy', memberId: 'u_alice', ownerName: 'Alice' })
|
|||
|
|
assert.equal(proxy.authorId, 'u_alice', '形态 B 的 author 必须是组员本人')
|
|||
|
|
assert.equal(proxy.via, 'agent')
|
|||
|
|
assert.equal(proxy.label.includes('代答'), true, '标识必须可见(产品语义)')
|
|||
|
|
assert.equal(proxy.label.includes('Alice'), true)
|
|||
|
|
|
|||
|
|
const bot = replyMarkerOf({ form: 'bot', memberId: 'bot_a' })
|
|||
|
|
assert.equal(bot.authorId, 'bot_a', '形态 A 的 author 指向 bot 成员位')
|
|||
|
|
assert.equal(bot.via, 'agent')
|
|||
|
|
|
|||
|
|
// 回写 payload 带标识(UI 可直读,不必猜)
|
|||
|
|
const payload = replyPayloadOf('答案是 ALPHA', proxy)
|
|||
|
|
assert.equal(payload.text, '答案是 ALPHA')
|
|||
|
|
assert.equal(payload.agentReply, true)
|
|||
|
|
assert.equal(typeof payload.agentLabel, 'string')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 8 / §六-1-8:插件崩溃 ⇒ 只影响自己(判据落在"不抛"上)', async () => {
|
|||
|
|
// 载体:真加载插件包的纯函数面(不 apply ⇒ 不起连接)
|
|||
|
|
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
|
|||
|
|
// 默认 ask 实现是占位,必须**不抛**
|
|||
|
|
const out = await mod.defaultAsk('hello')
|
|||
|
|
assert.equal(typeof out, 'string')
|
|||
|
|
// 解析端点在坏输入下应抛(由 apply 捕获 ⇒ 不拖垮 profile);这里只确认它可解析正常输入
|
|||
|
|
const ep = mod.parseEndpoint('https://ai1net.com')
|
|||
|
|
assert.equal(ep.port, 443)
|
|||
|
|
assert.equal(ep.path, '/api/im/ws')
|
|||
|
|
assert.equal(ep.tls, true)
|
|||
|
|
const ep2 = mod.parseEndpoint('http://127.0.0.1:3080')
|
|||
|
|
assert.equal(ep2.port, 3080)
|
|||
|
|
assert.equal(ep2.tls, false)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 2 / 10:只拨出(无监听端口)──────────────────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 2 / 10 / E3:插件源码**不含任何 listen(**(只拨出)', () => {
|
|||
|
|
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
|
|||
|
|
// ⚠️ 只查真正的调用形态(`listen(` / `.listen (`),注释里提到这个词不算
|
|||
|
|
const callSites = src
|
|||
|
|
.split('\n')
|
|||
|
|
.filter((line) => !line.trim().startsWith('*') && !line.trim().startsWith('//'))
|
|||
|
|
.filter((line) => /\.listen\s*\(/.test(line) || /(^|[^.\w])listen\s*\(/.test(line))
|
|||
|
|
assert.deepEqual(callSites, [], `⛔ 不得有监听端口(E3 只拨出):\n${callSites.join('\n')}`)
|
|||
|
|
// 正向:必须真的有 connect 调用(否则"无 listen"是空断言)
|
|||
|
|
assert.equal(/connect\s*\(/.test(src), true, '必须有拨出(connect)')
|
|||
|
|
assert.equal(/createServer|createServer\s*\(/.test(src), false, '⛔ 不得建 server')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 2:插件不引任何外部依赖(无 import 第三方包)', () => {
|
|||
|
|
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
|
|||
|
|
assert.deepEqual(pkg.dependencies, {}, '⛔ C 单不引入新依赖')
|
|||
|
|
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
|
|||
|
|
const imports = [...src.matchAll(/from\s+['"]([^'"]+)['"]/g)].map((m) => m[1])
|
|||
|
|
assert.equal(imports.length > 0, true)
|
|||
|
|
for (const spec of imports) {
|
|||
|
|
assert.equal(spec.startsWith('node:') || spec.startsWith('.'), true, `⛔ 非内置/相对导入:${spec}`)
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 2 / §三:插件形态 = cordis.patch.yml + dsh.bundle.patch(⛔ 无 client 面)', () => {
|
|||
|
|
const pkg = JSON.parse(readFileSync(join(REPO, 'poc/im-agent-bridge/package.json'), 'utf8'))
|
|||
|
|
assert.equal(pkg.dsh.bundle.patch, './cordis.patch.yml')
|
|||
|
|
assert.equal(pkg.dsh.client, undefined, 'C 单是**服务端**接入,⛔ 不声明 client 面')
|
|||
|
|
const patch = readFileSync(join(REPO, 'poc/im-agent-bridge/cordis.patch.yml'), 'utf8')
|
|||
|
|
assert.equal(patch.includes('@dsh-local/im-agent-bridge'), true)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 1 / §三:env 是唯一开关(不注入则不启动)──────────────────────── */
|
|||
|
|
|
|||
|
|
test('判据 1 / §三:不注入 env ⇒ apply() 直接 return(⛔ 无连接尝试)', async () => {
|
|||
|
|
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
|
|||
|
|
const saved = {
|
|||
|
|
url: process.env.DSH_PLATFORM_IM_URL,
|
|||
|
|
token: process.env.DSH_IM_INSTANCE_TOKEN,
|
|||
|
|
}
|
|||
|
|
delete process.env.DSH_PLATFORM_IM_URL
|
|||
|
|
delete process.env.DSH_IM_INSTANCE_TOKEN
|
|||
|
|
try {
|
|||
|
|
// 无 ctx 服务依赖、不抛 ⇒ 天然开关生效
|
|||
|
|
assert.doesNotThrow(() => mod.apply({ on: () => undefined }))
|
|||
|
|
assert.equal(mod.currentBridge(), null, '不注入 ⇒ 不起桥(日志无连接尝试)')
|
|||
|
|
} finally {
|
|||
|
|
if (saved.url !== undefined) process.env.DSH_PLATFORM_IM_URL = saved.url
|
|||
|
|
if (saved.token !== undefined) process.env.DSH_IM_INSTANCE_TOKEN = saved.token
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1 / §五-2:重连退避在 30 s 内(首延迟与上限)', async () => {
|
|||
|
|
const mod = await import(pathToFileUrl(PLUGIN_ENTRY))
|
|||
|
|
assert.equal(mod.RECONNECT_FIRST_MS <= 30_000, true, '首延迟必须 ≤ 30 s')
|
|||
|
|
assert.equal(mod.RECONNECT_MAX_MS <= 30_000, true, '上限必须 ≤ 30 s(§五-2 要求 30 s 内恢复)')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 1:凭据头是 `x-dsh-im-instance-token`(与 B 单预留点同名)', () => {
|
|||
|
|
const src = readFileSync(PLUGIN_ENTRY, 'utf8')
|
|||
|
|
assert.equal(src.includes('x-dsh-im-instance-token'), true)
|
|||
|
|
// 与平台侧读取的头必须字面一致
|
|||
|
|
const routes = readFileSync(join(REPO, 'src/web/routes/im.ts'), 'utf8')
|
|||
|
|
assert.equal(routes.includes('x-dsh-im-instance-token'), true)
|
|||
|
|
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
|
|||
|
|
assert.equal(ws.includes('x-dsh-im-instance-token'), true)
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 判据 7:边界(官方 dsh 未改 / relay 未改 / 表结构未改)─────────────── */
|
|||
|
|
|
|||
|
|
test('判据 7 / §六-1:A 单的三张表 DDL 计数未变(⛔ 本单不动表结构)', () => {
|
|||
|
|
const schema = readFileSync(join(REPO, 'src/db/schema.ts'), 'utf8')
|
|||
|
|
// ⚠️ 本条原断言「最高版本 = 14」是**A 单自己的 ratchet**:A 单⛔不改表结构。
|
|||
|
|
// 后续单子**允许**加版本(v15 = 插件数据面台账,插件投放与分库线),故判据改成
|
|||
|
|
// 「**v14 仍在** + 没有**大于 v14 的本单改动**」——保留 ratchet 语义(A 单的表结构未被改),
|
|||
|
|
// 同时不把后续合法迁移卡红。⛔ 不要为了过测而删掉 v14 的存在性断言。
|
|||
|
|
const versions = [...schema.matchAll(/\{\s*version:\s*(\d+)/g)].map((m) => Number(m[1]))
|
|||
|
|
assert.equal(versions.includes(14), true, 'A 单的 v14 三张表仍在')
|
|||
|
|
assert.equal(schema.includes('im_room_keys'), false, 'E2EE 的两张表属**待拍板**,本单⛔不建')
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
test('判据 7:B 单的对外形状未变(WS 路径与 op 词表)', () => {
|
|||
|
|
const ws = readFileSync(join(REPO, 'src/im/ws.ts'), 'utf8')
|
|||
|
|
for (const op of ['subscribe', 'unsubscribe', 'send', 'resume', 'ping']) {
|
|||
|
|
assert.equal(ws.includes(`'${op}'`), true, `客户端 op 词表应保留 ${op}`)
|
|||
|
|
}
|
|||
|
|
const hub = readFileSync(join(REPO, 'src/im/hub.ts'), 'utf8')
|
|||
|
|
assert.equal(hub.includes("'/api/im/ws'"), true, 'WS 路径形状不变')
|
|||
|
|
assert.equal(IM_MAX_CONN_PER_USER > 0, true)
|
|||
|
|
})
|