@mzzsfy/dsh-usage-dash 0.3.0 → 0.5.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.
@@ -17,6 +17,12 @@ const usage = (extra = {}) => ({
17
17
  cacheWriteTokens: 4,
18
18
  ...extra,
19
19
  })
20
+ // 官方助手流紧凑记录构造(与 dsh-llm AssistantStreamAccumulator.snapshot 同形):
21
+ // packed run 以 time0 + dt 差分还原成员时刻,裸 chunk 携带原始 time
22
+ const textRun = (time0, texts, dt = []) => ({ type: 'text-chunks', time0, index: 0, dt, texts })
23
+ const toolRun = (time0, args, extra = {}) => ({ type: 'tool-call-chunks', time0, index: 0, dt: [], id: 't1', args, ...extra })
24
+ const rawChunk = (time, chunk) => ({ type: 'chunk', time, chunk })
25
+ const attempt = (seq, time, turn, step, stream) => ev('assistant/attempt', seq, time, { turn, step, stream })
20
26
  const session = (id) => ({ id })
21
27
  const tick = () => new Promise((resolve) => setImmediate(resolve))
22
28
 
@@ -72,17 +78,46 @@ function fakeSessions(handles = []) {
72
78
  }
73
79
 
74
80
  // definitions: [{ id, events?, inheritedEventCount?, fail? }]
81
+ // 内部统一契约形状(list → [{id}],readLog → {inheritedEventCount, events}),
82
+ // 宿主 persistence 的多版本映射由 archive-reader 测试单独覆盖
75
83
  function fakePersistence(definitions = []) {
76
84
  const byId = new Map(definitions.map((def) => [def.id, def]))
77
85
  return {
78
86
  async list() {
79
87
  return definitions.map((def) => ({ id: def.id }))
80
88
  },
81
- async inspect(id) {
89
+ async readLog(id) {
82
90
  const def = byId.get(id)
83
91
  if (!def) throw new Error(`session "${id}" not found`)
84
92
  if (def.fail) throw new Error(def.fail)
85
- return { meta: { id }, inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
93
+ return { inheritedEventCount: def.inheritedEventCount ?? 0, events: def.events ?? [] }
94
+ },
95
+ }
96
+ }
97
+
98
+ // 宿主 handle 代形状(list 返回 snapshot,open/read/close 读体),供走
99
+ // rescan → 工厂分派 → backfill 的装配路径测试使用
100
+ function fakeHostPersistence(definitions = []) {
101
+ const byId = new Map(definitions.map((def) => [def.id, def]))
102
+ return {
103
+ async list() {
104
+ return definitions.map((def) => ({ header: { id: def.id }, revision: {} }))
105
+ },
106
+ async open(id, access) {
107
+ const def = byId.get(id)
108
+ if (!def) throw new Error(`session "${id}" not found`)
109
+ return {
110
+ header: { id },
111
+ inheritedEventCount: def.inheritedEventCount ?? 0,
112
+ access,
113
+ async read(offset, length) {
114
+ if (def.fail) throw new Error(def.fail)
115
+ const all = def.events ?? []
116
+ const start = typeof offset === 'number' && offset > 0 ? offset : 0
117
+ return { eventState: 'detached', events: all.slice(start, typeof length === 'number' ? start + length : undefined) }
118
+ },
119
+ async close() {},
120
+ }
86
121
  },
87
122
  }
88
123
  }
@@ -139,12 +174,12 @@ test('step/start 与 llm/retry-started 产生 request 标记样本', async () =>
139
174
  }
140
175
  })
141
176
 
142
- test('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本时刻)', async () => {
177
+ test('decode 口径:durationMs 为首 token 到汇报,ttftMs 为起点到首 token', async () => {
143
178
  const { ctx, store } = liveCollector()
144
179
  const start = local(2026, 8, 2, 10, 0)
145
- const sampleAt = start + 6 * 1000 + 500
146
180
  ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
147
- ctx.emit('session/event', session('s1'), ev('assistant/message', 2, sampleAt, {
181
+ ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['你好'])]))
182
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
148
183
  turn: 0,
149
184
  step: 0,
150
185
  usage: usage(),
@@ -152,7 +187,98 @@ test('step/start 观测起点:首个 usage 样本附 durationMs(start 到样本
152
187
  }))
153
188
  await tick()
154
189
  const usageSample = store.samples.find((sample) => !sample.request)
155
- assert.equal(usageSample.durationMs, 6500)
190
+ assert.equal(usageSample.durationMs, 30 * 1000)
191
+ assert.equal(usageSample.ttftMs, 10 * 1000)
192
+ assert.equal(usageSample.decodeTokens, 20)
193
+ assert.equal(usageSample.outputTokens, 20)
194
+ })
195
+
196
+ test('无 attempt 时回落 message 自带 stream 取首 token', async () => {
197
+ const { ctx, store } = liveCollector()
198
+ const start = local(2026, 8, 2, 10, 0)
199
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
200
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 2, start + 40 * 1000, {
201
+ turn: 0,
202
+ step: 0,
203
+ usage: usage(),
204
+ stream: [textRun(start + 8 * 1000, ['好'])],
205
+ message: {},
206
+ }))
207
+ await tick()
208
+ const usageSample = store.samples.find((sample) => !sample.request)
209
+ assert.equal(usageSample.durationMs, 32 * 1000)
210
+ assert.equal(usageSample.ttftMs, 8 * 1000)
211
+ })
212
+
213
+ test('chunk 先发 token 契约保持,message 补发纯 timing 增量样本', async () => {
214
+ const { ctx, store } = liveCollector()
215
+ const start = local(2026, 8, 2, 10, 0)
216
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
217
+ ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
218
+ turn: 0,
219
+ step: 0,
220
+ chunk: { type: 'usage', usage: usage() },
221
+ }))
222
+ ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['你好'])]))
223
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
224
+ turn: 0,
225
+ step: 0,
226
+ usage: usage({ inputTokens: 999 }),
227
+ message: {},
228
+ }))
229
+ await tick()
230
+ assert.equal(store.samples.length, 3)
231
+ const chunkSample = store.samples[1]
232
+ assert.equal(chunkSample.inputTokens, 10)
233
+ assert.equal(chunkSample.durationMs, undefined)
234
+ assert.equal(chunkSample.ttftMs, undefined)
235
+ assert.equal(chunkSample.decodeTokens, undefined)
236
+ assert.equal(store.samples[0].request, true)
237
+ const timingSample = store.samples[2]
238
+ assert.equal(timingSample.inputTokens, 0)
239
+ assert.equal(timingSample.outputTokens, 0)
240
+ assert.equal(timingSample.decodeTokens, 20)
241
+ assert.equal(timingSample.durationMs, 30 * 1000)
242
+ assert.equal(timingSample.ttftMs, 10 * 1000)
243
+ })
244
+
245
+ test('attempt 首 token 锁定:后续 attempt 不覆盖首个产出 token 的时刻', async () => {
246
+ const { ctx, store } = liveCollector()
247
+ const start = local(2026, 8, 2, 10, 0)
248
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
249
+ ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['a'])]))
250
+ ctx.emit('session/event', session('s1'), attempt(3, start + 38 * 1000, 0, 0, [textRun(start + 20 * 1000, ['b'])]))
251
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
252
+ turn: 0,
253
+ step: 0,
254
+ usage: usage(),
255
+ message: {},
256
+ }))
257
+ await tick()
258
+ const usageSample = store.samples.find((sample) => !sample.request)
259
+ assert.equal(usageSample.ttftMs, 10 * 1000)
260
+ assert.equal(usageSample.durationMs, 30 * 1000)
261
+ })
262
+
263
+ test('首个 attempt 无 token,由后续产出 token 的 attempt 锁定', async () => {
264
+ const { ctx, store } = liveCollector()
265
+ const start = local(2026, 8, 2, 10, 0)
266
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
267
+ ctx.emit('session/event', session('s1'), attempt(2, start + 5 * 1000, 0, 0, [
268
+ textRun(start + 5 * 1000, ['']),
269
+ rawChunk(start + 6 * 1000, { type: 'usage' }),
270
+ ]))
271
+ ctx.emit('session/event', session('s1'), attempt(3, start + 38 * 1000, 0, 0, [toolRun(start + 15 * 1000, ['{'], { name: 'fn' })]))
272
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
273
+ turn: 0,
274
+ step: 0,
275
+ usage: usage(),
276
+ message: {},
277
+ }))
278
+ await tick()
279
+ const usageSample = store.samples.find((sample) => !sample.request)
280
+ assert.equal(usageSample.ttftMs, 15 * 1000)
281
+ assert.equal(usageSample.durationMs, 25 * 1000)
156
282
  })
157
283
 
158
284
  test('(session,turn,step) 去重:同键先到样本生效,重复报告吞掉', async () => {
@@ -188,14 +314,194 @@ test('无 step/start 起点的 usage 样本不带 durationMs', async () => {
188
314
  assert.equal(store.samples[0].durationMs, undefined)
189
315
  })
190
316
 
191
- test('retry-started 重置起点:时长只含最终尝试', async () => {
317
+ test('retry 不重置起点:TTFT 含失败尝试,decode 段自成功首 token 起', async () => {
192
318
  const { ctx, store } = liveCollector()
193
319
  const start = local(2026, 8, 2, 10, 0)
194
320
  const retryAt = start + 60 * 1000
195
- const sampleAt = retryAt + 5 * 1000
196
321
  ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
197
322
  ctx.emit('session/event', session('s1'), ev('llm/retry-started', 2, retryAt, { turn: 0, step: 0 }))
198
- ctx.emit('session/event', session('s1'), ev('assistant/message', 3, sampleAt, {
323
+ ctx.emit('session/event', session('s1'), attempt(3, retryAt + 3 * 1000, 0, 0, [textRun(retryAt + 5 * 1000, ['好'])]))
324
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, retryAt + 35 * 1000, {
325
+ turn: 0,
326
+ step: 0,
327
+ usage: usage(),
328
+ message: {},
329
+ }))
330
+ await tick()
331
+ const usageSample = store.samples.find((sample) => !sample.request)
332
+ assert.equal(usageSample.ttftMs, 65 * 1000)
333
+ assert.equal(usageSample.durationMs, 30 * 1000)
334
+ })
335
+
336
+ test('零 TTFT(起点即出 token)附 ttftMs 为 0', async () => {
337
+ const { ctx, store } = liveCollector()
338
+ const start = local(2026, 8, 2, 10, 0)
339
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
340
+ ctx.emit('session/event', session('s1'), attempt(2, start + 30 * 1000, 0, 0, [textRun(start, ['快'])]))
341
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 30 * 1000, {
342
+ turn: 0,
343
+ step: 0,
344
+ usage: usage(),
345
+ message: {},
346
+ }))
347
+ await tick()
348
+ const usageSample = store.samples.find((sample) => !sample.request)
349
+ assert.equal(usageSample.ttftMs, 0)
350
+ assert.equal(usageSample.durationMs, 30 * 1000)
351
+ })
352
+
353
+ test('时刻倒挂防回拨:首 token 晚于汇报,durationMs 钳 0 不附,ttftMs 照常', async () => {
354
+ const { ctx, store } = liveCollector()
355
+ const start = local(2026, 8, 2, 10, 0)
356
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
357
+ ctx.emit('session/event', session('s1'), attempt(2, start + 50 * 1000, 0, 0, [textRun(start + 50 * 1000, ['晚'])]))
358
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 3 * 1000, {
359
+ turn: 0,
360
+ step: 0,
361
+ usage: usage(),
362
+ message: {},
363
+ }))
364
+ await tick()
365
+ const usageSample = store.samples.find((sample) => !sample.request)
366
+ assert.equal(usageSample.durationMs, undefined)
367
+ assert.equal(usageSample.decodeTokens, undefined)
368
+ assert.equal(usageSample.ttftMs, 50 * 1000)
369
+ })
370
+
371
+ test('时刻倒挂防回拨:首 token 早于起点,ttftMs 钳 0 照常计步,durationMs 照常', async () => {
372
+ const { ctx, store } = liveCollector()
373
+ const start = local(2026, 8, 2, 10, 0)
374
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
375
+ ctx.emit('session/event', session('s1'), attempt(2, start + 1 * 1000, 0, 0, [textRun(start - 5 * 1000, ['回拨'])]))
376
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 3 * 1000, {
377
+ turn: 0,
378
+ step: 0,
379
+ usage: usage(),
380
+ message: {},
381
+ }))
382
+ await tick()
383
+ const usageSample = store.samples.find((sample) => !sample.request)
384
+ assert.equal(usageSample.ttftMs, 0)
385
+ assert.equal(usageSample.durationMs, 8 * 1000)
386
+ })
387
+
388
+ test('usage 缺 outputTokens:不建 decode 配对(官方守卫),ttft 照常', async () => {
389
+ const { ctx, store } = liveCollector()
390
+ const start = local(2026, 8, 2, 10, 0)
391
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
392
+ ctx.emit('session/event', session('s1'), attempt(2, start + 30 * 1000, 0, 0, [textRun(start + 10 * 1000, ['早'])]))
393
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
394
+ turn: 0,
395
+ step: 0,
396
+ usage: usage({ outputTokens: undefined }),
397
+ message: {},
398
+ }))
399
+ await tick()
400
+ const usageSample = store.samples.find((sample) => !sample.request)
401
+ assert.equal(usageSample.outputTokens, 0)
402
+ assert.equal(usageSample.durationMs, undefined)
403
+ assert.equal(usageSample.decodeTokens, undefined)
404
+ assert.equal(usageSample.ttftMs, 10 * 1000)
405
+ })
406
+
407
+ test('重复报告不重复补发 timing:chunk → message → 重复 message 恰一条增量', async () => {
408
+ const { ctx, store } = liveCollector()
409
+ const start = local(2026, 8, 2, 10, 0)
410
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
411
+ ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
412
+ turn: 0,
413
+ step: 0,
414
+ chunk: { type: 'usage', usage: usage() },
415
+ }))
416
+ ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
417
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
418
+ turn: 0,
419
+ step: 0,
420
+ usage: usage({ inputTokens: 999 }),
421
+ message: {},
422
+ }))
423
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 5, start + 41 * 1000, {
424
+ turn: 0,
425
+ step: 0,
426
+ usage: usage(),
427
+ message: {},
428
+ }))
429
+ await tick()
430
+ const timingSamples = store.samples.filter((sample) => !sample.request && sample.decodeTokens !== undefined)
431
+ assert.equal(timingSamples.length, 1)
432
+ assert.equal(timingSamples[0].decodeTokens, 20)
433
+ assert.equal(timingSamples[0].durationMs, 30 * 1000)
434
+ assert.equal(timingSamples[0].ttftMs, 10 * 1000)
435
+ })
436
+
437
+ test('message 首发后到达的 chunk usage 不补 timing', async () => {
438
+ const { ctx, store } = liveCollector()
439
+ const start = local(2026, 8, 2, 10, 0)
440
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
441
+ ctx.emit('session/event', session('s1'), attempt(2, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
442
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 3, start + 40 * 1000, {
443
+ turn: 0,
444
+ step: 0,
445
+ usage: usage(),
446
+ message: {},
447
+ }))
448
+ ctx.emit('session/event', session('s1'), ev('assistant/chunk', 4, start + 41 * 1000, {
449
+ turn: 0,
450
+ step: 0,
451
+ chunk: { type: 'usage', usage: usage() },
452
+ }))
453
+ await tick()
454
+ const usageSamples = store.samples.filter((sample) => !sample.request)
455
+ assert.equal(usageSamples.length, 1)
456
+ assert.equal(usageSamples[0].decodeTokens, 20)
457
+ assert.equal(usageSamples[0].durationMs, 30 * 1000)
458
+ })
459
+
460
+ test('chunk 非零后 message usage 全零仍补发 timing', async () => {
461
+ const { ctx, store } = liveCollector()
462
+ const start = local(2026, 8, 2, 10, 0)
463
+ const zeroUsage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }
464
+ ctx.emit('session/event', session('s1'), ev('step/start', 1, start, { turn: 0, step: 0 }))
465
+ ctx.emit('session/event', session('s1'), ev('assistant/chunk', 2, start + 30 * 1000, {
466
+ turn: 0,
467
+ step: 0,
468
+ chunk: { type: 'usage', usage: usage() },
469
+ }))
470
+ ctx.emit('session/event', session('s1'), attempt(3, start + 35 * 1000, 0, 0, [textRun(start + 10 * 1000, ['好'])]))
471
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 4, start + 40 * 1000, {
472
+ turn: 0,
473
+ step: 0,
474
+ usage: zeroUsage,
475
+ message: {},
476
+ }))
477
+ await tick()
478
+ const timingSamples = store.samples.filter((sample) => !sample.request && sample.ttftMs !== undefined)
479
+ assert.equal(timingSamples.length, 1)
480
+ assert.equal(timingSamples[0].inputTokens, 0)
481
+ assert.equal(timingSamples[0].ttftMs, 10 * 1000)
482
+ assert.equal(timingSamples[0].decodeTokens, 0)
483
+ assert.equal(timingSamples[0].durationMs, 30 * 1000)
484
+ })
485
+
486
+ test('缺 turn/step 的 message 带 stream:首发全量样本含 timing 且不污染去重集合', async () => {
487
+ const { ctx, store } = liveCollector()
488
+ const t = local(2026, 8, 2, 10, 0)
489
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t + 30 * 1000, {
490
+ usage: usage(),
491
+ stream: [textRun(t, ['早'])],
492
+ message: {},
493
+ }))
494
+ await tick()
495
+ assert.equal(store.samples.length, 1)
496
+ assert.equal(store.samples[0].durationMs, 30 * 1000)
497
+ assert.equal(store.samples[0].ttftMs, undefined)
498
+ })
499
+
500
+ test('有首 token 无起点:durationMs 附而 ttftMs 不附', async () => {
501
+ const { ctx, store } = liveCollector()
502
+ const t = local(2026, 8, 2, 10, 0)
503
+ ctx.emit('session/event', session('s1'), attempt(1, t + 10 * 1000, 0, 0, [textRun(t, ['早'])]))
504
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 2, t + 30 * 1000, {
199
505
  turn: 0,
200
506
  step: 0,
201
507
  usage: usage(),
@@ -203,7 +509,23 @@ test('retry-started 重置起点:时长只含最终尝试', async () => {
203
509
  }))
204
510
  await tick()
205
511
  const usageSample = store.samples.find((sample) => !sample.request)
206
- assert.equal(usageSample.durationMs, 5000)
512
+ assert.equal(usageSample.durationMs, 30 * 1000)
513
+ assert.equal(usageSample.ttftMs, undefined)
514
+ })
515
+
516
+ test('无任何 stream 信息不附时长与首字(存量超旧日志形态)', async () => {
517
+ const { ctx, store } = liveCollector()
518
+ const t = local(2026, 8, 2, 10, 0)
519
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 1, t, {
520
+ turn: 0,
521
+ step: 0,
522
+ usage: usage(),
523
+ message: {},
524
+ }))
525
+ await tick()
526
+ assert.equal(store.samples.length, 1)
527
+ assert.equal(store.samples[0].durationMs, undefined)
528
+ assert.equal(store.samples[0].ttftMs, undefined)
207
529
  })
208
530
 
209
531
  test('同时刻零时长不附 durationMs,request 标记样本不带时长', async () => {
@@ -250,6 +572,67 @@ test('四桶全零 usage 为噪声不产样本', async () => {
250
572
  assert.equal(store.samples.length, 0)
251
573
  })
252
574
 
575
+ test('V3 日志兼容:V3 专有事件与新字段被折叠忽略不炸', async () => {
576
+ const { ctx, store } = liveCollector()
577
+ const t = local(2026, 8, 2, 10, 0)
578
+ // V3 新增事件(SESSION_FORMAT_VERSION 3):统计不消费,折叠必须安全忽略
579
+ ctx.emit('session/event', session('s1'), ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }))
580
+ ctx.emit('session/event', session('s1'), ev('assistant/attempt', 2, t, { turn: 0, step: 0, stream: [] }))
581
+ ctx.emit('session/event', session('s1'), ev('request/header', 3, t, { header: { config: {} }, reason: 'initial' }))
582
+ ctx.emit('session/event', session('s1'), ev('user/message', 4, t, { role: 'user', content: 'hi' }, ))
583
+ ctx.emit('session/event', session('s1'), ev('tool/call', 5, t, { turn: 0, step: 0, callId: 'c1', name: 'n', arguments: '{}' }))
584
+ ctx.emit('session/event', session('s1'), ev('tool/result', 6, t, { turn: 0, step: 0, message: { role: 'toolResult', content: 'r' } }))
585
+ ctx.emit('session/event', session('s1'), ev('session/end-seed', 7, t, {}))
586
+ ctx.emit('session/event', session('s1'), ev('step/end', 8, t, { turn: 0, step: 0 }))
587
+ ctx.emit('session/event', session('s1'), ev('turn/start', 9, t, { turn: 1 }))
588
+ // V3 assistant/message 携带 stream/surfaceOp/interrupted 等新字段:usage 折叠不受影响
589
+ ctx.emit('session/event', session('s1'), ev('assistant/message', 10, t, {
590
+ turn: 1,
591
+ step: 0,
592
+ usage: usage({ inputTokens: 5 }),
593
+ message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
594
+ stream: [{ type: 'text-delta', text: 'x' }],
595
+ surfaceOp: 'append',
596
+ interrupted: true,
597
+ }))
598
+ await tick()
599
+ assert.equal(store.samples.length, 1)
600
+ assert.equal(store.samples[0].inputTokens, 5)
601
+ assert.equal(store.samples[0].model, 'p/m')
602
+ })
603
+
604
+ test('V3 日志兼容:回扫重放 V3 事件流,样本与游标正确', async () => {
605
+ const t = local(2026, 8, 2, 14, 0)
606
+ const persistence = fakePersistence([{
607
+ id: 'v3session',
608
+ events: [
609
+ ev('request/header', 0, t, { header: { config: {} }, reason: 'resume' }),
610
+ ev('system/message', 1, t, { turn: 0, step: 0, message: { role: 'system', content: 'sys' } }),
611
+ ev('user/message', 2, t, { role: 'user', content: 'q' }),
612
+ ev('step/start', 3, t, { turn: 0, step: 0 }),
613
+ ev('assistant/attempt', 4, t, { turn: 0, step: 0, stream: [] }),
614
+ ev('assistant/message', 5, t, {
615
+ turn: 0,
616
+ step: 0,
617
+ usage: usage({ inputTokens: 7 }),
618
+ message: { role: 'assistant', source: { provider: 'p', model: 'm' } },
619
+ stream: [],
620
+ }),
621
+ ev('step/end', 6, t, { turn: 0, step: 0 }),
622
+ ev('turn/end', 7, t, { turn: 0, reason: { kind: 'completed' } }),
623
+ ],
624
+ }])
625
+ const store = fakeStore()
626
+ const collector = new UsageCollector(fakeCtx({ persistence }), store)
627
+ await collector.backfill(persistence, fakeSessions())
628
+ assert.deepEqual(store.samples.map((sample) => [sample.request ?? sample.turn ?? false, sample.inputTokens]), [
629
+ [true, 0],
630
+ [false, 7],
631
+ [true, 0],
632
+ ])
633
+ assert.deepEqual([...store.state.backfilledSessions], ['v3session'])
634
+ })
635
+
253
636
  test('纯缓存调用(仅缓存桶非零)仍产样本', async () => {
254
637
  const { ctx, store } = liveCollector()
255
638
  const t = local(2026, 8, 2, 10, 0)
@@ -429,14 +812,15 @@ test('回扫:无边界会话全量重放,归因与游标写回', async () => {
429
812
  assert.equal(collector.status().scannedSessions, 1)
430
813
  })
431
814
 
432
- test('回扫:重放路径同样附加 durationMs', async () => {
815
+ test('回扫:重放路径同样产出 decode 口径时长与首字', async () => {
433
816
  const start = local(2026, 8, 2, 14, 0)
434
817
  const persistence = fakePersistence([{
435
818
  id: 's1',
436
819
  events: [
437
820
  ev('request/context', 0, start, { provider: 'deepseek', model: 'chat' }),
438
821
  ev('step/start', 1, start, { turn: 0, step: 0 }),
439
- ev('assistant/message', 2, start + 7 * 1000, {
822
+ attempt(2, start + 5 * 1000, 0, 0, [textRun(start + 2 * 1000, ['回'])]),
823
+ ev('assistant/message', 3, start + 7 * 1000, {
440
824
  turn: 0,
441
825
  step: 0,
442
826
  usage: usage(),
@@ -448,7 +832,8 @@ test('回扫:重放路径同样附加 durationMs', async () => {
448
832
  const collector = new UsageCollector(fakeCtx({ persistence }), store)
449
833
  await collector.backfill(persistence, fakeSessions())
450
834
  const usageSample = store.samples.find((sample) => !sample.request)
451
- assert.equal(usageSample.durationMs, 7000)
835
+ assert.equal(usageSample.durationMs, 5 * 1000)
836
+ assert.equal(usageSample.ttftMs, 2 * 1000)
452
837
  })
453
838
 
454
839
  test('回扫:已入游标会话不再重扫', async () => {
@@ -578,6 +963,56 @@ test('回扫:record 失败使会话失败并保留游标重试机会', async ()
578
963
  assert.equal(collector.status().done, 1)
579
964
  })
580
965
 
966
+ test('回扫:skipped 计数独立累加,超过日志上限不漂移', async () => {
967
+ const t = local(2026, 8, 2, 14, 0)
968
+ const defs = Array.from({ length: 210 }, (_, i) => ({ id: `bad${i}`, fail: 'uses unsupported descriptor version 2' }))
969
+ const persistence = fakePersistence(defs)
970
+ const store = fakeStore()
971
+ const collector = new UsageCollector(fakeCtx({ persistence }), store)
972
+ await collector.backfill(persistence, fakeSessions())
973
+ assert.equal(collector.status().skippedSessions, 210)
974
+ assert.equal(collector.status().log.length, 200)
975
+ assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 210, corrupt: 0, legacy: 0, other: 0 })
976
+ })
977
+
978
+ test('回扫:skipped 按 detail 归因分类,次轮扫描计数复位', async () => {
979
+ const t = local(2026, 8, 2, 14, 0)
980
+ const persistence = fakePersistence([
981
+ { id: 'a', fail: 'uses unsupported descriptor version 2; source v0 artifact remains unchanged' },
982
+ { id: 'b', fail: 'stored log is corrupt: SessionFormatError: seq gap' },
983
+ { id: 'c', fail: 'format v0 contains unknown member "editor"' },
984
+ { id: 'd', fail: 'mystery failure' },
985
+ { id: 'ok', events: [ev('turn/end', 0, t)] },
986
+ ])
987
+ const store = fakeStore()
988
+ const collector = new UsageCollector(fakeCtx({ persistence }), store)
989
+ await collector.backfill(persistence, fakeSessions())
990
+ assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
991
+ assert.equal(collector.status().skippedSessions, 4)
992
+ // 次轮:失败档不进游标会重现,但本轮从空游标重扫,计数从头累计且包含上一轮成功档
993
+ await collector.backfill(persistence, fakeSessions())
994
+ assert.equal(collector.status().skippedSessions, 4)
995
+ assert.deepEqual(collector.status().skippedBreakdown, { descriptor: 1, corrupt: 1, legacy: 1, other: 1 })
996
+ })
997
+
998
+ test('回扫:list 失败记入 error 并向上传播,running 复位', async () => {
999
+ const failure = new Error('sessionPersistence API 未识别')
1000
+ const persistence = {
1001
+ async list() {
1002
+ throw failure
1003
+ },
1004
+ async readLog() {
1005
+ throw new Error('not reached')
1006
+ },
1007
+ }
1008
+ const store = fakeStore()
1009
+ const collector = new UsageCollector(fakeCtx({ persistence }), store)
1010
+ await assert.rejects(collector.backfill(persistence, fakeSessions()), (error) => error === failure)
1011
+ assert.equal(collector.status().error, failure.message)
1012
+ assert.equal(collector.status().running, false)
1013
+ assert.equal(store.samples.length, 0)
1014
+ })
1015
+
581
1016
  test('abort:已中止 signal 直接返回不进入扫描', async () => {
582
1017
  const persistence = fakePersistence([{ id: 's1', events: [] }])
583
1018
  const store = fakeStore()
@@ -597,9 +1032,9 @@ test('abort:扫描中中断停止处理且不写游标', async () => {
597
1032
  async list() {
598
1033
  return [{ id: 's1' }]
599
1034
  },
600
- async inspect(id) {
1035
+ async readLog() {
601
1036
  controller.abort()
602
- return { meta: { id }, inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
1037
+ return { inheritedEventCount: 0, events: [ev('turn/end', 0, t)] }
603
1038
  },
604
1039
  }
605
1040
  const store = fakeStore()
@@ -625,14 +1060,14 @@ test('回扫并发受默认并发 4 约束', async () => {
625
1060
  async list() {
626
1061
  return defs.map((def) => ({ id: def.id }))
627
1062
  },
628
- async inspect(id) {
1063
+ async readLog(id) {
629
1064
  inFlight += 1
630
1065
  peak = Math.max(peak, inFlight)
631
1066
  if (inFlight >= EXPECTED_CONCURRENCY) release()
632
1067
  await Promise.race([gate, new Promise((resolve) => setTimeout(resolve, GATE_TIMEOUT_MS))])
633
1068
  inFlight -= 1
634
1069
  const def = defs.find((candidate) => candidate.id === id)
635
- return { meta: { id }, inheritedEventCount: 0, events: def.events }
1070
+ return { inheritedEventCount: 0, events: def.events }
636
1071
  },
637
1072
  }
638
1073
  const store = fakeStore()
@@ -645,7 +1080,7 @@ test('回扫并发受默认并发 4 约束', async () => {
645
1080
  test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫', async () => {
646
1081
  const t = local(2026, 8, 2, 9, 0)
647
1082
  const events = Array.from({ length: 13 }, (_, seq) => ev('turn/end', seq, t))
648
- const persistence = fakePersistence([{ id: 'live-a', events }])
1083
+ const persistence = fakeHostPersistence([{ id: 'live-a', events }])
649
1084
  const sessions = fakeSessions([{ id: 'live-a', seq: 10 }, { id: 'live-b' }])
650
1085
  const store = fakeStore()
651
1086
  store.state.liveFirstSeq.set('live-a', 3)
@@ -661,7 +1096,7 @@ test('resetAndRescan 以 wipe 时刻日志长度为活跃会话边界并重扫',
661
1096
 
662
1097
  test('并发 resetAndRescan 合并为一次重建', async () => {
663
1098
  const store = fakeStore()
664
- const collector = new UsageCollector(fakeCtx(), store)
1099
+ const collector = new UsageCollector(fakeCtx({ persistence: fakeHostPersistence() }), store)
665
1100
  const first = collector.resetAndRescan()
666
1101
  const second = collector.resetAndRescan()
667
1102
  await Promise.all([first, second])
@@ -682,8 +1117,12 @@ test('status() 返回快照,外部修改不影响内部状态', async () => {
682
1117
  error: undefined,
683
1118
  recordFailures: 0,
684
1119
  skippedSessions: 0,
1120
+ skippedBreakdown: { descriptor: 0, corrupt: 0, legacy: 0, other: 0 },
685
1121
  log: [],
686
1122
  })
1123
+ const before = collector.status().skippedBreakdown
1124
+ before.descriptor = 99
1125
+ assert.equal(collector.status().skippedBreakdown.descriptor, 0)
687
1126
  await collector.backfill(persistence, fakeSessions())
688
1127
  const snapshot = collector.status()
689
1128
  assert.equal(snapshot.running, false)