dsh-agent-board 1.7.2 → 1.7.4
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.
- package/README.md +31 -13
- package/index.mjs +13 -5
- package/lib/client/dashboard.js +120 -39
- package/lib/client/kernel.js +44 -6
- package/lib/client/task-detail.js +7 -3
- package/lib/client.js +171 -48
- package/lib/core.mjs +61 -29
- package/lib/dispatch.mjs +920 -226
- package/lib/rpc.mjs +127 -18
- package/lib/store.mjs +26 -1
- package/lib/usage.mjs +323 -20
- package/package.json +1 -1
- package/test/core.test.mjs +2066 -107
- package/test/usage-mainwindow.test.mjs +230 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// dsh-agent-board — 主窗口 Token 消耗展示项单测(test/usage-mainwindow.test.mjs,task-muwsol23)
|
|
2
|
+
// 覆盖(对应任务断言①~⑥ + README):
|
|
3
|
+
// ① 增量尾读——同文件追加后只读增量字节段(readSync 字节计数);无变化零读;截断全量重读
|
|
4
|
+
// ② 聚合五分量 + e=input+output+cacheWrite 口径 + byDay 本地日落点 + 尾部半帧不结算(下轮补读不丢不重)
|
|
5
|
+
// ③ 范围裁剪——byDay/累计三分量随 range(本地日闭区间);空范围=无范围(parity);出参副本不污染缓存
|
|
6
|
+
// ④ 隔离红线——mainWindow 不进 Top8/累计/模型归属(挂上后逐字不变);聚合本体永不产出 mainWindow 键
|
|
7
|
+
// ⑤ 失败静默降级——日志不存在/无 usage/垃圾字节/无缓存槽 → null 或旧值,绝不抛
|
|
8
|
+
// ⑥ client/host 接线源码断言——单列行 / 模型分布尾部条(0 不渲染)/ caption / get-tasks 挂载
|
|
9
|
+
import { test } from 'node:test'
|
|
10
|
+
import assert from 'node:assert/strict'
|
|
11
|
+
import fs from 'node:fs'
|
|
12
|
+
import os from 'node:os'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
import zlib from 'node:zlib'
|
|
15
|
+
import { readFileSync } from 'node:fs'
|
|
16
|
+
import { readMainWindowUsage, aggregateUsageSummary } from '../index.mjs'
|
|
17
|
+
import { localDayKey } from '../lib/usage.mjs'
|
|
18
|
+
|
|
19
|
+
const zstdOk = typeof zlib.zstdCompressSync === 'function'
|
|
20
|
+
// 一帧追加写:与 v4 日志同形(多帧 zstd 顺序拼接,帧内 jsonl)
|
|
21
|
+
function frameOf(events) { return zlib.zstdCompressSync(Buffer.from(events.map(function (e) { return JSON.stringify(e) }).join('\n') + '\n', 'utf8')) }
|
|
22
|
+
// 主会话日志根:布局与真实一致 <root>/<bucket>/<sid>/session.v4.jsonl.zstd(findRunLog 按桶探测)
|
|
23
|
+
function mkMainLog(sid, chunks) {
|
|
24
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-mw-'))
|
|
25
|
+
const dir = path.join(root, 'bucket', sid)
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
27
|
+
const logPath = path.join(dir, 'session.v4.jsonl.zstd')
|
|
28
|
+
if (chunks && chunks.length) fs.writeFileSync(logPath, Buffer.concat(chunks))
|
|
29
|
+
return { root: root, logPath: logPath, append: function (b) { fs.appendFileSync(logPath, b) } }
|
|
30
|
+
}
|
|
31
|
+
// 事件时间戳(epoch ms):本地构造保证落点与 localDayKey 口径一致(与机器时区无关)
|
|
32
|
+
function msOf(y, m, d, h) { return new Date(y, m - 1, d, h === undefined ? 12 : h, 0, 0).getTime() }
|
|
33
|
+
function dayOf(y, m, d, h) { return localDayKey(new Date(msOf(y, m, d, h))) }
|
|
34
|
+
// 一条 assistant/message usage 事件(time 是 v4 事件自带的全序时间戳字段)
|
|
35
|
+
function amEvent(ms, usage) { return { type: 'assistant/message', time: ms, data: { usage: usage } } }
|
|
36
|
+
function U(inp, outp, cr, cw) { return { inputTokens: inp, outputTokens: outp, cacheReadTokens: cr, cacheWriteTokens: cw, totalTokens: inp + outp + cr + cw } }
|
|
37
|
+
|
|
38
|
+
// ===== ① 增量尾读(字节计数)=====
|
|
39
|
+
test('主窗口①:增量尾读——追加后只读新增字节段、无变化零读、截断全量重读(readSync 字节计数)', (t) => {
|
|
40
|
+
if (!zstdOk) { t.skip('zstdCompressSync unavailable'); return }
|
|
41
|
+
const L = mkMainLog('sid-mw1', [frameOf([amEvent(msOf(2026, 10, 5, 10), U(100, 7, 1000, 0))])])
|
|
42
|
+
try {
|
|
43
|
+
const cache = {}
|
|
44
|
+
// 字节计数:node:fs 的默认导出是共享 CJS 对象,包装 readSync 即覆盖 usage.mjs 的 fsNode.readSync
|
|
45
|
+
const origRead = fs.readSync
|
|
46
|
+
let bytes = 0
|
|
47
|
+
fs.readSync = function (fd, buf, off, len, pos) { bytes += len; return origRead.apply(fs, arguments) }
|
|
48
|
+
try {
|
|
49
|
+
const sz1 = fs.statSync(L.logPath).size
|
|
50
|
+
const r1 = readMainWindowUsage('sid-mw1', L.root, null, cache)
|
|
51
|
+
assert.equal(bytes, sz1) // 首轮全量读
|
|
52
|
+
assert.equal(r1.total, 1107); assert.equal(r1.effective, 107); assert.equal(r1.cacheRead, 1000)
|
|
53
|
+
// 第二轮无变化 → 零读直接用缓存(3s 轮询稳态零全量重读的硬保证)
|
|
54
|
+
bytes = 0
|
|
55
|
+
const r1b = readMainWindowUsage('sid-mw1', L.root, null, cache)
|
|
56
|
+
assert.equal(bytes, 0)
|
|
57
|
+
assert.deepEqual(r1b, r1)
|
|
58
|
+
// 追加一帧 → 只读新增字节段(增量尾读),聚合 = 旧账 + 增量(不重复计账)
|
|
59
|
+
const add = frameOf([amEvent(msOf(2026, 10, 6, 9), U(3, 4, 0, 5))])
|
|
60
|
+
L.append(add)
|
|
61
|
+
bytes = 0
|
|
62
|
+
const r2 = readMainWindowUsage('sid-mw1', L.root, null, cache)
|
|
63
|
+
assert.equal(bytes, add.length) // 增量段字节数,不是全文件
|
|
64
|
+
assert.equal(r2.total, 1119) // 1107 + 12(若全量重读重复计账会变 2226)
|
|
65
|
+
assert.equal(r2.effective, 119) // 107 + (3+4+5)
|
|
66
|
+
assert.equal(r2.cacheRead, 1000)
|
|
67
|
+
// 截断(日志轮换/文件变小)→ 缓存作废,全量重读一次
|
|
68
|
+
const fresh = frameOf([amEvent(msOf(2026, 10, 7, 8), U(1, 1, 1, 1))])
|
|
69
|
+
fs.writeFileSync(L.logPath, fresh)
|
|
70
|
+
bytes = 0
|
|
71
|
+
const r3 = readMainWindowUsage('sid-mw1', L.root, null, cache)
|
|
72
|
+
assert.equal(bytes, fresh.length)
|
|
73
|
+
assert.equal(r3.total, 4) // 旧账随轮换清零重算(不是 1119+4)
|
|
74
|
+
assert.equal(r3.effective, 3)
|
|
75
|
+
} finally { fs.readSync = origRead }
|
|
76
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// ===== ② 聚合口径 + byDay 日落点 + 尾部半帧 =====
|
|
80
|
+
test('主窗口②:五分量 + e=输入+输出+缓存写 + byDay 本地日落点;尾部半帧不结算(补全后整帧只记一次)', (t) => {
|
|
81
|
+
if (!zstdOk) { t.skip('zstdCompressSync unavailable'); return }
|
|
82
|
+
const d1 = dayOf(2026, 10, 5, 10), d2 = dayOf(2026, 10, 6, 23)
|
|
83
|
+
const f1 = frameOf([
|
|
84
|
+
{ type: 'request/context', time: msOf(2026, 10, 5, 9), data: { model: 'm-x' } }, // 非 assistant/message:不计
|
|
85
|
+
amEvent(msOf(2026, 10, 5, 10), U(10, 5, 100, 5)), // d1:t=120 e=20 cr=100
|
|
86
|
+
amEvent(msOf(2026, 10, 6, 23), U(1, 2, 3, 4)) // d2:t=10 e=7 cr=3
|
|
87
|
+
])
|
|
88
|
+
const L = mkMainLog('sid-mw2', [f1])
|
|
89
|
+
try {
|
|
90
|
+
const cache = {}
|
|
91
|
+
const r = readMainWindowUsage('sid-mw2', L.root, null, cache)
|
|
92
|
+
assert.equal(r.total, 130) // 120 + 10(总量含缓存读)
|
|
93
|
+
assert.equal(r.effective, 27) // (10+5+5) + (1+2+4):缓存读不进有效口径
|
|
94
|
+
assert.equal(r.cacheRead, 103)
|
|
95
|
+
assert.deepEqual(r.byDay, { [d1]: { t: 120, e: 20, cr: 100 }, [d2]: { t: 10, e: 7, cr: 3 } })
|
|
96
|
+
// 尾部半帧(写入进行中):解压必失败 → 不结算,聚合值原地不动
|
|
97
|
+
const good = frameOf([amEvent(msOf(2026, 10, 6, 23, 30), U(2, 2, 0, 1))]) // t=5 e=5
|
|
98
|
+
L.append(good.subarray(0, good.length - 6))
|
|
99
|
+
const r2 = readMainWindowUsage('sid-mw2', L.root, null, cache)
|
|
100
|
+
assert.equal(r2.total, 130)
|
|
101
|
+
assert.equal(r2.effective, 27)
|
|
102
|
+
// 补全该帧 → 从半帧起点重读,整帧恰好记一次(不丢不重)
|
|
103
|
+
L.append(good.subarray(good.length - 6))
|
|
104
|
+
const r3 = readMainWindowUsage('sid-mw2', L.root, null, cache)
|
|
105
|
+
assert.equal(r3.total, 135)
|
|
106
|
+
assert.equal(r3.effective, 32)
|
|
107
|
+
assert.deepEqual(r3.byDay[d2], { t: 15, e: 12, cr: 3 })
|
|
108
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ===== ③ 范围裁剪 =====
|
|
112
|
+
test('主窗口③:范围裁剪——byDay/累计三分量随 range(本地日闭区间);空范围=无范围(parity);出参副本不污染缓存', (t) => {
|
|
113
|
+
if (!zstdOk) { t.skip('zstdCompressSync unavailable'); return }
|
|
114
|
+
const d1 = dayOf(2026, 10, 5, 10), d2 = dayOf(2026, 10, 6, 23)
|
|
115
|
+
const L = mkMainLog('sid-mw3', [frameOf([
|
|
116
|
+
amEvent(msOf(2026, 10, 5, 10), U(10, 5, 100, 5)),
|
|
117
|
+
amEvent(msOf(2026, 10, 6, 23), U(1, 2, 3, 4))
|
|
118
|
+
])])
|
|
119
|
+
try {
|
|
120
|
+
const cache = {}
|
|
121
|
+
const all = readMainWindowUsage('sid-mw3', L.root, null, cache)
|
|
122
|
+
assert.equal(all.total, 130)
|
|
123
|
+
// 范围只留 d2:三分量由入选日重算,byDay 只剩 d2(与 run 口径同一 dayInRange 两端闭区间)
|
|
124
|
+
const cropped = readMainWindowUsage('sid-mw3', L.root, { from: d2, to: d2 }, cache)
|
|
125
|
+
assert.equal(cropped.total, 10)
|
|
126
|
+
assert.equal(cropped.effective, 7)
|
|
127
|
+
assert.equal(cropped.cacheRead, 3)
|
|
128
|
+
assert.deepEqual(cropped.byDay, { [d2]: { t: 10, e: 7, cr: 3 } })
|
|
129
|
+
// 范围全落空:0 值对象(不是 null——会话确有消耗,只是都在范围外)
|
|
130
|
+
const miss = readMainWindowUsage('sid-mw3', L.root, { from: '2020-01-01', to: '2020-01-02' }, cache)
|
|
131
|
+
assert.deepEqual(miss, { total: 0, effective: 0, cacheRead: 0, byDay: {} })
|
|
132
|
+
// 空范围与不传等价(parity,与 aggregateUsageSummary 同一约定)
|
|
133
|
+
assert.deepEqual(readMainWindowUsage('sid-mw3', L.root, { from: '', to: '' }, cache), all)
|
|
134
|
+
// 缓存里存的是未裁剪全量:范围请求之后再读全量仍正确(裁剪发生在出参,不毁缓存)
|
|
135
|
+
const again = readMainWindowUsage('sid-mw3', L.root, null, cache)
|
|
136
|
+
assert.equal(again.total, 130)
|
|
137
|
+
// 出参是副本:改出参 byDay 不污染缓存聚合
|
|
138
|
+
cropped.byDay[d2].t = 999999
|
|
139
|
+
assert.equal(readMainWindowUsage('sid-mw3', L.root, null, cache).total, 130)
|
|
140
|
+
} finally { fs.rmSync(L.root, { recursive: true, force: true }) }
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// ===== ④ 隔离红线(parity)=====
|
|
144
|
+
test('主窗口④:隔离红线——挂上 mainWindow 后 Top8/累计/模型归属逐字不变;聚合本体永不产出 mainWindow 键', () => {
|
|
145
|
+
const tasks = [
|
|
146
|
+
{ id: 'a', title: 'A', usage: { total: 290, input: 9, output: 19, cacheRead: 252, cacheWrite: 10, runs: 2, models: { 'm-x': 290 }, updatedAt: '2026-10-06T02:00:00.000Z', byDay: { '2026-10-05': { t: 112, e: 12 }, '2026-10-06': { t: 178, e: 26 } } } },
|
|
147
|
+
{ id: 'b', title: 'B', usage: { total: 30, input: 1, output: 1, cacheRead: 28, cacheWrite: 0, runs: 1, models: { 'm-y': 30 }, updatedAt: '2026-10-06T03:00:00.000Z', byDay: { '2026-10-06': { t: 30, e: 2 } } } }
|
|
148
|
+
]
|
|
149
|
+
const before = aggregateUsageSummary(tasks)
|
|
150
|
+
// host 接线形态(rpc.mjs get-tasks):聚合完成后才挂 mainWindow
|
|
151
|
+
const after = aggregateUsageSummary(tasks)
|
|
152
|
+
after.mainWindow = { total: 999999, effective: 8888, cacheRead: 7777, byDay: { '2026-10-06': { t: 999999, e: 8888, cr: 7777 } } }
|
|
153
|
+
// Top8 / 本看板累计 / 模型归属逐字不变(隔离红线)
|
|
154
|
+
assert.deepEqual(after.topTasks, before.topTasks)
|
|
155
|
+
assert.equal(after.total, before.total)
|
|
156
|
+
assert.equal(after.effective, before.effective)
|
|
157
|
+
assert.equal(after.cacheRead, before.cacheRead)
|
|
158
|
+
assert.deepEqual(after.byModel, before.byModel)
|
|
159
|
+
assert.deepEqual(after.byModelEff, before.byModelEff)
|
|
160
|
+
assert.deepEqual(after.byDay, before.byDay)
|
|
161
|
+
assert.deepEqual(after.byDayFull, before.byDayFull)
|
|
162
|
+
// aggregateUsageSummary 本体永不产出 mainWindow 键(隔离在聚合层就成立,范围路径同样)
|
|
163
|
+
assert.equal('mainWindow' in before, false)
|
|
164
|
+
assert.equal('mainWindow' in aggregateUsageSummary(tasks, { from: '2026-10-06', to: '2026-10-06' }), false)
|
|
165
|
+
// 源码级:mainWindow 挂载必须发生在 run 口径聚合之后
|
|
166
|
+
const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
167
|
+
const iAgg = rpc.indexOf('d.usageSummary = aggregateUsageSummary(d.tasks, args && args.range)')
|
|
168
|
+
const iMw = rpc.indexOf('d.usageSummary.mainWindow =')
|
|
169
|
+
assert.ok(iAgg >= 0 && iMw > iAgg, 'mainWindow 必须在 aggregateUsageSummary 之后挂载(隔离顺序)')
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// ===== ⑤ 失败静默降级 =====
|
|
173
|
+
test('主窗口⑤:失败静默降级——日志不存在/无 usage/垃圾字节/无缓存槽 → null 或旧值,绝不抛', (t) => {
|
|
174
|
+
if (!zstdOk) { t.skip('zstdCompressSync unavailable'); return }
|
|
175
|
+
// 日志不存在 / 空会话 id → null(客户端不渲染该行)
|
|
176
|
+
assert.equal(readMainWindowUsage('no-such-session-' + Date.now(), undefined, null, {}), null)
|
|
177
|
+
assert.equal(readMainWindowUsage('', undefined, null, {}), null)
|
|
178
|
+
// 只有非 usage 事件 → null(不渲染,与 readRunUsage 的 hasUsage 同哲学)
|
|
179
|
+
const L1 = mkMainLog('sid-nouse', [frameOf([{ type: 'step/end', time: Date.now(), data: {} }])])
|
|
180
|
+
try { assert.equal(readMainWindowUsage('sid-nouse', L1.root, null, {}), null) } finally { fs.rmSync(L1.root, { recursive: true, force: true }) }
|
|
181
|
+
// 垃圾字节(非 zstd 帧)→ null,不抛
|
|
182
|
+
const L2 = mkMainLog('sid-garbage', null)
|
|
183
|
+
fs.writeFileSync(L2.logPath, Buffer.from('not a zstd stream at all'))
|
|
184
|
+
try { assert.equal(readMainWindowUsage('sid-garbage', L2.root, null, {}), null) } finally { fs.rmSync(L2.root, { recursive: true, force: true }) }
|
|
185
|
+
// 无 cacheStore(undefined)也能工作:每次全量读,不炸(老宿主/直接调用兜底)
|
|
186
|
+
const L3 = mkMainLog('sid-nocache', [frameOf([amEvent(msOf(2026, 10, 5, 10), U(1, 1, 0, 0))])])
|
|
187
|
+
try {
|
|
188
|
+
const r = readMainWindowUsage('sid-nocache', L3.root, null, undefined)
|
|
189
|
+
assert.equal(r.total, 2)
|
|
190
|
+
assert.equal(r.effective, 2)
|
|
191
|
+
} finally { fs.rmSync(L3.root, { recursive: true, force: true }) }
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// ===== ⑥ client/host 接线(源码级断言)=====
|
|
195
|
+
test('主窗口⑥:接线(源码级)——get-tasks 挂载 / 增量尾读三态 / 单列行 + 模型分布尾部条 + caption', () => {
|
|
196
|
+
const rpc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
|
|
197
|
+
const usage = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
|
|
198
|
+
const idx = readFileSync(new URL('../index.mjs', import.meta.url), 'utf8')
|
|
199
|
+
const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8') // 组装产物(pretest 已重建)
|
|
200
|
+
// host:get-tasks 在 run 口径聚合之后挂载 mainWindow(带统计范围 + 增量尾读缓存)
|
|
201
|
+
assert.match(rpc, /readMainWindowUsage\(sid, sessionsRoot, args && args\.range, state\.mainWindowUsageCache\)/)
|
|
202
|
+
assert.match(rpc, /if \(__mw\) d\.usageSummary\.mainWindow = __mw/) // null 不挂字段(客户端静默不渲染)
|
|
203
|
+
assert.match(idx, /mainWindowUsageCache: \{\}/) // 缓存本体在共享 state
|
|
204
|
+
// usage.mjs:增量尾读三态 + 半帧不结算
|
|
205
|
+
assert.match(usage, /export function readMainWindowUsage\(sessionId, sessionsRoot, range, cacheStore\)/)
|
|
206
|
+
assert.match(usage, /if \(acc && st\.size < acc\.size\) acc = null/) // 变小/轮换 → 全量重读
|
|
207
|
+
assert.match(usage, /st\.size > acc\.size/) // 变大 → 增量臂
|
|
208
|
+
assert.match(usage, /readLogBytes\(log, st\.size - acc\.size\)/) // 只读新增字节段
|
|
209
|
+
assert.match(usage, /if \(k === offs\.length - 1\) break/) // 尾部半帧不结算
|
|
210
|
+
// client:缺字段静默降级 + 累计行下方单列行
|
|
211
|
+
assert.match(cli, /u\.mainWindow && typeof u\.mainWindow === 'object'/)
|
|
212
|
+
assert.match(cli, /'主窗口(本会话):今日有效 '/)
|
|
213
|
+
assert.match(cli, /' · 累计 '/)
|
|
214
|
+
assert.match(cli, /'(缓存读 '/)
|
|
215
|
+
// client:模型分布尾部追加「主窗口(对话)」(0 不渲染,固定尾部不参与排序)
|
|
216
|
+
assert.match(cli, /label: '主窗口(对话)'/)
|
|
217
|
+
assert.match(cli, /mw && mw\.effective > 0/)
|
|
218
|
+
// caption:并列不混入的口径说明
|
|
219
|
+
assert.match(cli, /主窗口行=本会话对话消耗,与看板派发口径并列不混入/)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
// ===== README 双份记录 =====
|
|
223
|
+
test('主窗口⑦:README 双份记录主窗口行口径(增量尾读 / 隔离红线 / 范围裁剪 / 静默降级)', () => {
|
|
224
|
+
const pkg = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
|
|
225
|
+
const root = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
|
|
226
|
+
assert.equal(pkg, root) // 两份 README 必须字节一致(npm run sync-readme 的约束)
|
|
227
|
+
for (const s of ['主窗口(本会话)', '主窗口(对话)', '增量尾读', '不进 Top 8', '不含主窗口对话', '静默降级']) {
|
|
228
|
+
assert.ok(pkg.includes(s), 'README 应记录主窗口行口径:' + s)
|
|
229
|
+
}
|
|
230
|
+
})
|