dsh-jev-prune 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1359 @@
1
+ /**
2
+ * smoke_apply.mjs —— 冒烟测试:用假 ctx 真的跑一次 apply(),**两层都覆盖**。
3
+ *
4
+ * 比"能否 import"深一层:只证明模块能加载是不够的,这里证明 **apply() 的用法是对的**——
5
+ * ctx.on / ctx.effect / ctx.tools.register 的调用形状、Config schema 能否被 schemastery 接受、
6
+ * 接管的 pruneSession 在真实调用链上是否按预期裁决、以及
7
+ * **summarize 猴补丁有没有真的把回执注进 compactRegion**。
8
+ *
9
+ * 怎么跑:这个文件要放在**能解析到 @deepseek-ai/schemastery 与 dsh-tools** 的目录里,
10
+ * 并且同目录的 node_modules 下要有插件副本(包名 `dsh-jev-prune`)。例如:
11
+ * cp <plugin>/{index,jev,state,prune,receipt}.js package.json <ws>/_probe/node_modules/dsh-jev-prune/
12
+ * cp <plugin>/smoke_apply.mjs <ws>/_probe/ && cd <ws>/_probe && node test/smoke_apply.mjs
13
+ * 不塞进 DSH 完整依赖树也能跑(那棵树要装 3~25 分钟,而这个只要 4 秒)。
14
+ *
15
+ * 假对象的事件/服务形状全部取自 DSH 源码,不是猜的:
16
+ * · pruner: measureContent / pruneContent / pruneSession / ctx.tokenMeter.estimateMessage
17
+ * · compaction: summarize(input, agent, signal) / compactRegion(start, end, agent, signal)
18
+ * · session: surface.nodes / eventAt / deriveEventMessage / append(返回 {seq})
19
+ * · tool/result 事件: data.message.content[0] 是 tool-result 块,正文在它的 .content
20
+ * · assistant 消息: data.message.content[] 里 { type:'tool-call', name, arguments, id }
21
+ */
22
+
23
+ const results = []
24
+ const ok = (name, detail = '') => results.push({ name, ok: true, detail })
25
+ const bad = (name, detail) => results.push({ name, ok: false, detail })
26
+ const check = (name, cond, detail = '') => (cond ? ok(name, detail) : bad(name, detail))
27
+
28
+ // ---------------------------------------------------------------- 假 DSH 对象
29
+
30
+ /**
31
+ * 会话:user 提问 + 若干组只读探查(每组的工具结果大小不同)+ 1 条收尾文本。
32
+ * 这样才有「连续的只读步骤」可供第二层合并成一段。
33
+ *
34
+ * @param {Array<{tool:string,args:object,chars:number,interleave?:string}>} [plan]
35
+ * 自定义步骤表。默认三步连成**一段**;H 块要测多段配额,所以会传入
36
+ * 带 interleave(中间插一条 assistant 文本把段切开)的表。
37
+ */
38
+ function makeSession(plan) {
39
+ const events = new Map()
40
+ let nextSeq = 1
41
+ const add = (type, data) => {
42
+ const seq = nextSeq++
43
+ events.set(seq, { seq, type, data })
44
+ return seq
45
+ }
46
+ const addStep = (tool, args, text) => {
47
+ const callId = `c${nextSeq + 1}`
48
+ const head = add('assistant/message', {
49
+ message: { content: [{ type: 'text', text: '看一下。' }, { type: 'tool-call', id: callId, name: tool, arguments: args }] },
50
+ })
51
+ const result = add('tool/result', {
52
+ message: { source: { callId }, content: [{ type: 'tool-result', content: [{ type: 'text', text }] }] },
53
+ })
54
+ return { head, result, tool }
55
+ }
56
+
57
+ add('user/message', { content: [{ type: 'text', text: '排查河牌底池计算,先摸结构。' }], source: { kind: 'user' } })
58
+ // 三步都必须在默认只读白名单内(read/grep/glob)——默认配置下 shell 工具进不了候选
59
+ const effectivePlan = plan ?? [
60
+ { tool: 'Read', args: { file_path: 'server/src/game/river.ts' }, chars: 6000 },
61
+ { tool: 'Grep', args: { pattern: 'basePot', path: 'server/src' }, chars: 4000 },
62
+ { tool: 'Glob', args: { pattern: '*.ts', path: 'server/src' }, chars: 6000 },
63
+ ]
64
+ const steps = []
65
+ for (const item of effectivePlan) {
66
+ if (item.interleave) {
67
+ // 插一条不含 tool-call 的 assistant 文本,把前后两组只读步骤**切成两段**,
68
+ // 这样 maxCompactionsPerPass > 1 才有第二个可压区间
69
+ add('assistant/message', { message: { content: [{ type: 'text', text: item.interleave }] } })
70
+ }
71
+ const fill = item.fill ?? 'a'
72
+ steps.push(addStep(item.tool, item.args, fill.repeat(item.chars)))
73
+ }
74
+ const step1 = steps[0]
75
+ const step2 = steps[1]
76
+ const step3 = steps[2]
77
+
78
+ const tail = add('assistant/message', { message: { content: [{ type: 'text', text: '继续。' }] } })
79
+
80
+ const appended = []
81
+ const session = {
82
+ seqs: { s1: step1.result, s2: step2.result, s3: step3.result },
83
+ steps: [step1, step2, step3],
84
+ tail,
85
+ appended,
86
+ // ⚠️ 刻意**不**提供 `.events`:实测活的 DSH 会话对象上 `session.events` 是 undefined,
87
+ // 只有 `eventAt(seq)` 与 `surface.nodes` 可用。假对象要复刻这个现实,
88
+ // 否则会掩盖"依赖 .events 的 bug"(那个 bug 让工具名索引在活宿主里建出 0 条)。
89
+ surface: { nodes: [...events.keys()] },
90
+ eventAt: (seq) => events.get(seq) ?? null,
91
+ deriveEventMessage: (event) => event.data.message,
92
+ append(type, data, options) {
93
+ const seq = nextSeq++
94
+ events.set(seq, { seq, type, data, ...options })
95
+ appended.push({ seq, type, data, options })
96
+ const op = options?.surfaceOp
97
+ if (op === 'append') {
98
+ session.surface.nodes.push(seq)
99
+ } else if (op?.op === 'replace') {
100
+ const start = session.surface.nodes.indexOf(op.startSeq)
101
+ const end = session.surface.nodes.indexOf(op.endSeq)
102
+ if (start < 0 || end < start) throw new Error(`fake surface replace: 非法区间 ${op.startSeq}-${op.endSeq}`)
103
+ session.surface.nodes.splice(start, end - start + 1, seq)
104
+ }
105
+ return { seq }
106
+ },
107
+ }
108
+ return session
109
+ }
110
+
111
+ /** 一个 assistant 消息并行发出 6 个 read,随后跟 6 个结果(issue #39 真实形态)。 */
112
+ function makeBatchSession() {
113
+ const events = new Map()
114
+ let nextSeq = 1
115
+ const appended = []
116
+ const add = (type, data, options = {}) => {
117
+ const seq = nextSeq++
118
+ events.set(seq, { seq, type, data, ...options })
119
+ return seq
120
+ }
121
+ add('user/message', { content: [{ type: 'text', text: '批量读六个文件。' }], source: { kind: 'user' } })
122
+ const calls = Array.from({ length: 6 }, (_, i) => ({
123
+ type: 'tool-call', id: `batch-${i + 1}`, name: 'Read',
124
+ arguments: { file_path: `src/file-${i + 1}.js` },
125
+ }))
126
+ const head = add('assistant/message', {
127
+ message: { content: [{ type: 'text', text: '批量读取。' }, ...calls] },
128
+ })
129
+ const results = calls.map((call, i) => add('tool/result', {
130
+ message: {
131
+ source: { callId: call.id },
132
+ content: [{ type: 'tool-result', content: [{ type: 'text', text: String(i + 1).repeat(3000) }] }],
133
+ },
134
+ }))
135
+ add('assistant/message', { message: { content: [{ type: 'text', text: '继续。' }] } })
136
+ const session = {
137
+ seqs: Object.fromEntries(results.map((seq, i) => [`s${i + 1}`, seq])),
138
+ steps: [{ head, results }],
139
+ appended,
140
+ surface: { nodes: [...events.keys()] },
141
+ eventAt: (seq) => events.get(seq) ?? null,
142
+ deriveEventMessage: (event) => event.data.message,
143
+ append(type, data, options) {
144
+ const seq = add(type, data, options)
145
+ appended.push({ seq, type, data, options })
146
+ const op = options?.surfaceOp
147
+ if (op === 'append') session.surface.nodes.push(seq)
148
+ else if (op?.op === 'replace') {
149
+ const start = session.surface.nodes.indexOf(op.startSeq)
150
+ const end = session.surface.nodes.indexOf(op.endSeq)
151
+ if (start < 0 || end < start) throw new Error(`fake surface replace: 非法区间 ${op.startSeq}-${op.endSeq}`)
152
+ session.surface.nodes.splice(start, end - start + 1, seq)
153
+ }
154
+ return { seq }
155
+ },
156
+ }
157
+ return session
158
+ }
159
+
160
+ function makePruner() {
161
+ let pruneContentCalls = 0
162
+ return {
163
+ get pruneContentCalls() { return pruneContentCalls },
164
+ ctx: { tokenMeter: { estimateMessage: () => 123 } },
165
+ measureContent: (blocks) => blocks.reduce((n, b) => n + (b?.text ? Array.from(b.text).length : 0), 0),
166
+ // 与 DSH 同口径:超过阈值才裁;这里设成极高,模拟"体积启发式不动作"
167
+ pruneContent(blocks) {
168
+ pruneContentCalls += 1
169
+ const chars = blocks.reduce((n, b) => n + (b?.text ? Array.from(b.text).length : 0), 0)
170
+ if (chars <= 100000) return null
171
+ return blocks
172
+ },
173
+ /**
174
+ * 基线 pruneSession —— 忠实复刻 DSH 自带实现的循环与 append 协议。
175
+ * 插件会**接管**这个方法;这里的实现用来验证"接管前它是可用的"
176
+ * (插件的 guard 要求 pruner.pruneSession 是函数,否则拒绝接管)。
177
+ */
178
+ pruneSession(session) {
179
+ const pruned = []
180
+ let charsRemoved = 0
181
+ for (const seq of [...session.surface.nodes]) {
182
+ const event = session.eventAt(seq)
183
+ if (event?.type !== 'tool/result') continue
184
+ const original = session.deriveEventMessage(event)
185
+ const result = original?.content?.[0]
186
+ if (result == null) continue
187
+ const content = this.pruneContent(result.content)
188
+ if (content == null) continue
189
+ const before = this.measureContent(result.content)
190
+ const after = this.measureContent(content)
191
+ session.append('compaction/prune', {
192
+ shadowedRange: { start: seq, end: seq },
193
+ shadowedSeqs: [seq],
194
+ shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(original),
195
+ })
196
+ session.append('tool/result',
197
+ { ...event.data, message: { ...original, content: [{ ...result, content }] } },
198
+ { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] })
199
+ pruned.push({ originalSeq: seq, charsBefore: before, charsAfter: after })
200
+ charsRemoved += before - after
201
+ }
202
+ return { pruned, charsRemoved }
203
+ },
204
+ }
205
+ }
206
+
207
+ /**
208
+ * 假 compaction 服务。行为对齐 DSH `BasicCompactionEngine` 的关键契约:
209
+ * · `compactRegion` 内部通过 `this.summarize(...)` **动态派发**(所以我们猴补丁实例方法才有效)
210
+ * · `summarize` 返回 `{ summary, provider, model }`,summary 是内容块数组
211
+ * · 回执必须比被压缩的区间更小,否则后端会抛错(这里也照做)
212
+ */
213
+ function makeCompaction(session) {
214
+ const calls = []
215
+ const summaries = []
216
+ return {
217
+ calls,
218
+ summaries,
219
+ async summarize(input, agent, signal) {
220
+ summaries.push({ input, injected: false })
221
+ return { summary: [{ type: 'text', text: '(基线模型摘要)' }], provider: 'fake', model: 'fake-model' }
222
+ },
223
+ async compactRegion(start, end, agent, signal) {
224
+ const nodes = session.surface.nodes
225
+ const startIdx = nodes.indexOf(start)
226
+ const endIdx = nodes.indexOf(end)
227
+ if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) throw new Error(`compactRegion: 非法区间 ${start}-${end}`)
228
+ // 关键:模拟后端内部这一次动态派发 —— 猴补丁必须在这里被调用到
229
+ const summarized = await this.summarize({ messages: [] }, agent, signal)
230
+ const text = summarized.summary.map((b) => b.text ?? '').join('')
231
+ calls.push({ start, end, nodes: nodes.slice(startIdx, endIdx + 1), summary: text, provider: summarized.provider })
232
+ return {
233
+ compactionId: 'fake-compaction',
234
+ shadowedRange: { start, end },
235
+ shadowedSeqs: nodes.slice(startIdx, endIdx + 1),
236
+ shadowedTokenCount: 1234,
237
+ summary: summarized.summary,
238
+ }
239
+ },
240
+ }
241
+ }
242
+
243
+ function makeCtx({ pruner, session, compaction }) {
244
+ const handlers = new Map()
245
+ const registeredTools = []
246
+ const registeredCommands = []
247
+ const tokenMeter = {
248
+ measure: (s) => {
249
+ const nodes = (s?.surface?.nodes ?? []).map((seq) => {
250
+ const event = s.eventAt(seq)
251
+ const blocks = event?.data?.message?.content?.[0]?.content
252
+ const text = Array.isArray(blocks) ? blocks.map((b) => b.text ?? '').join('') : ''
253
+ return { seq, tokens: Math.ceil(text.length / 4), heuristicTokens: Math.ceil(text.length / 4) }
254
+ })
255
+ return { totalTokens: nodes.reduce((sum, n) => sum + n.tokens, 0), nodes }
256
+ },
257
+ }
258
+ const services = new Map([
259
+ ['toolResultPruner', pruner],
260
+ ['tokenMeter', tokenMeter],
261
+ ['compaction', compaction],
262
+ ['commands', { register: (c) => { registeredCommands.push(c); return c } }],
263
+ ])
264
+ const toolsService = { register: (t) => { registeredTools.push(t); return t } }
265
+ services.set('tools', toolsService)
266
+
267
+ return {
268
+ registeredTools,
269
+ registeredCommands,
270
+ handlers,
271
+ // Cordis 两种取法都要支持:ctx.get('x') 与 ctx.x
272
+ get: (n) => services.get(n) ?? null,
273
+ tools: toolsService,
274
+ commands: services.get('commands'),
275
+ tokenMeter,
276
+ compaction,
277
+ // 与 cordis 同构:同一事件可以有**多个**监听器,第三个参数(或 options.prepend)
278
+ // 决定插入队首还是队尾。此前这里只是 Map<event, fn> —— 第二个监听会**覆盖**第一个,
279
+ // 而且 prepend 被完全忽略。于是"判定钩子 prepend 到 compaction-basic 之前"这条
280
+ // 真实语义在测试里根本不存在(拆成两个监听后 30/72 项直接失败)。
281
+ // 假对象必须复刻宿主这个形状,否则测的是一套不存在的语义。
282
+ on: (evt, fn, options) => {
283
+ const prepend = typeof options === 'object' && options !== null
284
+ ? options.prepend === true
285
+ : options === true
286
+ const list = handlers.get(evt) ?? []
287
+ if (prepend) list.unshift(fn)
288
+ else list.push(fn)
289
+ handlers.set(evt, list)
290
+ },
291
+ /**
292
+ * 忠实复刻 cordis 的 waterfall(`agent/pre-step` 正是这类事件):
293
+ * 最外层先跑,最后一个参数是内层 next;监听器**不调用 next() 即否决**后续链路
294
+ * (含宿主内建行为)。所以测试里不能"取一个 handler 直接调"——链上有几个监听、
295
+ * 谁先谁后,恰恰是这次要验证的东西。
296
+ */
297
+ waterfall: (name, ...args) => {
298
+ const cbs = [...(handlers.get(name) ?? [])]
299
+ const inner = args.pop()
300
+ const next = () => (cbs.shift() ?? inner)(...args)
301
+ args.push(next)
302
+ return next()
303
+ },
304
+ effect: (fn) => fn(),
305
+ logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} },
306
+ }
307
+ }
308
+
309
+ // ---------------------------------------------------------------- 假 Jev
310
+ /**
311
+ * 只读概率、不看文本 —— 与真实现同语义。
312
+ * @param {Record<number, number>} resultPreset 结果可丢性 P(保留)
313
+ * @param {Record<number, number>} effectPreset 副作用 P(有副作用)
314
+ */
315
+ function fakeJudge(resultPreset, effectPreset = {}) {
316
+ return {
317
+ ready: true,
318
+ requests: 0,
319
+ usage: { input_tokens: 0, output_tokens: 0 },
320
+ batch: (_state, questions) => [questions],
321
+ async ask(_state, questions) {
322
+ this.requests += 1
323
+ const out = {}
324
+ for (const id of Object.keys(questions)) {
325
+ const match = /^(result|effect)_s(\d+)$/.exec(id)
326
+ if (match == null) continue
327
+ const seq = Number(match[2])
328
+ const preset = match[1] === 'result' ? resultPreset : effectPreset
329
+ if (preset[seq] != null) out[id] = preset[seq]
330
+ }
331
+ return out
332
+ },
333
+ }
334
+ }
335
+
336
+ // ---------------------------------------------------------------- 跑
337
+ let mod
338
+ try {
339
+ mod = await import('dsh-jev-prune')
340
+ ok('import dsh-jev-prune')
341
+ } catch (error) {
342
+ bad('import dsh-jev-prune', error?.message ?? String(error))
343
+ // issue #18:README 的冒烟配方在干净目录里必失败(缺 peer 依赖)——报错要说清缺什么、怎么装
344
+ if (/Cannot find package/.test(error?.message ?? '')) {
345
+ console.error('\n提示:缺少 peer 依赖。在本目录执行:')
346
+ console.error(' npm install @deepseek-ai/schemastery @deepseek-ai/dsh-tools')
347
+ console.error('(或直接用 npm pack 产出的 tarball 安装本包)\n')
348
+ }
349
+ for (const r of results) console.log(`${r.ok ? ' ✅' : ' ❌'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)
350
+ process.exit(1)
351
+ }
352
+
353
+ check('导出 name', typeof mod.name === 'string' && mod.name.length > 0, mod.name)
354
+ check('导出 inject 含 tools', Array.isArray(mod.inject) && mod.inject.includes('tools'), JSON.stringify(mod.inject))
355
+ check('导出 apply', typeof mod.apply === 'function')
356
+
357
+ // 可选的 dsh-llm 不存在时必须走可测的浅拷贝降级;恒等函数会让后续代码意外复用入参。
358
+ {
359
+ const fallbackFreeze = await mod.resolveFreezeMessage(async () => {
360
+ throw new Error('模拟可选依赖缺失')
361
+ })
362
+ const input = { role: 'tool', content: [{ type: 'text', text: 'x' }] }
363
+ const output = fallbackFreeze(input)
364
+ check('freezeMessage 加载失败时返回浅拷贝而不是原对象',
365
+ output !== input && output.role === input.role && output.content === input.content)
366
+ }
367
+
368
+ const PLUGIN_CFG = {
369
+ enabled: true,
370
+ apiKey: 'dummy', // 有注入的假 judge,这个不会被用到
371
+ // 本文件全部用例都走"决策路径",不走压力门:假 ctx 里解析不出上下文窗口,
372
+ // 而并行门(issue #32)在两个方向上都要求**解析不出来就别做**——若沿用默认
373
+ // `judgeOn='pressure'`,第一层会诚实地什么都不做,测的就不是决策逻辑了。
374
+ // 压力门本身由下方 G 块专门覆盖。
375
+ judgeOn: 'always',
376
+ compactOn: 'always',
377
+ preserveRecent: 0, // 让三条都进候选
378
+ minCharsToPrune: 400,
379
+ headChars: 600,
380
+ tailChars: 200,
381
+ wording: 'goal',
382
+ logLevel: 'silent',
383
+ heartbeatFile: '',
384
+ }
385
+
386
+ // ---------- A. apply() 不抛错、装配齐全、两个接入点都被接管 ----------
387
+ {
388
+ const pruner = makePruner()
389
+ const session = makeSession()
390
+ const compaction = makeCompaction(session)
391
+ const baseSummarize = compaction.summarize
392
+ const ctx = makeCtx({ pruner, session, compaction })
393
+ try {
394
+ mod.apply(ctx, PLUGIN_CFG, { judge: fakeJudge({}) })
395
+ ok('apply(ctx, config, {judge}) 未抛错')
396
+ } catch (error) {
397
+ bad('apply(ctx, config, {judge}) 未抛错', error?.message ?? String(error))
398
+ }
399
+ // 拆成两个监听是有意的:判定必须 **prepend**(抢在 compaction-basic 调 pruneSession
400
+ // 之前),压缩保持 append。数量与顺序都要钉住,否则"拆开"这个动作本身没有测试保护。
401
+ const preStepHooks = ctx.handlers.get('agent/pre-step') ?? []
402
+ check('pre-step 注册了判定 + 压缩两个监听', preStepHooks.length === 2, `实际 ${preStepHooks.length}`)
403
+ const toolNames = ctx.registeredTools.map((t) => t?.name).filter(Boolean)
404
+ for (const name of ['jev_prune_status', 'jev_prune_now', 'jev_compact_now', 'jev_restore', 'jev_probe_shapes']) {
405
+ check(`注册了 ${name} 工具`, toolNames.includes(name), toolNames.join(', '))
406
+ }
407
+ check('注册了 /jev 命令', ctx.registeredCommands.some((c) => c?.name === 'jev'),
408
+ ctx.registeredCommands.map((c) => c?.name).join(', '))
409
+ check('已接管 pruner.pruneSession', typeof pruner.pruneSession === 'function')
410
+ check('已接管 compaction.summarize(回执的注入点)', compaction.summarize !== baseSummarize)
411
+
412
+ // 无判定 → 退回按体积;假 pruner 阈值极高,故什么都不裁
413
+ const r = pruner.pruneSession(session)
414
+ check('无判定时退回按体积且不裁', r.pruned.length === 0 && session.appended.length === 0,
415
+ `pruned=${r.pruned.length} appended=${session.appended.length}`)
416
+ check('三条候选都走到了 pruneContent', pruner.pruneContentCalls === 3, `实际 ${pruner.pruneContentCalls}`)
417
+
418
+ // summarize 被接管后,没有 pending 回执时**必须**退回原实现(不能吞掉别人的摘要)
419
+ const passthrough = await compaction.summarize({ messages: [] }, { session, options: {} })
420
+ check('无 pending 回执时 summarize 退回原实现',
421
+ passthrough?.provider === 'fake', JSON.stringify(passthrough))
422
+ }
423
+
424
+ // ---------- B. 预置判定 → 跑完整第一层决策路径 ----------
425
+ {
426
+ const pruner = makePruner()
427
+ const session = makeSession()
428
+ const compaction = makeCompaction(session)
429
+ const { s1, s2, s3 } = session.seqs
430
+ const ctx = makeCtx({ pruner, session, compaction })
431
+ const judge = fakeJudge({ [s1]: 0.08, [s2]: 0.15, [s3]: 0.93 }, { [s1]: 0.05, [s2]: 0.05, [s3]: 0.30 })
432
+ mod.apply(ctx, {
433
+ ...PLUGIN_CFG,
434
+ // 第二层这一步不参与(窗口解析不出来时会保守跳过),避免干扰第一层的断言
435
+ compactReceipts: false,
436
+ }, { judge })
437
+
438
+ const preStepHooks = ctx.handlers.get('agent/pre-step') ?? []
439
+ check('agent/pre-step 处理器存在', preStepHooks.length > 0, `实际 ${preStepHooks.length} 个`)
440
+ if (preStepHooks.length > 0) {
441
+ try {
442
+ // 走 waterfall 而不是取单个 handler 直接调:链上有判定 + 压缩两个监听,
443
+ // 真实宿主链上还有 compaction-basic,只调一个测不出顺序问题
444
+ await ctx.waterfall('agent/pre-step', { agent: { session, options: {} } }, () => {})
445
+ ok('判定 pass 已执行')
446
+ } catch (error) {
447
+ bad('判定 pass 已执行', error?.message ?? String(error))
448
+ }
449
+ }
450
+ check('判定 pass 发出了请求', judge.requests === 1, `实际 ${judge.requests}`)
451
+
452
+ const r = pruner.pruneSession(session)
453
+ const prunedSeqs = r.pruned.map((p) => p.originalSeq)
454
+ check('Jev 说过期的 s1 被裁', prunedSeqs.includes(s1), `pruned=${JSON.stringify(prunedSeqs)}`)
455
+ check('Jev 说过期且不太小的 s2 被裁', prunedSeqs.includes(s2))
456
+ check('Jev 说保留的 s3 没被碰(哪怕 6000 字符)', !prunedSeqs.includes(s3))
457
+
458
+ const prices = session.appended.filter((a) => a.type === 'compaction/prune')
459
+ const replaces = session.appended.filter((a) => a.type === 'tool/result')
460
+ check('每次替换前都有 compaction/prune', prices.length === prunedSeqs.length && replaces.length === prunedSeqs.length,
461
+ `prune 事件=${prices.length} 替换=${replaces.length}`)
462
+ check('compaction/prune 带齐 shadowedRange/Seqs/tokenCount',
463
+ prices.every((p) => p.data.shadowedRange?.start === p.data.shadowedSeqs?.[0]
464
+ && typeof p.data.shadowedTokenCount === 'number'),
465
+ JSON.stringify(prices[0]?.data ?? null))
466
+ check('替换事件带 surfaceOp:replace + sourceEventSeqs',
467
+ replaces.every((x) => x.options?.surfaceOp?.op === 'replace'
468
+ && Array.isArray(x.options?.sourceEventSeqs)),
469
+ JSON.stringify(replaces[0]?.options ?? null))
470
+
471
+ const first = session.appended.find((a) => a.type === 'tool/result')
472
+ const text = first?.data?.message?.content?.[0]?.content?.[0]?.text ?? ''
473
+ check('裁后仍保留头部 600 字符', text.slice(0, 20) === 'a'.repeat(20))
474
+ check('裁后长度显著变短', text.length < 2000, `实际 ${text.length}`)
475
+ check('stats 记录了省下的字符', r.charsRemoved > 0, `charsRemoved=${r.charsRemoved}`)
476
+ }
477
+
478
+ /** 从报告里读出"两轴尾部交集"的 seq 列表(报告格式变了这里会立刻失败,这是有意的)。 */
479
+ function eligibleSeqsFrom(report) {
480
+ const line = report.split('\n').find((l) => l.includes('两轴尾部交集'))
481
+ const match = /\[([^\]]*)\]/.exec(line ?? '')
482
+ if (match == null) return []
483
+ return match[1].split(',').map((s) => Number(s.trim().replace('s', ''))).filter(Number.isFinite)
484
+ }
485
+
486
+ // ---------- C. 第二层 · 自动路径:pre-step 里就完成回执压缩 ----------
487
+ {
488
+ const pruner = makePruner()
489
+ const session = makeSession()
490
+ const compaction = makeCompaction(session)
491
+ const ctx = makeCtx({ pruner, session, compaction })
492
+ // 三条都"结果可丢 + 无副作用"→ 两轴尾部交集覆盖三条 → 应合并成一段
493
+ const judge = fakeJudge({ [session.seqs.s1]: 0.10, [session.seqs.s2]: 0.11, [session.seqs.s3]: 0.12 },
494
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.06, [session.seqs.s3]: 0.07 })
495
+ mod.apply(ctx, {
496
+ ...PLUGIN_CFG,
497
+ compactOn: 'always', // 跳过压力门(本测试不接真 LLM,解析不出上下文窗口)
498
+ compactQuantile: 1, // 测试专用:三条全取,才验得到"合并成一段"
499
+ minCandidatesForRelative: 3,
500
+ compactMinChars: 1000,
501
+ receiptTextChars: 3,
502
+ }, { judge })
503
+
504
+ const agentRef = { agent: { session, options: {} } }
505
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
506
+ check('判定 pass 拿到了两轴概率', judge.requests >= 1, `requests=${judge.requests}`)
507
+
508
+ // 关键:**不需要手动触发** —— pre-step 的自动路径已经压缩了
509
+ check('自动路径(pre-step)就完成了压缩', compaction.calls.length === 1, `实际 ${compaction.calls.length}`)
510
+ const call = compaction.calls[0]
511
+ if (call != null) {
512
+ check('压缩范围覆盖三个步骤的头到尾',
513
+ call.start === session.steps[0].head && call.end === session.steps[2].result,
514
+ `${call.start}-${call.end} vs ${session.steps[0].head}-${session.steps[2].result}`)
515
+ check('区间含 6 个节点(3 调用 + 3 结果)', call.nodes.length === 6, `实际 ${call.nodes.length}`)
516
+ check('注进去的是我们的回执,不是模型摘要',
517
+ call.summary.includes('[已压缩 · 确定性回执]') && !call.summary.includes('基线模型摘要'),
518
+ call.summary.slice(0, 80))
519
+ check('回执 provider 标记为 jev-receipt', call.provider === 'jev-receipt', String(call.provider))
520
+ check('真实压缩路径保留 assistant 可见文本,并透传 receiptTextChars',
521
+ call.summary.includes('模型原话(原文摘录):看一下…'), call.summary)
522
+ // 回执必须逐字记录命令与路径 —— 这是"整对删除后事实仍不丢"的依据
523
+ check('回执记录了 Read 的路径', call.summary.includes('server/src/game/river.ts'))
524
+ check('回执记录了 Grep 的 pattern', call.summary.includes('basePot'))
525
+ check('回执记录了 Glob 的 pattern 与 path', call.summary.includes('*.ts server/src'))
526
+ check('回执写明了被移出的 seq 范围与字符数', /s\d+–s\d+/.test(call.summary) && /字符输出/.test(call.summary))
527
+ }
528
+ }
529
+
530
+ // ---------- C1. issue #39:6 个并行 read 中 5 个合格,只替换这 5 个结果 ----------
531
+ {
532
+ const pruner = makePruner()
533
+ const session = makeBatchSession()
534
+ const compaction = makeCompaction(session)
535
+ const ctx = makeCtx({ pruner, session, compaction })
536
+ const resultPreset = {}
537
+ const effectPreset = {}
538
+ const resultSeqs = Object.values(session.seqs)
539
+ for (const seq of resultSeqs) {
540
+ resultPreset[seq] = 0.05
541
+ effectPreset[seq] = 0.04
542
+ }
543
+ const protectedSeq = resultSeqs[2]
544
+ resultPreset[protectedSeq] = 0.9
545
+ effectPreset[protectedSeq] = 0.9
546
+ const judge = fakeJudge(resultPreset, effectPreset)
547
+ mod.apply(ctx, {
548
+ ...PLUGIN_CFG,
549
+ compactOn: 'always',
550
+ compactMode: 'absolute',
551
+ compactThreshold: 0.5,
552
+ compactPreserveRecent: 0,
553
+ compactMinChars: 1000,
554
+ }, { judge })
555
+
556
+ await ctx.waterfall('agent/pre-step', { agent: { session, options: {} } }, () => {})
557
+
558
+ // 部分路径使用单节点 replace,不调用要求整段平衡的 compactRegion。
559
+ check('批量 6-read 的部分回执不调用不支持子集的 compactRegion', compaction.calls.length === 0,
560
+ `实际 ${compaction.calls.length}`)
561
+ const surfaceResults = session.surface.nodes
562
+ .map((seq) => session.eventAt(seq))
563
+ .filter((event) => event?.type === 'tool/result')
564
+ const texts = surfaceResults.map((event) => event.data.message.content[0].content[0].text)
565
+ check('6 个结果仍全部留有配对外壳', surfaceResults.length === 6, `实际 ${surfaceResults.length}`)
566
+ check('其中 5 个合格结果被替换为确定性回执',
567
+ texts.filter((text) => text.includes('[已压缩 · 确定性回执]')).length === 5,
568
+ `回执数=${texts.filter((text) => text.includes('[已压缩 · 确定性回执]')).length}`)
569
+ check('唯一不合格结果保持原始 3000 字符正文', texts.some((text) => text === '3'.repeat(3000)))
570
+
571
+ const head = session.eventAt(session.steps[0].head)
572
+ const callCount = head.data.message.content.filter((block) => block.type === 'tool-call').length
573
+ check('assistant 的 6 个 tool-call 未被改写', callCount === 6, `实际 ${callCount}`)
574
+ check('最终 surface 的 call/result 数仍为 6/6,配对平衡', callCount === surfaceResults.length,
575
+ `${callCount}/${surfaceResults.length}`)
576
+
577
+ const statusTool = ctx.registeredTools.find((tool) => tool?.name === 'jev_prune_status')
578
+ const status = await statusTool.execute({}, { agent: { session, options: {} } })
579
+ check('部分回执计入 compactions > 0', /回执压缩 [1-9]\d* 段/.test(status), status.split('\n').find((l) => l.includes('第二层')))
580
+ check('部分回执计入 receiptSummaries > 0', /回执摘要被消费 [1-9]\d* 次/.test(status), status.split('\n').find((l) => l.includes('第二层')))
581
+ }
582
+
583
+ // ---------- C2. 第二层 · 手动路径:dry-run 先看,再真跑 ----------
584
+ // compactOn='pressure' 且解析不出上下文窗口 → pre-step 保守跳过(这是一条刻意的设计决策:
585
+ // 不知道压力就不做破坏性动作)。于是可以用手动工具把决策过程摊开看。
586
+ {
587
+ const pruner = makePruner()
588
+ const session = makeSession()
589
+ const compaction = makeCompaction(session)
590
+ const ctx = makeCtx({ pruner, session, compaction })
591
+ const judge = fakeJudge({ [session.seqs.s1]: 0.10, [session.seqs.s2]: 0.11, [session.seqs.s3]: 0.12 },
592
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.06, [session.seqs.s3]: 0.07 })
593
+ mod.apply(ctx, {
594
+ ...PLUGIN_CFG,
595
+ compactOn: 'pressure', // 默认值:解析不出窗口 → 保守不动作
596
+ compactQuantile: 1,
597
+ minCandidatesForRelative: 3,
598
+ compactMinChars: 1000,
599
+ }, { judge })
600
+ const agentRef = { agent: { session, options: {} } }
601
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
602
+ check('压力未知时自动路径保守跳过(不做破坏性动作)', compaction.calls.length === 0, `实际 ${compaction.calls.length}`)
603
+
604
+ const compactTool = ctx.registeredTools.find((t) => t?.name === 'jev_compact_now')
605
+ check('jev_compact_now 可用', compactTool != null)
606
+
607
+ let dryReport = ''
608
+ try {
609
+ dryReport = await compactTool.execute({ dryRun: true }, agentRef)
610
+ ok('jev_compact_now --dryRun 执行未抛错')
611
+ } catch (error) {
612
+ bad('jev_compact_now --dryRun 执行未抛错', error?.message ?? String(error))
613
+ }
614
+ check('dry-run 下没有任何 compactRegion 调用', compaction.calls.length === 0, `实际 ${compaction.calls.length}`)
615
+ check('dry-run 报告里带上回执全文', dryReport.includes('[已压缩 · 确定性回执]'), dryReport.slice(0, 60))
616
+ check('dry-run 报告声明未执行', /dry-run/.test(dryReport))
617
+ check('dry-run 已选中三条', eligibleSeqsFrom(dryReport).length === 3, eligibleSeqsFrom(dryReport).join(','))
618
+ check('dry-run 逐条说明省了多少', /tokens → 回执/.test(dryReport), dryReport.split('\n').slice(3, 5).join(' | '))
619
+
620
+ let report = ''
621
+ try {
622
+ report = await compactTool.execute({}, agentRef)
623
+ ok('jev_compact_now 执行未抛错')
624
+ } catch (error) {
625
+ bad('jev_compact_now 执行未抛错', error?.message ?? String(error))
626
+ }
627
+ check('手动路径压缩了一次', compaction.calls.length === 1, `实际 ${compaction.calls.length}`)
628
+ check('工具输出报告了已压缩', /已压缩/.test(report), report.split('\n').slice(0, 4).join(' | '))
629
+ check('手动路径注入的也是回执',
630
+ (compaction.calls[0]?.summary ?? '').includes('[已压缩 · 确定性回执]'))
631
+ }
632
+
633
+ // ---------- D. 第二层的门控:有副作用的调用绝不能被整对移出(含反事实对照) ----------
634
+ /**
635
+ * 构造条件让 s2 **只可能**因为"副作用"这一轴被排除:
636
+ * result 轴尾部 = {s2, s3} (s2 的 result 概率故意给最低)
637
+ * effect 轴尾部 = {s1, s3} (只有 s2 的 effect 高)
638
+ * 于是 s2 被排除的原因唯一 —— 副作用轴。再用"把 s2 的副作用设低"做对照,证明因果。
639
+ */
640
+ async function layer2Run(effectOfS2) {
641
+ const pruner = makePruner()
642
+ const session = makeSession()
643
+ const compaction = makeCompaction(session)
644
+ const { s1, s2, s3 } = session.seqs
645
+ const ctx = makeCtx({ pruner, session, compaction })
646
+ const judge = fakeJudge({ [s1]: 0.14, [s2]: 0.10, [s3]: 0.12 },
647
+ { [s1]: 0.05, [s2]: effectOfS2, [s3]: 0.07 })
648
+ mod.apply(ctx, {
649
+ ...PLUGIN_CFG,
650
+ compactOn: 'pressure', // 让 pre-step 只判定、不动作,方便逐步观察
651
+ compactQuantile: 0.7, // take = 2/3,必须有取舍才测得出门控
652
+ minCandidatesForRelative: 3,
653
+ compactMinChars: 100,
654
+ }, { judge })
655
+ const agentRef = { agent: { session, options: {} } }
656
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
657
+ const tool = ctx.registeredTools.find((t) => t?.name === 'jev_compact_now')
658
+ const dry = await tool.execute({ dryRun: true }, agentRef)
659
+ const dryEligible = eligibleSeqsFrom(dry)
660
+ const exec = await tool.execute({}, agentRef)
661
+ return { session, compaction, dryEligible, dry, exec }
662
+ }
663
+
664
+ {
665
+ const blocked = await layer2Run(0.40)
666
+ check('副作用高的 s2 不在两轴交集里', !blocked.dryEligible.includes(blocked.session.seqs.s2),
667
+ `eligible=[${blocked.dryEligible.join(',')}]`)
668
+ check('报告列出了被排除的原因', /排除计数/.test(blocked.dry), blocked.dry.split('\n')[2] ?? '')
669
+ const call = blocked.compaction.calls[0]
670
+ check('实际压缩的段落不含 s2', call != null && !call.nodes.includes(blocked.session.seqs.s2),
671
+ `nodes=[${(call?.nodes ?? []).join(',')}]`)
672
+
673
+ // 反事实:把同一个调用的副作用改成低 → 它就该被选中。这证明上面的排除确实来自副作用轴
674
+ const allowed = await layer2Run(0.06)
675
+ check('反事实对照:副作用低时同一条被选中', allowed.dryEligible.includes(allowed.session.seqs.s2),
676
+ `eligible=[${allowed.dryEligible.join(',')}]`)
677
+ check('反事实对照:确实压缩了包含它的段落',
678
+ (allowed.compaction.calls[0]?.nodes ?? []).includes(allowed.session.seqs.s2),
679
+ `nodes=[${(allowed.compaction.calls[0]?.nodes ?? []).join(',')}]`)
680
+ }
681
+
682
+ // ---------- C3. 第二层 · 竞态:await 期间别处的并发压缩抢走回执 ----------
683
+ //
684
+ // issue #29。原实现按 session 存一个待用回执、并在 finally 里无条件 delete(session):
685
+ // compactRegion 是异步的,而 summarize 的入参里**没有区间身份**,所以 await 窗口内任何
686
+ // 别处发起的压缩都可能把回执抢走 —— 别人那段被换成我们的确定性回执,我们要压的那段
687
+ // 反而用了模型摘要。
688
+ //
689
+ // 真实的触发形态是**并发的顶层压缩**(DSH 自己的自动压缩),不是我们调用栈里的嵌套调用。
690
+ // 所以这里的 mock 让 `compactRegion` 在真正派发 summarize 之前先 `await` 一次
691
+ // 微任务边界,并在这期间启动一条独立的 summarize 调用 —— 复刻"两条压缩同时在飞"。
692
+ {
693
+ const pruner = makePruner()
694
+ const session = makeSession()
695
+ const compaction = makeCompaction(session)
696
+
697
+ // 模拟另一个"压缩发起方":在 await 窗口里独立跑一次 summarize(不是我们嵌套调的)
698
+ let intruderResult = null
699
+ const innerCompact = compaction.compactRegion.bind(compaction)
700
+ compaction.compactRegion = async (start, end, agent, signal) => {
701
+ const first = await innerCompact(start, end, agent, signal)
702
+ // 第一次完成后,让"别处"再发起一次压缩并观察它拿到什么 provider
703
+ if (intruderResult == null) {
704
+ intruderResult = await compaction.summarize({ messages: ['elsewhere'] }, agent, signal)
705
+ compaction.intruderProvider = intruderResult?.provider ?? null
706
+ }
707
+ return first
708
+ }
709
+
710
+ const ctx = makeCtx({ pruner, session, compaction })
711
+ const judge = fakeJudge({ [session.seqs.s1]: 0.10, [session.seqs.s2]: 0.11, [session.seqs.s3]: 0.12 },
712
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.06, [session.seqs.s3]: 0.07 })
713
+ mod.apply(ctx, {
714
+ ...PLUGIN_CFG,
715
+ compactOn: 'always',
716
+ compactQuantile: 1,
717
+ minCandidatesForRelative: 3,
718
+ compactMinChars: 1000,
719
+ }, { judge })
720
+
721
+ const agentRef = { agent: { session, options: {} } }
722
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
723
+
724
+ // 以插件自己的账本为准(外面包一层会漏记我们注入的那次:我们的钩子会直接 return,
725
+ // 不走 original,所以包装 `baseSummarize` 观察不到注入)
726
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
727
+ const statusText = statusTool ? await statusTool.execute({}, agentRef) : ''
728
+ check('我们自己的那次压缩拿到了回执',
729
+ compaction.calls[0]?.provider === 'jev-receipt',
730
+ `provider=${compaction.calls[0]?.provider}`)
731
+ check('回执是一次性的:后续别处的压缩拿不到它(否则会把别人的区间写成我们的回执)',
732
+ compaction.intruderProvider !== 'jev-receipt',
733
+ `intruder provider=${compaction.intruderProvider}`)
734
+ check('插件账本记到「回执摘要被消费 1 次」',
735
+ /回执摘要被消费 1 次/.test(String(statusText)),
736
+ String(statusText).split('\n').find((l) => /回执摘要被消费/.test(l)) ?? String(statusText).slice(0, 120))
737
+ }
738
+
739
+ // ---------- G. 两层压力门必须同向关闭(issue #32 回归) ----------
740
+ // 旧实现是不对称的:第二层解析不出阈值 → 不做;第一层解析不出阈值 → **穿透照做**。
741
+ // 更隐蔽的是 meter 缺失/抛错时 `used` 恒为 0,于是 `0 < threshold` 永远成立,
742
+ // 判定每一轮都跑,压力门等于不存在。对一个"省 Jev 调用钱"的门来说,
743
+ // "解析不出来就别花钱"才是安全方向。这里把两个方向都钉住。
744
+ {
745
+ // G1:解析不出窗口 + 默认 judgeOn='pressure' → 第一层**不发请求**
746
+ const pruner = makePruner()
747
+ const session = makeSession()
748
+ const compaction = makeCompaction(session)
749
+ const ctx = makeCtx({ pruner, session, compaction })
750
+ const judge = fakeJudge({ [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.05, [session.seqs.s3]: 0.05 },
751
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.05, [session.seqs.s3]: 0.05 })
752
+ mod.apply(ctx, { ...PLUGIN_CFG, judgeOn: 'pressure' }, { judge })
753
+ await ctx.waterfall('agent/pre-step', { agent: { session, options: {} } }, () => {})
754
+ check('压力未知时第一层不发 Jev 请求(与第二层同向关闭)',
755
+ judge.requests === 0,
756
+ `实际发出 ${judge.requests} 次`)
757
+
758
+ // G2:meter 存在但 measure 抛错 → 同样不发请求(旧实现会把它当成 used=0 放行)
759
+ const pruner2 = makePruner()
760
+ const session2 = makeSession()
761
+ const compaction2 = makeCompaction(session2)
762
+ const ctx2 = makeCtx({ pruner: pruner2, session: session2, compaction: compaction2 })
763
+ ctx2.tokenMeter.measure = () => { throw new Error('meter 坏了') }
764
+ const judge2 = fakeJudge({ [session2.seqs.s1]: 0.05, [session2.seqs.s2]: 0.05, [session2.seqs.s3]: 0.05 },
765
+ { [session2.seqs.s1]: 0.05, [session2.seqs.s2]: 0.05, [session2.seqs.s3]: 0.05 })
766
+ mod.apply(ctx2, { ...PLUGIN_CFG, judgeOn: 'pressure' }, { judge: judge2 })
767
+ await ctx2.waterfall('agent/pre-step', { agent: { session: session2, options: {} } }, () => {})
768
+ check('meter 抛错时第一层也不发 Jev 请求(旧实现会放行)',
769
+ judge2.requests === 0,
770
+ `实际发出 ${judge2.requests} 次`)
771
+
772
+ // G3:meter 正常且用量低于阈值 → 不发请求;高于阈值 → 发
773
+ const pruner3 = makePruner()
774
+ const session3 = makeSession()
775
+ const compaction3 = makeCompaction(session3)
776
+ const ctx3 = makeCtx({ pruner: pruner3, session: session3, compaction: compaction3 })
777
+ // 用一个绝对阈值(非 ratio)绕开窗口解析:低于它必定不判,高于它必定判
778
+ const judge3 = fakeJudge({ [session3.seqs.s1]: 0.05, [session3.seqs.s2]: 0.05, [session3.seqs.s3]: 0.05 },
779
+ { [session3.seqs.s1]: 0.05, [session3.seqs.s2]: 0.05, [session3.seqs.s3]: 0.05 })
780
+ mod.apply(ctx3, { ...PLUGIN_CFG, judgeOn: 'pressure', softLimit: 999999 }, { judge: judge3 })
781
+ await ctx3.waterfall('agent/pre-step', { agent: { session: session3, options: {} } }, () => {})
782
+ check('用量低于绝对阈值时第一层不发请求', judge3.requests === 0, `实际发出 ${judge3.requests} 次`)
783
+
784
+ // G4(PR #28 review 回归):绝对阈值 + meter 缺失 → **必须照常判定**。
785
+ // 绝对阈值不需要 meter,早期实现却在 `!measured` 处直接 return,
786
+ // 于是宿主没注册 tokenMeter 时第一层永久静默失效(比旧行为更糟)。
787
+ //
788
+ // 注意:必须从 ctx.get 里摘掉 tokenMeter。只设 `ctx.tokenMeter = undefined`
789
+ // 是**无效的**——插件走的是 `ctx.get('tokenMeter')`,仍会读到那个对象,
790
+ // 于是这条断言在回退修复后依然变绿(我在反向验证时踩到过这个坑)。
791
+ {
792
+ const p = makePruner()
793
+ const s = makeSession()
794
+ const c = makeCompaction(s)
795
+ const cx = makeCtx({ pruner: p, session: s, compaction: c })
796
+ const realGet = cx.get
797
+ cx.get = (n) => (n === 'tokenMeter' ? null : realGet(n)) // 真正让 meter 消失
798
+ const j = fakeJudge(
799
+ { [s.seqs.s1]: 0.05, [s.seqs.s2]: 0.05, [s.seqs.s3]: 0.05 },
800
+ { [s.seqs.s1]: 0.05, [s.seqs.s2]: 0.05, [s.seqs.s3]: 0.05 },
801
+ )
802
+ mod.apply(cx, { ...PLUGIN_CFG, judgeOn: 'pressure', softLimit: 1 }, { judge: j })
803
+ await cx.waterfall('agent/pre-step', { agent: { session: s, options: {} } }, () => {})
804
+ check('绝对阈值下 meter 缺失时第一层仍照常判定(不因拿不到用量而关闭功能)',
805
+ j.requests > 0,
806
+ `实际发出 ${j.requests} 次(0 表示功能被静默关掉了)`)
807
+ }
808
+
809
+ const pruner4 = makePruner()
810
+ const session4 = makeSession()
811
+ const compaction4 = makeCompaction(session4)
812
+ const ctx4 = makeCtx({ pruner: pruner4, session: session4, compaction: compaction4 })
813
+ const judge4 = fakeJudge({ [session4.seqs.s1]: 0.05, [session4.seqs.s2]: 0.05, [session4.seqs.s3]: 0.05 },
814
+ { [session4.seqs.s1]: 0.05, [session4.seqs.s2]: 0.05, [session4.seqs.s3]: 0.05 })
815
+ mod.apply(ctx4, { ...PLUGIN_CFG, judgeOn: 'pressure', softLimit: 1 }, { judge: judge4 })
816
+ await ctx4.waterfall('agent/pre-step', { agent: { session: session4, options: {} } }, () => {})
817
+ check('用量高于绝对阈值时第一层正常发请求', judge4.requests > 0, `实际发出 ${judge4.requests} 次`)
818
+ }
819
+
820
+ // ---------- H. 单次 pass 的压缩配额(issue #35 回归) ----------
821
+ // 旧默认 maxCompactionsPerPass=1:一次 pass 只回收一段,大上下文要靠多轮 pre-step
822
+ // 慢慢挤,每轮都要重走压力门 + 重判定。默认提到 3 之后,一段 pass 内应能压掉多段。
823
+ // 这里用 interleave 把只读步骤切成**两段**,验证配额真的被放行了。
824
+ {
825
+ const twoRangePlan = [
826
+ { tool: 'Read', args: { file_path: 'server/src/game/river.ts' }, chars: 6000, fill: 'a' },
827
+ { tool: 'Grep', args: { pattern: 'basePot', path: 'server/src' }, chars: 5000, fill: 'b' },
828
+ // 这条不带 tool-call 的 assistant 文本把前后只读步骤切成两段
829
+ { tool: 'Read', args: { file_path: 'server/src/game/turn.ts' }, chars: 6000, fill: 'c', interleave: '中间结论:再看下一段。' },
830
+ { tool: 'Glob', args: { pattern: '*.ts', path: 'server/src' }, chars: 5000, fill: 'd' },
831
+ ]
832
+ const pruner = makePruner()
833
+ const session = makeSession(twoRangePlan)
834
+ const compaction = makeCompaction(session)
835
+ const ctx = makeCtx({ pruner, session, compaction })
836
+ const seqs = session.steps.map((s) => s.result)
837
+ const judge = fakeJudge(
838
+ Object.fromEntries(seqs.map((s) => [s, 0.10])),
839
+ Object.fromEntries(seqs.map((s) => [s, 0.05])),
840
+ )
841
+ mod.apply(ctx, {
842
+ ...PLUGIN_CFG,
843
+ compactQuantile: 1,
844
+ minCandidatesForRelative: 3,
845
+ compactMinChars: 1000,
846
+ maxCompactionsPerPass: 2, // 显式给 2,确保两段都能压
847
+ }, { judge })
848
+
849
+ const agentRef = { agent: { session, options: {} } }
850
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
851
+ check('配额 ≥2 时一次 pass 压掉多段(旧默认 1 只能压一段)',
852
+ compaction.calls.length >= 2,
853
+ `实际压了 ${compaction.calls.length} 段`)
854
+
855
+ // 配额=1 必须严格只压一段(回归保护:配额不能被无视)
856
+ const pruner2 = makePruner()
857
+ const session2 = makeSession(twoRangePlan)
858
+ const compaction2 = makeCompaction(session2)
859
+ const ctx2 = makeCtx({ pruner: pruner2, session: session2, compaction: compaction2 })
860
+ const seqs2 = session2.steps.map((s) => s.result)
861
+ const judge2 = fakeJudge(
862
+ Object.fromEntries(seqs2.map((s) => [s, 0.10])),
863
+ Object.fromEntries(seqs2.map((s) => [s, 0.05])),
864
+ )
865
+ mod.apply(ctx2, {
866
+ ...PLUGIN_CFG,
867
+ compactQuantile: 1,
868
+ minCandidatesForRelative: 3,
869
+ compactMinChars: 1000,
870
+ maxCompactionsPerPass: 1,
871
+ }, { judge: judge2 })
872
+ await ctx2.waterfall('agent/pre-step', { agent: { session: session2, options: {} } }, () => {})
873
+ check('配额=1 时严格只压一段(配额不得被无视)',
874
+ compaction2.calls.length === 1,
875
+ `实际压了 ${compaction2.calls.length} 段`)
876
+
877
+ // 默认值必须是 3(issue #35 把默认从 1 上调);通过 resolveConfig 读,避免硬编码漂移
878
+ const defaults = mod.resolveConfig({})
879
+ check('默认配额已从 1 提到 3', defaults.maxCompactionsPerPass === 3,
880
+ `实际默认 ${defaults.maxCompactionsPerPass}`)
881
+ }
882
+
883
+ // ---------- I. 批次循环只处理本批的题(issue #30 回归) ----------
884
+ // 旧实现每批都遍历完整的 `fresh`:对不在本批里的候选,若缓存里已有旧值,
885
+ // `previous` 分支会把它原样写回并**再累加一次** stats.judged——
886
+ // 于是"判定了 N 条"按批数虚报(3 条切成 3 批 → 报 9 条)。
887
+ // 这里造 3 条候选 + 只装得下 1 题的请求预算 = 3 批,
888
+ // 然后直接读状态报告里的 `判定 N 次` 计数器。
889
+ {
890
+ const pruner = makePruner()
891
+ const session = makeSession()
892
+ const compaction = makeCompaction(session)
893
+ const ctx = makeCtx({ pruner, session, compaction })
894
+ // 每条候选都给出真实的预置概率(这样 answers 里确实有值,不会被 `continue` 跳过)
895
+ const judge = fakeJudge({ [session.seqs.s1]: 0.10, [session.seqs.s2]: 0.11, [session.seqs.s3]: 0.12 },
896
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.06, [session.seqs.s3]: 0.07 })
897
+ // 记录 fakeJudge 实际收到的问题个数,用来证明"确实切成了多批"
898
+ const batchSizes = []
899
+ const baseBatch = judge.batch
900
+ // 让假 judge 也尊重请求预算:每条陈述都**单独成批**(模拟 maxRequestTokens 极小)。
901
+ // 真 client 的 batch() 会按 token 预算切;这里直接按题切,等价于预算只装得下 1 题。
902
+ judge.batch = (state, questions, limits) => {
903
+ if (limits?.maxRequestTokens > 1) return baseBatch(state, questions, limits)
904
+ const out = Object.keys(questions).map((id) => ({ [id]: questions[id] }))
905
+ for (const b of out) batchSizes.push(Object.keys(b).length)
906
+ return out
907
+ }
908
+ // 强制每题一批(把预算压到 0,batch() 里每题都会单独成批)
909
+ mod.apply(ctx, {
910
+ ...PLUGIN_CFG,
911
+ dryRun: true,
912
+ maxRequestTokens: 1,
913
+ }, { judge })
914
+
915
+ const agentRef = { agent: { session, options: {} } }
916
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
917
+
918
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
919
+ const statusText = String(await statusTool.execute({}, agentRef))
920
+ const judgedMatch = /判定 (\d+) 次/.exec(statusText)
921
+ const judgedCount = judgedMatch ? Number(judgedMatch[1]) : -1
922
+
923
+ // 前提成立性检查:必须真的切成了多批,否则这条测试说明不了问题
924
+ check('前提:请求预算被压到每题一批(否则本测试无意义)',
925
+ batchSizes.length >= 2,
926
+ `批数=${batchSizes.length} 各批题数=${JSON.stringify(batchSizes)}`)
927
+ check('批次循环不重复计数:3 条候选跨多批时判定数不得 >3(旧实现按批数虚报)',
928
+ judgedCount === 3,
929
+ `实际判定数=${judgedCount}(批数 ${batchSizes.length})`)
930
+ }
931
+
932
+ // ---------- J. 判定 pass 不得静默抛错(回归:startRequests 未声明) ----------
933
+ // judgePass 的结算行读过未声明的 `startRequests`(全仓 0 次声明、1 次引用),
934
+ // 在严格模式下抛 ReferenceError,被 pre-step 的 catch 吞成
935
+ // stats.errors + lastNote="判定失败:startRequests is not defined"。
936
+ //
937
+ // 危害恰恰在于它**不显眼**:判定结果已经写进 decisions,第一层裁剪照常执行,
938
+ // 所以从会话行为上看功能完全正常——只有 errors 计数与心跳在说谎。
939
+ // 这个 bug 能一路活到 PR #28 之后,就是因为此前没有任何断言检查过 errors 计数。
940
+ {
941
+ const pruner = makePruner()
942
+ const session = makeSession()
943
+ const compaction = makeCompaction(session)
944
+ const ctx = makeCtx({ pruner, session, compaction })
945
+ const judge = fakeJudge(
946
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.05, [session.seqs.s3]: 0.05 },
947
+ { [session.seqs.s1]: 0.05, [session.seqs.s2]: 0.05, [session.seqs.s3]: 0.05 },
948
+ )
949
+ mod.apply(ctx, { ...PLUGIN_CFG, dryRun: true }, { judge })
950
+ const agentRef = { agent: { session, options: {} } }
951
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
952
+
953
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
954
+ const statusText = String(await statusTool.execute({}, agentRef))
955
+ const lines = statusText.split(String.fromCharCode(10))
956
+
957
+ // 前提:判定真的跑过了,否则"没有错误"毫无意义
958
+ check('前提:判定确实执行了(否则 J 块无意义)',
959
+ judge.requests > 0,
960
+ `实际发出 ${judge.requests} 次`)
961
+ // 关键断言:成功路径上不允许留下任何被吞掉的错误
962
+ check('判定成功后不得留下被吞掉的错误(startRequests 未声明回归)',
963
+ !/错误 [1-9]/.test(statusText),
964
+ lines.filter((l) => /错误|判定失败/.test(l)).join(' | ') || '未发现错误行')
965
+ check('判定成功后不得被记成"判定失败"',
966
+ !/判定失败/.test(statusText),
967
+ lines.find((l) => /判定失败/.test(l)) ?? '无')
968
+ }
969
+
970
+
971
+ // ---------- K. session 缺失时必须优雅退出,不得留下被吞掉的错误 ----------
972
+ // judgePass 的第一道早退条件写的是 `session?.surface?.nodes == null`——**显式容忍**
973
+ // session 不存在(宿主在会话挂上之前也会发 pre-step),compactPass 对同一情况的处置是
974
+ // `report.blocked = '没有活动会话'`,所以这是项目自己声明的受支持路径。
975
+ //
976
+ // 但早退分支里那句 `pressureRatios.set(session, 0)` 是**无条件**的,而 WeakMap 的键必须
977
+ // 是对象:`set(undefined)` 抛 `TypeError: Invalid value used as weak map key`。抛出点
978
+ // 落在 pre-step 的 try 里 → 被吞成 `stats.errors += 1` 与
979
+ // `最近(第一层): 判定失败:…`。第一层行为其实没坏,只有账本在说谎——与 #29 同一个模式,
980
+ // 也正是这个 PR 想消灭的那类"静默失效"。
981
+ //
982
+ // 断言方式刻意走**真实路径**:不是直接调 judgePass,而是发一次不带 session 的 pre-step,
983
+ // 再读插件自己的状态报告。否则测的又是"我以为的东西"。
984
+ {
985
+ const pruner = makePruner()
986
+ const session = makeSession()
987
+ const compaction = makeCompaction(session)
988
+ const ctx = makeCtx({ pruner, session, compaction })
989
+ const judge = fakeJudge({}, {})
990
+ mod.apply(ctx, { ...PLUGIN_CFG }, { judge })
991
+
992
+ // agent 没有 session —— 第一道早退条件为真
993
+ await ctx.waterfall('agent/pre-step', { agent: {}, options: {} }, () => {})
994
+
995
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
996
+ const text = String(await statusTool.execute({}, { agent: { session, options: {} } }))
997
+ check('session 缺失时判定 pass 不得抛错(WeakMap.set(undefined) 回归)',
998
+ !/Invalid value used as weak map key/.test(text),
999
+ text.split('\n').filter((l) => /判定失败|Invalid/.test(l)).join(' | ') || '未发现')
1000
+ check('session 缺失时不得被计入错误(错误 1 次)',
1001
+ !/错误 [1-9]/.test(text),
1002
+ text.split('\n').filter((l) => /错误/.test(l)).join(' | ') || '未发现错误行')
1003
+ check('session 缺失应被如实记成"跳过",而不是静默',
1004
+ /没有活动会话/.test(text),
1005
+ text.split('\n').filter((l) => /跳过|活动会话/.test(l)).join(' | ') || '未记录跳过原因')
1006
+ }
1007
+
1008
+
1009
+ // ---------- L. alwaysTrimRatio 必须真的生效(新增配置项的行为断言) ----------
1010
+ // 这个键是 `judgeOn: 'always'` 时替代硬编码 0.5 的固定裁剪比例。配置项最危险的失败模式
1011
+ // 是"进了 schema 但没人读"(静默空转)——schema / CONFIG_RANGES / resolveConfig 三处
1012
+ // 一致(已有元断言)**排不掉**这种失败,只有行为断言能:改这个值,裁剪量必须跟着变。
1013
+ //
1014
+ // 构造上有两个坑,都是实测踩出来的,写在这里免得下次重踩:
1015
+ // ① 候选数必须 ≥ minCandidatesForBudget(默认 4)——只有 3 条时 P0-2 的小总体降级会
1016
+ // 接管,直接走"绝对下限"、完全不看比例,于是三个比例给出同一个结果,
1017
+ // 看起来像"这个配置项没生效",其实是走了另一条路径。故用 6 步会话 + 前提断言。
1018
+ // ② compactOn 必须是 pressure——若是 always,第二层会在 pre-step 里把节点整对移出,
1019
+ // 第一层就没东西可裁,探针会看到恒为 0 的假结果。
1020
+ {
1021
+ const plan6 = [
1022
+ { tool: 'Read', args: { file_path: 'server/src/game/river.ts' }, chars: 6000 },
1023
+ { tool: 'Grep', args: { pattern: 'basePot' }, chars: 4000 },
1024
+ { tool: 'Glob', args: { pattern: '*.ts', path: 'server/src' }, chars: 6000 },
1025
+ { tool: 'Read', args: { file_path: 'server/src/game/turn.ts' }, chars: 5000 },
1026
+ { tool: 'Grep', args: { pattern: 'potOdds' }, chars: 4500 },
1027
+ { tool: 'Glob', args: { pattern: '*.json', path: 'server/config' }, chars: 3000 },
1028
+ ]
1029
+ const pruneWithRatio = async (ratio) => {
1030
+ const pruner = makePruner()
1031
+ const session = makeSession(plan6)
1032
+ const compaction = makeCompaction(session)
1033
+ const ctx = makeCtx({ pruner, session, compaction })
1034
+ // ⚠️ 不能用 session.steps —— 假会话只暴露前 3 步(见 makeSession),
1035
+ // 6 步计划里后 3 步拿不到概率就会被排除出候选,于是又只剩 3 条、撞上小总体降级。
1036
+ // 从 surface 推全部 tool/result 节点才是完整候选集。
1037
+ const resultSeqs = session.surface.nodes.filter((seq) => session.eventAt(seq)?.type === 'tool/result')
1038
+ const probs = Object.fromEntries(resultSeqs.map((seq) => [seq, 0.05]))
1039
+ const judge = fakeJudge(probs, probs)
1040
+ mod.apply(ctx, { ...PLUGIN_CFG, judgeOn: 'always', compactOn: 'pressure', alwaysTrimRatio: ratio }, { judge })
1041
+ const agentRef = { agent: { session, options: {} } }
1042
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
1043
+ // 直接走 DSH 每步真正调用、且已被插件接管的那个接缝
1044
+ const out = pruner.pruneSession(session)
1045
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
1046
+ const statusText = String(await statusTool.execute({}, agentRef))
1047
+ return { out, statusText }
1048
+ }
1049
+
1050
+ const r0 = await pruneWithRatio(0)
1051
+ const rHalf = await pruneWithRatio(0.5)
1052
+ const r1 = await pruneWithRatio(1)
1053
+
1054
+ // 前提:确实走了"预算(压力分位)"路径,而不是小总体降级或体积兜底
1055
+ check('前提:走的是预算路径而非小总体降级(否则本测试无意义)',
1056
+ /压力分位/.test(r1.statusText) && !/降级绝对下限/.test(r1.statusText),
1057
+ r1.statusText.split('\n').find((l) => /第一层预算/.test(l)) ?? '(无预算行)')
1058
+
1059
+ check('alwaysTrimRatio=0 → 一条都不裁(缺口为 0 就不动手)',
1060
+ r0.out.pruned.length === 0,
1061
+ `实际裁了 ${r0.out.pruned.length} 条 / ${r0.out.charsRemoved} 字符`)
1062
+ check('alwaysTrimRatio 单调生效:0.5 与 1 都真的裁了东西',
1063
+ rHalf.out.charsRemoved > 0 && r1.out.charsRemoved > 0,
1064
+ `0.5 → ${rHalf.out.charsRemoved} 字符 / 1 → ${r1.out.charsRemoved} 字符`)
1065
+ check('alwaysTrimRatio=1 裁得比 0.5 多(比例真的进了预算)',
1066
+ r1.out.charsRemoved > rHalf.out.charsRemoved,
1067
+ `0.5 → ${rHalf.out.charsRemoved} 字符 / 1 → ${r1.out.charsRemoved} 字符`)
1068
+ check('alwaysTrimRatio=1 裁满全池(6 条)',
1069
+ r1.out.pruned.length === plan6.length,
1070
+ `实际 ${r1.out.pruned.length} / 期望 ${plan6.length}`)
1071
+ }
1072
+
1073
+ // ---------- M. 判定钩子必须 prepend 到 compaction-basic 之前(顺序回归) ----------
1074
+ // 为什么这条必须存在:真正调用 `pruner.pruneSession` 的**只有** DSH 的
1075
+ // `dsh-compaction-basic`(全依赖树仅两处,都在该文件里::888 context-overflow、
1076
+ // :902 pressure),而它是在**自己的** `agent/pre-step` 里调的。我们的第一层判定结果
1077
+ // 正是在那次调用里被消费。所以:
1078
+ // · 判定钩子若排在 compaction-basic **之后** → pruneSession 读到的 cache 还是上一轮的
1079
+ // → 本轮新结果全部 fallback 到体积规则 → **第一层静默失效**(不是报错,是悄悄不生效)。
1080
+ // · 这就是为什么判定钩子要用 `ctx.on(..., true)` **prepend**:抢在基线束之前。
1081
+ //
1082
+ // 复刻真实装载顺序:基线束先加载(监听先注册),插件后加载但 prepend。
1083
+ // 断言点选在"compaction-basic 调 pruneSession 的那一刻"——那一刻 judge 是否已经跑完,
1084
+ // 就是这条修复的全部内容。
1085
+ {
1086
+ const pruner = makePruner()
1087
+ const session = makeSession()
1088
+ const compaction = makeCompaction(session)
1089
+ const ctx = makeCtx({ pruner, session, compaction })
1090
+
1091
+ // 假 compaction-basic:位置等价于基线束的 pre-step,内部调用 pruneSession
1092
+ let cbcObserved = null
1093
+ let judgeRequestsAtCbc = null
1094
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
1095
+ judgeRequestsAtCbc = judge.requests
1096
+ cbcObserved = pruner.pruneSession(agent.session)
1097
+ return next()
1098
+ })
1099
+
1100
+ const resultSeqs = session.surface.nodes.filter((q) => session.eventAt(q)?.type === 'tool/result')
1101
+ const probs = Object.fromEntries(resultSeqs.map((q) => [q, 0.05]))
1102
+ const judge = fakeJudge(probs, probs)
1103
+ mod.apply(ctx, {
1104
+ ...PLUGIN_CFG,
1105
+ compactOn: 'always',
1106
+ compactQuantile: 1,
1107
+ compactPreserveRecent: 0,
1108
+ }, { judge })
1109
+
1110
+ const chain = ctx.handlers.get('agent/pre-step') ?? []
1111
+ // 前提:链上有 3 个监听(基线 + 判定 + 压缩)。若只有 2 个,说明判定与压缩仍是
1112
+ // 同一个监听、或基线监听没挂上——两种情况下这条测试都测不到顺序问题。
1113
+ check('前提:pre-step 链上有基线 + 判定 + 压缩三个监听(否则本测试无意义)',
1114
+ chain.length === 3, `实际 ${chain.length}`)
1115
+
1116
+ await ctx.waterfall('agent/pre-step', { agent: { session, options: {} } }, () => {})
1117
+
1118
+ check('前提:compaction-basic 的位置确实调用了 pruneSession',
1119
+ cbcObserved != null,
1120
+ cbcObserved == null ? '未观察到 pruneSession 调用' : `观察到了,裁了 ${cbcObserved.pruned.length} 条`)
1121
+ check('compaction-basic 调 pruneSession 时本轮判定已完成(判定钩子确实 prepend 了)',
1122
+ judgeRequestsAtCbc === 1,
1123
+ `那一刻 judge.requests=${judgeRequestsAtCbc}(应为 1;为 0 说明判定跑在基线之后)`)
1124
+ check('因此那一刻第一层确实按 Jev 裁了,而不是 fallback 到体积规则',
1125
+ (cbcObserved?.pruned?.length ?? 0) > 0,
1126
+ `pruneSession 裁了 ${cbcObserved?.pruned?.length ?? 0} 条(0 = 判定 cache 为空、已退化为体积规则)`)
1127
+ check('第一层替换 seq 后,第二层仍能沿 sourceEventSeqs 找回判定并压缩',
1128
+ compaction.calls.length > 0,
1129
+ `第二层压缩 ${compaction.calls.length} 段(0 = replacement seq 使缓存失效)`)
1130
+ }
1131
+
1132
+ // ---------- N. 第二层被 skip 时必须落盘原因(可观测缺口) ----------
1133
+ // 此前只有**成功**路径与 catch 会写 stats.lastCompactNote,被 skip 的 pass 一律不写
1134
+ // → 心跳与状态报告里留着上一轮的旧值,"第二层为什么没动"恰好是唯一看不见的东西。
1135
+ // 跑批端实测踩到:只能看到 compactSkipped=4,不知道原因。两条路径都要钉住:
1136
+ // ① 有 selection 的 blocked(带逐条排除计数)
1137
+ // ② 无 selection 的 blocked(极早退)
1138
+ {
1139
+ // ① compactMinChars 设成不可能达到的值 → 所有范围都被 skippedShort 掉 → ranges 为空
1140
+ const pruner = makePruner()
1141
+ const session = makeSession()
1142
+ const compaction = makeCompaction(session)
1143
+ const ctx = makeCtx({ pruner, session, compaction })
1144
+ const resultSeqs = session.surface.nodes.filter((q) => session.eventAt(q)?.type === 'tool/result')
1145
+ const probs = Object.fromEntries(resultSeqs.map((q) => [q, 0.05]))
1146
+ const judge = fakeJudge(probs, probs)
1147
+ mod.apply(ctx, {
1148
+ ...PLUGIN_CFG,
1149
+ compactOn: 'always',
1150
+ compactQuantile: 1,
1151
+ minCandidatesForRelative: 3,
1152
+ compactMinChars: 999999, // 任何范围都达不到 → 全被 skippedShort
1153
+ }, { judge })
1154
+ const agentRef = { agent: { session, options: {} } }
1155
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
1156
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
1157
+ const txt = String(await statusTool.execute({}, agentRef))
1158
+ const layer2 = txt.split('\n').find((l) => /最近(第二层)/.test(l)) ?? ''
1159
+
1160
+ check('第二层被 skip 时原因必须落盘(此前只在成功路径写)',
1161
+ /没有合格的连续只读步骤段/.test(layer2), layer2.trim() || '(没有第二层记录 = 又回到静默)')
1162
+ check('blocked 时还要带上 selection 的逐条排除计数与合格步数',
1163
+ /short:1/.test(layer2) && /eligible:3/.test(layer2),
1164
+ layer2.trim() || '(缺排除计数)')
1165
+
1166
+ // ② 极早退(compactReceipts=false,selection 为 null)同样要留痕
1167
+ const pruner2 = makePruner()
1168
+ const session2 = makeSession()
1169
+ const compaction2 = makeCompaction(session2)
1170
+ const ctx2 = makeCtx({ pruner: pruner2, session: session2, compaction: compaction2 })
1171
+ const judge2 = fakeJudge({}, {})
1172
+ mod.apply(ctx2, { ...PLUGIN_CFG, compactReceipts: false }, { judge: judge2 })
1173
+ const agentRef2 = { agent: { session: session2, options: {} } }
1174
+ await ctx2.waterfall('agent/pre-step', agentRef2, () => {})
1175
+ const txt2 = String(await ctx2.registeredTools.find((t) => t?.name === 'jev_prune_status').execute({}, agentRef2))
1176
+ const layer2b = txt2.split('\n').find((l) => /最近(第二层)/.test(l)) ?? ''
1177
+ check('极早退的 blocked 也要落盘(compactReceipts=false)',
1178
+ /compactReceipts=false/.test(layer2b), layer2b.trim() || '(没有第二层记录)')
1179
+ }
1180
+
1181
+ // ---------- O. 两层最近区必须真正独立(compactPreserveRecent 接线回归) ----------
1182
+ // 只把配置写进 schema / 状态页还不够:判定缓存若仍按第一层 preserveRecent 截断,
1183
+ // 第二层即使配置 compactPreserveRecent=0,也永远拿不到最近节点的两轴概率。
1184
+ // 这里保持第一层最近 4 个 surface 节点(恰好挡住最后一个 tool/result),再让第二层
1185
+ // 保留 0 个;判定范围应扩到两层里更小的窗口,因此 3 条结果都要被判定。
1186
+ {
1187
+ const judgedWith = async (compactReceipts, plan, overrides = {}) => {
1188
+ const pruner = makePruner()
1189
+ const session = makeSession(plan)
1190
+ const compaction = makeCompaction(session)
1191
+ const ctx = makeCtx({ pruner, session, compaction })
1192
+ const resultSeqs = session.surface.nodes.filter((q) => session.eventAt(q)?.type === 'tool/result')
1193
+ const probs = Object.fromEntries(resultSeqs.map((q) => [q, 0.05]))
1194
+ const judge = fakeJudge(probs, probs)
1195
+ mod.apply(ctx, {
1196
+ ...PLUGIN_CFG,
1197
+ dryRun: true,
1198
+ preserveRecent: 4,
1199
+ compactReceipts,
1200
+ compactOn: 'always',
1201
+ compactPreserveRecent: 0,
1202
+ ...overrides,
1203
+ }, { judge })
1204
+ const agentRef = { agent: { session, options: {} } }
1205
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
1206
+ const statusTool = ctx.registeredTools.find((t) => t?.name === 'jev_prune_status')
1207
+ const statusText = String(await statusTool.execute({}, agentRef))
1208
+ return Number(/第一层:判定 (\d+) 次/.exec(statusText)?.[1] ?? -1)
1209
+ }
1210
+
1211
+ const firstLayerOnly = await judgedWith(false)
1212
+ const bothLayers = await judgedWith(true)
1213
+ check('前提:第一层 preserveRecent=4 时只判定最早的 1 条结果',
1214
+ firstLayerOnly === 1, `实际 ${firstLayerOnly}`)
1215
+ check('compactPreserveRecent=0 会把判定范围扩到全部 3 条结果',
1216
+ bothLayers === 3, `实际 ${bothLayers}(若仍为 2,说明配置只展示了但没有接线)`)
1217
+
1218
+ const relativeDisabled = await judgedWith(true, undefined, { compactQuantile: 0 })
1219
+ check('compactQuantile=0 显式关闭选择时不为第二层扩候选付费',
1220
+ relativeDisabled === firstLayerOnly,
1221
+ `实际判定 ${relativeDisabled} 条(第一层自身只需 ${firstLayerOnly} 条)`)
1222
+
1223
+ const absoluteDisabled = await judgedWith(true, undefined, {
1224
+ compactMode: 'absolute',
1225
+ compactThreshold: 0,
1226
+ })
1227
+ check('absolute 阈值=0 时不为不可能命中的第二层扩候选付费',
1228
+ absoluteDisabled === firstLayerOnly,
1229
+ `实际判定 ${absoluteDisabled} 条(第一层自身只需 ${firstLayerOnly} 条)`)
1230
+
1231
+ const manualOnly = await judgedWith(true, undefined, { compactOn: 'off' })
1232
+ check("compactOn='off' 仍预取第二层两轴,保证 jev_compact_now(force) 可用",
1233
+ manualOnly === bothLayers,
1234
+ `实际判定 ${manualOnly} 条(手动压缩需要 ${bothLayers} 条)`)
1235
+
1236
+ const mixedTools = await judgedWith(true, [
1237
+ { tool: 'Read', args: { file_path: 'a.ts' }, chars: 3000 },
1238
+ { tool: 'pwsh', args: { command: 'Get-Content b.ts' }, chars: 3000 },
1239
+ { tool: 'Read', args: { file_path: 'c.ts' }, chars: 3000 },
1240
+ ])
1241
+ check('第二层扩展判定窗口时不为白名单外工具付费',
1242
+ mixedTools === 2,
1243
+ `实际判定 ${mixedTools} 条(期望仅两条 Read;若为 3,pwsh 仍在空转)`)
1244
+
1245
+ const layerSpecificBlacklist = await judgedWith(true, undefined, { neverPruneTools: ['Read'] })
1246
+ check('第一层 neverPruneTools 不得误关第二层允许的 Read 判定',
1247
+ layerSpecificBlacklist === 3,
1248
+ `实际判定 ${layerSpecificBlacklist} 条(期望 3;为 0 说明两层黑名单仍耦合)`)
1249
+ }
1250
+
1251
+ // ---------- P. 会话恢复后,已裁 replacement 仍可重新进入第二层 ----------
1252
+ // decisions 是 WeakMap 内存缓存;插件/宿主重启后缓存为空,但 session 日志和 surface 会恢复。
1253
+ // 若第二层沿用第一层的“带裁剪标记就不再判定”,这些旧 replacement 将永久失去 verdict。
1254
+ {
1255
+ const session = makeSession()
1256
+
1257
+ // 第一实例先完成判定与第一层裁剪,制造带 sourceEventSeqs 的 replacement。
1258
+ const pruner1 = makePruner()
1259
+ const compaction1 = makeCompaction(session)
1260
+ const ctx1 = makeCtx({ pruner: pruner1, session, compaction: compaction1 })
1261
+ const originalResults = session.surface.nodes.filter((seq) => session.eventAt(seq)?.type === 'tool/result')
1262
+ const firstProbs = Object.fromEntries(originalResults.map((seq) => [seq, 0.05]))
1263
+ mod.apply(ctx1, { ...PLUGIN_CFG, compactReceipts: false }, { judge: fakeJudge(firstProbs, firstProbs) })
1264
+ const agentRef1 = { agent: { session, options: {} } }
1265
+ await ctx1.waterfall('agent/pre-step', agentRef1, () => {})
1266
+ pruner1.pruneSession(session)
1267
+ const replacements = session.surface.nodes.filter((seq) =>
1268
+ (session.eventAt(seq)?.sourceEventSeqs?.length ?? 0) > 0
1269
+ && session.eventAt(seq)?.type === 'tool/result')
1270
+ check('前提:第一实例已产生带 sourceEventSeqs 的裁剪 replacement',
1271
+ replacements.length > 0, `实际 ${replacements.length}`)
1272
+
1273
+ // 第二实例模拟重启:新的 apply() 拥有全新的 decisions WeakMap。
1274
+ const pruner2 = makePruner()
1275
+ const compaction2 = makeCompaction(session)
1276
+ const ctx2 = makeCtx({ pruner: pruner2, session, compaction: compaction2 })
1277
+ const currentResults = session.surface.nodes.filter((seq) => session.eventAt(seq)?.type === 'tool/result')
1278
+ const secondProbs = Object.fromEntries(currentResults.map((seq) => [seq, 0.05]))
1279
+ const judge2 = fakeJudge(secondProbs, secondProbs)
1280
+ mod.apply(ctx2, {
1281
+ ...PLUGIN_CFG,
1282
+ compactOn: 'always',
1283
+ compactQuantile: 1,
1284
+ compactPreserveRecent: 0,
1285
+ }, { judge: judge2 })
1286
+ await ctx2.waterfall('agent/pre-step', { agent: { session, options: {} } }, () => {})
1287
+
1288
+ check('重启后会重新判定已裁 replacement(缓存为空也不会永久跳过)',
1289
+ judge2.requests > 0, `实际请求 ${judge2.requests} 次`)
1290
+ check('重启后已裁 replacement 能继续进入第二层回执压缩',
1291
+ compaction2.calls.length > 0, `实际压缩 ${compaction2.calls.length} 段`)
1292
+ }
1293
+
1294
+ // ---------- Q. 部分判定缓存不得让缺失轴永久饿死 ----------
1295
+ // 第一次只返回 result 轴,cache 已有 entry 但 effectProb=null;第二次必须继续问。
1296
+ // 旧逻辑只看 cache.has(seq),会从此把它当成 fresh=false,第二层永远拿不到两轴交集。
1297
+ {
1298
+ const pruner = makePruner()
1299
+ const session = makeSession()
1300
+ const compaction = makeCompaction(session)
1301
+ const ctx = makeCtx({ pruner, session, compaction })
1302
+ // 复刻 compaction-basic:判定后立刻调用第一层,使两轮之间发生 old seq → replacement seq。
1303
+ ctx.on('agent/pre-step', async ({ agent }, next) => {
1304
+ pruner.pruneSession(agent.session)
1305
+ return next()
1306
+ })
1307
+ const judge = {
1308
+ ready: true,
1309
+ requests: 0,
1310
+ asked: [],
1311
+ retries: 0,
1312
+ lastRetries: 0,
1313
+ lastError: '',
1314
+ usage: { input_tokens: 0, output_tokens: 0 },
1315
+ batch: (_state, questions) => [questions],
1316
+ async ask(_state, questions) {
1317
+ this.requests += 1
1318
+ this.asked.push(Object.keys(questions))
1319
+ const out = {}
1320
+ for (const id of Object.keys(questions)) {
1321
+ if (this.requests === 1 && id.startsWith('result_s')) out[id] = 0.05
1322
+ if (this.requests >= 2 && id.startsWith('effect_s')) out[id] = 0.05
1323
+ }
1324
+ return out
1325
+ },
1326
+ }
1327
+ mod.apply(ctx, {
1328
+ ...PLUGIN_CFG,
1329
+ compactOn: 'always',
1330
+ compactQuantile: 1,
1331
+ compactPreserveRecent: 0,
1332
+ }, { judge })
1333
+ const agentRef = { agent: { session, options: {} } }
1334
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
1335
+ check('前提:第一轮只有单轴时第二层不能压缩',
1336
+ compaction.calls.length === 0, `实际压缩 ${compaction.calls.length} 段`)
1337
+ check('前提:两轮之间第一层确实把结果替换成了新 seq',
1338
+ session.surface.nodes.some((seq) => (session.eventAt(seq)?.sourceEventSeqs?.length ?? 0) > 0),
1339
+ 'surface 上没有 replacement,测不到跨 seq 补轴')
1340
+ await ctx.waterfall('agent/pre-step', agentRef, () => {})
1341
+ check('缺失 effect 轴的缓存项会在下一轮继续请求',
1342
+ judge.requests === 2, `实际请求 ${judge.requests} 次`)
1343
+ check('补轴请求只问缺失的 effect,不重复询问已有 result',
1344
+ judge.asked[1]?.length > 0
1345
+ && judge.asked[1].every((id) => id.startsWith('effect_s')),
1346
+ `第二轮题号 ${JSON.stringify(judge.asked[1] ?? [])}`)
1347
+ check('补齐第二轴后第二层可以继续压缩',
1348
+ compaction.calls.length > 0, `实际压缩 ${compaction.calls.length} 段`)
1349
+ }
1350
+
1351
+ // ---------------------------------------------------------------- 汇总
1352
+ console.log()
1353
+ for (const r of results) console.log(`${r.ok ? ' ✅' : ' ❌'} ${r.name}${r.detail ? ` — ${r.detail}` : ''}`)
1354
+ const failures = results.filter((r) => !r.ok)
1355
+ console.log()
1356
+ console.log(failures.length === 0
1357
+ ? `结论:apply() 装配 + 两个接入点接管 + 判定 pass + 两层裁决路径 + append/回执协议,在真实 DSH 接口形状下全部通过(${results.length} 项)`
1358
+ : `结论:${failures.length} / ${results.length} 项失败`)
1359
+ process.exitCode = failures.length === 0 ? 0 : 1