dsh-agent-board 1.7.8 → 1.7.9

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.
@@ -2101,9 +2101,10 @@ test('Token 区统计范围接线:get-tasks 接 range 透传 + fetchTasks 带
2101
2101
  // host:get-tasks 把 args.range 透传给聚合(过滤在 host 做——run 级数据只在 host)
2102
2102
  assert.match(host, /aggregateUsageSummary\(d\.tasks, args && args\.range\)/)
2103
2103
  assert.match(host, /runs\[i\]\.usage/) // run 级留账(范围过滤的数据源,先于本次改动已在)
2104
- // client:轮询带上当前统计范围;空范围不传 field(老 host 调用形态逐字不变)
2104
+ // client:轮询带上当前统计范围;空范围传 {} 占位(rpc 助手 args||{} 归一,host 只读 range 字段,
2105
+ // 与老 host 调用形态等价;task-mv492for 起 {} 上再挂 ifHash 做条件请求)
2105
2106
  assert.match(cli, /var rgSend = activeRange\(\)/)
2106
- assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : undefined/)
2107
+ assert.match(cli, /var rpcArgs = \(rgSend\.from \|\| rgSend\.to\) \? \{ range: rgSend \} : \{\}/)
2107
2108
  assert.match(cli, /rpc\('get-tasks', rpcArgs\)/)
2108
2109
  // 范围守卫:响应回来时范围已变则丢弃旧范围的聚合,不覆盖新范围的值
2109
2110
  assert.match(cli, /if \(rgKey === \(rgNow\.from \+ '~' \+ rgNow\.to\)\) state\.usageSummary = \(d && d\.usageSummary\) \|\| null/)
@@ -4226,6 +4227,49 @@ test('task_cancel GUI 按钮门禁:canCancel 三态 + 详情页按钮接线 +
4226
4227
  }
4227
4228
  })
4228
4229
 
4230
+ // ===== 详情面板「取消」消歧(task-mv493xjs,巡检实证 n-mv2turadjv63 误触 task_cancel)=====
4231
+ test('详情面板消歧:「取消」改名「取消任务」+ 危险操作区视觉分层 + ✕ 关闭钮(源码+产物双锁)', () => {
4232
+ const tdsrc = readFileSync(new URL('../lib/client/task-detail.js', import.meta.url), 'utf8')
4233
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
4234
+ // ① 新文案源码锁定:按钮标签为「取消任务」,不再出现裸「取消」按钮标签(ic('ban', 10), '取消')
4235
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消任务'") >= 0, 'task-detail.js 缺「取消任务」新文案')
4236
+ assert.ok(tdsrc.indexOf("ic('ban', 10), '取消')") < 0, 'task-detail.js 仍残留裸「取消」按钮标签')
4237
+ // ② 视觉分层:破坏性按钮独立成区(虚线分隔 + 「危险操作」前缀 + aria-label),与表单按钮组拉开
4238
+ assert.ok(tdsrc.indexOf("'aria-label': '危险操作'") >= 0, 'task-detail.js 缺危险操作区分组')
4239
+ assert.ok(tdsrc.indexOf("borderTop: '1px dashed ' + C.err") >= 0, 'task-detail.js 缺危险区顶部分隔线')
4240
+ assert.ok(tdsrc.indexOf('危险操作') >= 0, 'task-detail.js 缺「危险操作」前缀标签')
4241
+ // ④ ✕ 关闭钮:详情头部右上显性关闭(与「← 返回看板」同行走 state.detailId = null)
4242
+ assert.ok(tdsrc.indexOf("title: '关闭详情面板(返回看板)'") >= 0, 'task-detail.js 缺 ✕ 关闭钮')
4243
+ // ③ 产物重组装:新文案/分层/关闭钮全部进 lib/client.js
4244
+ for (const m of ["ic('ban', 10), '取消任务'", '危险操作', "title: '关闭详情面板(返回看板)'"]) {
4245
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m)
4246
+ }
4247
+ })
4248
+
4249
+ // ===== GUI RPC 操作者回退(task-mv493xjs,巡检次生观察①:流转轨迹 actor 显示 unknown)=====
4250
+ // 面板 HTTP RPC 无 agent 调用栈 → getActorId() 落 'unknown';rpcActor 回退 args 自带 sessionId。
4251
+ test('GUI RPC actor 回退:getActorId=unknown 时流转历史 actor 落会话身份(不再 unknown)', async () => {
4252
+ const t = mkTask({ id: 'g1', status: 'pending' })
4253
+ const board = mkBoard([t])
4254
+ const h = mkRpcHandlers(board, { getActorId: () => 'unknown' })
4255
+ const r = await h['cancel-task']({ taskId: 'g1', reason: 'GUI 误建' })
4256
+ assert.equal(r.ok, true)
4257
+ // actor 不再是 unknown,而是 rpcSessionId(args) 归一出的会话 id(测试桩=FULL_SID)
4258
+ assert.ok(t.history.some(function (x) { return x.to === 'cancelled' && x.actor === FULL_SID }), 'history actor 未回退到会话 id')
4259
+ assert.ok(!t.history.some(function (x) { return x.actor === 'unknown' }), 'history 仍出现 unknown actor')
4260
+ // 正对照:有真实 initiator 时不回退(保持 actor 原值)
4261
+ const t2 = mkTask({ id: 'g2', status: 'pending' })
4262
+ const h2 = mkRpcHandlers(mkBoard([t2]))
4263
+ await h2['cancel-task']({ taskId: 'g2' })
4264
+ assert.ok(t2.history.some(function (x) { return x.to === 'cancelled' && x.actor === 'tester' }), '有 initiator 时 actor 被意外改写')
4265
+ // 归档通道同口径(GUI 高频操作)
4266
+ const t3 = mkTask({ id: 'g3', status: 'resolved' })
4267
+ const h3 = mkRpcHandlers(mkBoard([t3]), { getActorId: () => 'unknown' })
4268
+ const r3 = await h3['archive-task']({ taskId: 'g3' })
4269
+ assert.equal(r3.ok, true)
4270
+ assert.ok(t3.history.some(function (x) { return x.to === 'archived' && x.actor === FULL_SID }), '归档 history actor 未回退到会话 id')
4271
+ })
4272
+
4229
4273
  // ===== 反馈修复(task-mux3uvx3,反馈 n-muw706h1uymy):归档路径触发父卡收口 + task_resolve 守卫对齐 =====
4230
4274
  test('归档双通道触发父卡自动收口:archive-task RPC 与 task_archive 工具同一 archiveApply 口径', async () => {
4231
4275
  // RPC 通道:归档末子卡 → 父卡 in-progress → verifying
@@ -4304,9 +4348,10 @@ test('反馈修复接线断言:index.mjs 注入 hasActiveRun + get-tasks 附 t
4304
4348
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4305
4349
  assert.equal((rpcSrc.match(/archiveErr\(sid, t\)/g) || []).length, 3)
4306
4350
  // kernel:hash 短路分支存在(hash 相同跳过 tasks 赋值 + notify;老 host 无 hash 恒视为变化)
4351
+ // task-mv492for:tasksChanged 叠加 unchanged 闸门(host 条件响应不含 tasks 时绝不清空列表)
4307
4352
  const ksrc = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
4308
4353
  assert.match(ksrc, /var newHash = \(d && d\.tasksHash\) \|\| ''/)
4309
- assert.match(ksrc, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
4354
+ assert.match(ksrc, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
4310
4355
  assert.match(ksrc, /if \(tasksChanged\) \{[\s\S]*?notify\(\)/)
4311
4356
  })
4312
4357
 
@@ -4488,13 +4533,37 @@ test('isRoot 持久语义:host 侧改读 parentSession(根=无父),防
4488
4533
  const rpcSrc = readFileSync(new URL('../lib/rpc.mjs', import.meta.url), 'utf8')
4489
4534
  // isRoot 持久判定入口:根会话=无父,不再读活跃 roots() 集
4490
4535
  assert.match(rpcSrc, /async function isRootPersistent\(sid\)/)
4491
- assert.match(rpcSrc, /snap\.header\) return !snap\.header\.parentSession/)
4536
+ // task-mv59nn9m:判据改用 delegationDepth——无父=根,或 parentSession 有值但 delegationDepth===0(fork/seed 会话)也认根
4537
+ assert.match(rpcSrc, /return !hh\.parentSession \|\| \(typeof hh\.delegationDepth === 'number' && hh\.delegationDepth === 0\)/)
4492
4538
  assert.match(rpcSrc, /d\.isRoot = await isRootPersistent\(sid\)/)
4493
4539
  // 指针注释:防抖仍在客户端 kernel.js applyIsRoot,曾确认 true 需连续 3 次 false 才收
4494
4540
  assert.match(rpcSrc, /防抖仍在客户端:kernel\.js applyIsRoot/)
4495
4541
  assert.match(rpcSrc, /曾确认 true 的会话需连续 3 次/)
4496
4542
  })
4497
4543
 
4544
+ // ===== fork 会话认根(task-mv59nn9m,用户报障:ontology_ai「本体数据平台开发」fork 会话看板不可见)=====
4545
+ // 根因:isRootPersistent stat 主源旧判据 `!header.parentSession` 把 fork/seed 会话
4546
+ //(v3→v4 迁移产物:parentSession 有值 + delegationDepth:0 + isSeeded:true)误判非根 → isRoot:false → 看板入口消失。
4547
+ // 修法:判据改 `!h.parentSession || (typeof h.delegationDepth==='number' && h.delegationDepth===0)`;
4548
+ // delegationDepth 缺省(v3 时代老会话无此字段)且有 parentSession 时维持非根保守判定(兼容 v3 时代子代理)。
4549
+ test('isRootPersistent 判据 delegationDepth(行为级):fork 认根 / 子代理非根 / 老形态保守 / 真根不变', async () => {
4550
+ function mkPersistCtx(header) {
4551
+ return { get: function (name) { if (name === 'sessionPersistence') return { stat: async function () { return { header: header } } }; return undefined } }
4552
+ }
4553
+ // ① fork/seed 头(parentSession 有 + delegationDepth 0)→ 根(本卡修复目标)
4554
+ const h1 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 0 }))
4555
+ assert.equal((await h1['get-tasks']({})).isRoot, true)
4556
+ // ② 真子代理头(parentSession 有 + delegationDepth 1)→ 非根
4557
+ const h2 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent', delegationDepth: 1 }))
4558
+ assert.equal((await h2['get-tasks']({})).isRoot, false)
4559
+ // ③ 老形态头(parentSession 有 + delegationDepth 缺省,v3 时代会话)→ 非根(保守判定不放行)
4560
+ const h3 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({ parentSession: 'session-parent' }))
4561
+ assert.equal((await h3['get-tasks']({})).isRoot, false)
4562
+ // ④ 真根(无 parentSession)→ 根
4563
+ const h4 = mkRpcHandlers(mkBoard([]), null, mkPersistCtx({}))
4564
+ assert.equal((await h4['get-tasks']({})).isRoot, true)
4565
+ })
4566
+
4498
4567
  // ===== 史诗 hooks UI(task-muuw56yf):卡片相位徽章 + 详情页 hooks 编辑区 =====
4499
4568
  // 依赖卡 task-muuw4ov7 落地的真实字段契约(本文件上方 hooks 测试段即其口径):
4500
4569
  // epic.hooks = { pre: { enabled, prompt, state, runId, pending? }, post: 同构 };state ∈ idle|running|done|failed;
@@ -5092,6 +5161,82 @@ test('卡3②③:重启 reconcile——存活的续跑 Worker 被找回重挂
5092
5161
  assert.equal(h.runs['k1'], before)
5093
5162
  })
5094
5163
 
5164
+ // ===== 僵尸 run 留档清扫(task-mv22z61v):落定卡 running 无 endedAt 留档关账 + 清卡面认领位 =====
5165
+ test('僵尸 run 留档清扫:落定卡 running 无 endedAt 留档被关账+清位;在飞卡/活跃 rec 不扫;二次运行零改动', async () => {
5166
+ const nowIso = new Date().toISOString()
5167
+ // ① 僵尸 verifier 留档:resolved 卡 + verifierRun 残留指向死会话 + runs[] 里 verifier:running 无 endedAt
5168
+ const zv = mkTask({ id: 'zv', title: '僵尸 verifier 卡', status: 'resolved', verifierRun: 'v-dead', verifierRunAt: '2026-01-01T00:00:00Z',
5169
+ runs: [{ role: 'verifier', id: 'v-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5170
+ // ② 僵尸 worker 留档:cancelled 卡 + claimedBy 残留指向死会话
5171
+ const zw = mkTask({ id: 'zw', title: '僵尸 worker 卡', status: 'cancelled', claimedBy: 'w-dead', claimedAt: '2026-01-01T00:00:00Z',
5172
+ runs: [{ role: 'worker', id: 'w-dead', at: '2026-01-01T00:00:00Z', model: '', outcome: 'running' }] })
5173
+ // ③ 已关账留档:outcome 已落定 + endedAt,非 running 条目不该被碰(不误扫非 running 留档)
5174
+ const zc = mkTask({ id: 'zc', title: '已关账卡', status: 'resolved',
5175
+ runs: [{ role: 'worker', id: 'w-ok', at: '2026-01-01T00:00:00Z', model: '', outcome: 'completed', endedAt: '2026-01-01T00:05:00Z' }] })
5176
+ // ④ 在飞卡(in-progress):running 留档是 reconcile/结算通道辖区,绝不误扫(断言②)
5177
+ const inflight = mkTask({ id: 'fly', title: '在飞卡', status: 'in-progress', claimedBy: 'w-alive', claimedAt: nowIso,
5178
+ runs: [{ role: 'worker', id: 'w-alive', at: nowIso, model: '', outcome: 'running', continuable: true }] })
5179
+ // ⑤ 在飞卡(verifying)+ 活跃 rec:双重护栏——status 非落定 + 内存活跃表里还活着,都该跳过(断言② + 无活跃 rec 才扫)
5180
+ const vfy = mkTask({ id: 'vfy', title: '验收中卡', status: 'verifying', verifierRun: 'v-alive', verifierRunAt: nowIso,
5181
+ runs: [{ role: 'verifier', id: 'v-alive', at: nowIso, model: '', outcome: 'running' }] })
5182
+ const board = mkBoard([zv, zw, zc, inflight, vfy])
5183
+ const h = mkUsageDispatch(board)
5184
+ h.runs['vfy'] = { id: 'v-alive', role: 'verifier', taskId: 'vfy', startedAt: Date.now(), model: '', settled: false }
5185
+ await h.dispatch.poolCycle(FULL_SID)
5186
+ // ① 僵尸 verifier 留档:关账(stale-closed + endedAt + note)+ 清 verifierRun 位 + 留一行 history
5187
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5188
+ assert.ok(zv.runs[0].endedAt, '僵尸 verifier 留档要带 endedAt')
5189
+ assert.equal(zv.runs[0].note, 'host 重启僵尸留档清扫')
5190
+ assert.equal(zv.verifierRun, null)
5191
+ assert.equal('verifierRunAt' in zv, false)
5192
+ assert.equal(zv.history.filter(function (x) { return /僵尸 run 留档已关账/.test(x.note || '') }).length, 1)
5193
+ // ② 僵尸 worker 留档:关账 + 清 claimedBy 位
5194
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5195
+ assert.ok(zw.runs[0].endedAt)
5196
+ assert.equal(zw.runs[0].note, 'host 重启僵尸留档清扫')
5197
+ assert.equal(zw.claimedBy, null)
5198
+ assert.equal(zw.claimedAt, null)
5199
+ // ③ 已关账留档零改动
5200
+ assert.equal(zc.runs[0].outcome, 'completed')
5201
+ assert.equal(zc.runs[0].endedAt, '2026-01-01T00:05:00Z')
5202
+ assert.equal(zc.runs[0].note, undefined)
5203
+ // ④⑤ 在飞卡 running 留档零改动(断言②:in-progress/verifying 都不扫)
5204
+ assert.equal(inflight.runs[0].outcome, 'running')
5205
+ assert.equal(inflight.runs[0].endedAt, undefined)
5206
+ assert.equal(inflight.claimedBy, 'w-alive')
5207
+ assert.equal(vfy.runs[0].outcome, 'running')
5208
+ assert.equal(vfy.runs[0].endedAt, undefined)
5209
+ assert.equal(vfy.verifierRun, 'v-alive')
5210
+ // ⑤ 幂等:二次运行零改动(关账后 outcome!=running,不再新增 history 行 / 不再刷新 endedAt)
5211
+ const zvEndedAt = zv.runs[0].endedAt
5212
+ const zwEndedAt = zw.runs[0].endedAt
5213
+ const zvHistoryLen = zv.history.length
5214
+ const zwHistoryLen = zw.history.length
5215
+ await h.dispatch.poolCycle(FULL_SID)
5216
+ assert.equal(zv.runs[0].outcome, 'stale-closed')
5217
+ assert.equal(zv.runs[0].endedAt, zvEndedAt)
5218
+ assert.equal(zv.history.length, zvHistoryLen)
5219
+ assert.equal(zw.runs[0].outcome, 'stale-closed')
5220
+ assert.equal(zw.runs[0].endedAt, zwEndedAt)
5221
+ assert.equal(zw.history.length, zwHistoryLen)
5222
+ })
5223
+
5224
+ test('僵尸 run 留档清扫接线(源码级):sweepZombieRuns 定义 + poolCycle 挂点 + 只清扫落定卡门禁', () => {
5225
+ const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5226
+ // ① 清扫函数定义(含「只对落定卡做」状态门禁与「无活跃 rec 才扫」护栏)
5227
+ assert.match(dsp, /async function sweepZombieRuns\(sid, snap\) \{/)
5228
+ assert.match(dsp, /if \(t\.status !== 'resolved' && t\.status !== 'cancelled' && t\.status !== 'archived'\) return/)
5229
+ assert.match(dsp, /if \(rec && !rec\.settled && String\(rec\.id\) === rid\) return/)
5230
+ assert.match(dsp, /r\.outcome = 'stale-closed'/)
5231
+ assert.match(dsp, /r\.endedAt = new Date\(\)\.toISOString\(\)/)
5232
+ assert.match(dsp, /r\.note = 'host 重启僵尸留档清扫'/)
5233
+ assert.match(dsp, /if \(t\.verifierRun === rid\) \{ t\.verifierRun = null; delete t\.verifierRunAt \}/)
5234
+ assert.match(dsp, /if \(t\.claimedBy === rid\) \{ t\.claimedBy = null; t\.claimedAt = null \}/)
5235
+ assert.match(dsp, /return any \? d : null/) // 无改动不写盘(安静板不因清扫产生心跳写盘)
5236
+ // ② poolCycle 挂点:reconcile 之后、幽灵回收之前(落定卡僵尸留档 reconcile/reap 都管不到,须在空闲快进前兜到)
5237
+ assert.match(dsp, /try \{ await sweepZombieRuns\(sid, snap\) \} catch \(e\) \{ console\.error\('\[task-board\] 僵尸留档清扫失败:/)
5238
+ })
5239
+
5095
5240
  test('卡2/卡3 接线(源码级):usage 水位线落卡 + reconcile 一次性 + 详情页 ↻ 续跑标注 + 文档收尾', () => {
5096
5241
  const dsp = readFileSync(new URL('../lib/dispatch.mjs', import.meta.url), 'utf8')
5097
5242
  const usageSrc = readFileSync(new URL('../lib/usage.mjs', import.meta.url), 'utf8')
@@ -5250,8 +5395,9 @@ test('fetchTasks 设置开关权威纠偏:hash 不变时配置变化补 notify
5250
5395
  const iNotify = src.indexOf('if (cfgDelta || usageDelta) notify()')
5251
5396
  const iEsc = src.indexOf('if (tasksChanged) {\n var newEsc')
5252
5397
  assert.ok(iDelta < iNotify && iEsc > iNotify, '补 notify 必须在配置赋值之后、escalation 分支之前')
5253
- // ④ 既有渲染节约(tasksHash 短路)逐字保持——本次只加不删
5254
- assert.match(src, /var tasksChanged = !newHash \|\| newHash !== state\.tasksHash/)
5398
+ // ④ 既有渲染节约(tasksHash 短路)结构保持——task-mv492for 起 tasksChanged 叠加 unchanged 闸门
5399
+ // (host 条件响应 unchanged:true 不含 tasks 全量时保留 state.tasks),hash 比对短路语义不变
5400
+ assert.match(src, /var tasksChanged = !unchanged && \(!newHash \|\| newHash !== state\.tasksHash\)/)
5255
5401
  assert.match(src, /if \(tasksChanged\) \{\s*\n\s*state\.tasksHash = newHash\s*\n\s*state\.tasks = \(d && d\.tasks\) \|\| \[\]\s*\n\s*\}/)
5256
5402
  // ⑤ 产物接线:client.js 为拼装产物,真源改动必须已重建(产物与源一致由 build-client --check 兜底)
5257
5403
  const cli = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
@@ -10,7 +10,8 @@
10
10
  // · subagents.startContinuable({...}) → { childId, messageId }(持久子会话,turn 结算走 emitStatus)
11
11
  // · subagents.sendMessage / interrupt / listChildren → 记录器 + 可剧本化
12
12
  // · agents.currentInitiator()/roots()/list()/get()/isOwnedBy() → 按测试剧本返回(缺省单 root,actor=root;env.setRoots 可清空模拟休眠)
13
- // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话)
13
+ // · ctx.get('sessionPersistence') → 持久会话表 stat(id)(env.persistedHeaders 可写 parentSession 模拟子会话/根会话;
14
+ // env.persistedSizes[id]=N 可让快照带 sizeBytes——cleanup 体积主源断言用)
14
15
  // · tools.register / webServer.register → 捕获工具表与 RPC 路由(env.rpc 走真 readBody/handler 路径)
15
16
  // · ctx.get('systemPrompt'|'llm') → 记录器(注册了什么段/查了什么模型都留痕)
16
17
  // · ctx.effect / ctx.on('agent/status') → 真实执行 + disposer 收口 / 事件捕获与重放
@@ -51,6 +52,7 @@ import { Readable } from 'node:stream'
51
52
  * @property {string} actor currentInitiator 返回值(缺省=root sid;测试可改写模拟子代理身份)
52
53
  * @property {(list:Object[])=>void} setRoots 改写活跃 root 集(传 [] 模拟休眠根会话/重建窗口)
53
54
  * @property {Object} persistedHeaders 持久会话表 sid → { id, parentSession }(写 parentSession 模拟子会话;缺省根会话 parentSession=undefined)
55
+ * @property {Object} persistedSizes 快照体积表 sid → sizeBytes 数字(cleanup 体积主源断言用;缺省=快照无 sizeBytes → 目录遍历回退)
54
56
  * @property {()=>Object} board 读当前看板文件 JSON(不存在返回 null)
55
57
  * @property {(id:string)=>Object|undefined} task 看板里按 id 取任务
56
58
  * @property {(fn:Function, label?:string, timeoutMs?:number)=>Promise<any>} waitFor 真实时间轮询直到条件为真(默认 5s 上限)
@@ -194,6 +196,7 @@ export function createMockCtx(opts) {
194
196
  var rootsList = [root] // 活跃 root 集(测试可 env.setRoots([]) 清空模拟「休眠根会话」)
195
197
  var persistedHeaders = {} // 持久会话表 sid → SessionHeader(测试可写 parentSession 模拟子会话/根会话)
196
198
  persistedHeaders[sid] = { id: sid, parentSession: undefined }
199
+ var persistedSizes = {} // 快照体积表 sid → sizeBytes(task-mv39ag1e:cleanup 体积主源断言用;缺省=快照无 sizeBytes,走目录遍历回退)
197
200
  var agents = {
198
201
  currentInitiator: function () { return { id: env.actor } },
199
202
  roots: function () { return rootsList },
@@ -237,7 +240,8 @@ export function createMockCtx(opts) {
237
240
  }
238
241
  if (name === 'sessionPersistence') {
239
242
  return {
240
- stat: async function (id) { var h = persistedHeaders[id]; return h ? { header: h } : undefined },
243
+ // 快照形态对齐官方 SessionPersistenceSnapshot:sizeBytes 仅当后端能廉价提供时才带(未登记=缺省字段)
244
+ stat: async function (id) { var h = persistedHeaders[id]; if (!h) return undefined; var snap = { header: h }; if (typeof persistedSizes[id] === 'number') snap.sizeBytes = persistedSizes[id]; return snap },
241
245
  }
242
246
  }
243
247
  return undefined
@@ -274,6 +278,7 @@ export function createMockCtx(opts) {
274
278
  actor: sid,
275
279
  setRoots: function (list) { rootsList = list || [] }, // 清空/改写活跃 root 集(模拟休眠/重建窗口)
276
280
  persistedHeaders: persistedHeaders, // 持久会话表(sid → { id, parentSession });写 parentSession 模拟子会话
281
+ persistedSizes: persistedSizes, // 快照体积表(sid → sizeBytes 数字);cleanup 体积主源断言用
277
282
 
278
283
  // 以真 RPC 语义调用捕获的 webServer 路由(POST /dsh-agent-board → readBody → handlers[method])
279
284
  rpc: async function (method, args) {
@@ -0,0 +1,76 @@
1
+ // dsh-agent-board — 界面缩放档位 client 侧单测(test/ui-zoom-client.test.mjs,task-mv58e01j)
2
+ // 背景:用户 2026-10-12 反馈「字体整体偏小」;面板 ~317 处内联 fontSize px 写死,逐处改不值 →
3
+ // 定稿方案=面板根容器 CSS zoom 等比缩放(Chromium 支持,字体/图标/间距一并放大,只影响本面板)。
4
+ // client 无渲染 harness(react 不在包依赖,渲染由 dsh web 客户端运行时注入)——与历卡同一纪律:
5
+ // 全部走源码级断言(kernel.js / dashboard.js 模块源 + client.js 重组装产物 + README 双份同步)。
6
+ // ① 状态与持久化——state.uiZoom(localStorage 键 dsh-agent-board-ui-zoom,白名单五档,脏值/异常回退 1)
7
+ // ② 根容器接线——TopPanel 顶层 div style 挂 zoom: uiZoom(订阅值,notify 即时生效)
8
+ // ③ 设置弹层档位——⚙ 弹层「界面」小节 ZoomCfg 下拉:90/100/110/125/150%,选中即 setItem+state+notify
9
+ // ④ 弹层坐标补偿——zoom 子树内 fixed 弹层 top/right/maxH 除以档位还原 CSS px(仍贴按钮、不溢出视口)
10
+ // ⑤ README 双份——根/包逐字一致 + 看板 UI 区补「界面缩放」说明行
11
+ // ⑥ 产物重组装——关键标记逐一出现在 lib/client.js(需 npm run build-client)
12
+ import { test } from 'node:test'
13
+ import assert from 'node:assert/strict'
14
+ import { readFileSync } from 'node:fs'
15
+
16
+ const kernel = readFileSync(new URL('../lib/client/kernel.js', import.meta.url), 'utf8')
17
+ const dash = readFileSync(new URL('../lib/client/dashboard.js', import.meta.url), 'utf8')
18
+ const built = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8')
19
+
20
+ // ===== ① 状态与持久化(kernel state + localStorage 读取,缺省 1,白名单校验)=====
21
+ test('①状态:UI_ZOOM_KEY/UI_ZOOM_LEVELS 常量 + readUiZoom 白名单回退 + state.uiZoom 初始化', () => {
22
+ assert.ok(kernel.indexOf("var UI_ZOOM_KEY = 'dsh-agent-board-ui-zoom'") >= 0, '缺 localStorage 键常量')
23
+ assert.match(kernel, /var UI_ZOOM_LEVELS = \[0\.9, 1, 1\.1, 1\.25, 1\.5\]/, '缺五档白名单(90/100/110/125/150%)')
24
+ // 读取:parseFloat + 白名单命中才采纳,脏值/异常一律回退 1
25
+ assert.match(kernel, /function readUiZoom\(\) \{ try \{ var v = parseFloat\(localStorage\.getItem\(UI_ZOOM_KEY\)\); return UI_ZOOM_LEVELS\.indexOf\(v\) >= 0 \? v : 1 \} catch \(_\) \{ return 1 \} \}/)
26
+ // state 字段初始化(读 localStorage,不开口在 host)
27
+ assert.match(kernel, /verifyUserGuide: true, uiZoom: readUiZoom\(\)/)
28
+ })
29
+
30
+ // ===== ② 根容器接线(TopPanel 顶层 div style.zoom + 订阅链)=====
31
+ test('②接线:TopPanel 订阅 uiZoom(useState + update 链)+ 根容器 style 挂 zoom', () => {
32
+ // 订阅:useState 初值读 state.uiZoom,update 链里 setUiZoom(state.uiZoom)(notify 即时生效的渲染通路)
33
+ assert.match(kernel, /useState\(state\.uiZoom\), uiZoom = _uzm\[0\], setUiZoom = _uzm\[1\]/)
34
+ assert.match(kernel, /setCrd\(state\.cleanupRetentionDays\); setUiZoom\(state\.uiZoom\)/)
35
+ // 根容器(面板顶层 fixed div)style 挂 zoom——只此一行接管全面板缩放,不动任何既有 fontSize
36
+ assert.match(kernel, /zIndex: 900, zoom: uiZoom, background: C\.bg/)
37
+ })
38
+
39
+ // ===== ③ 设置弹层档位(⚙ 弹层「界面」小节 ZoomCfg 下拉,选中即持久化+生效)=====
40
+ test('③档位:ZoomCfg 下拉五档 + 选中即 localStorage.setItem + state.uiZoom + notify + 弹层「界面」小节挂载', () => {
41
+ assert.match(dash, /function ZoomCfg\(\)/, '缺 ZoomCfg 组件')
42
+ // 五档选项由白名单渲染,默认档标注
43
+ assert.match(dash, /UI_ZOOM_LEVELS\.map\(function \(z\) \{ return React\.createElement\('option', \{ key: String\(z\), value: String\(z\) \}, Math\.round\(z \* 100\) \+ '%' \+ \(z === 1 \? '(默认)' : ''\)\) \}/)
44
+ // 选中口径:非法档位不采纳 → 写 state → localStorage 持久化 → notify 即时生效
45
+ assert.match(dash, /if \(UI_ZOOM_LEVELS\.indexOf\(n\) < 0\) return/)
46
+ assert.match(dash, /state\.uiZoom = n/)
47
+ assert.match(dash, /localStorage\.setItem\(UI_ZOOM_KEY, String\(n\)\)/)
48
+ // 弹层「界面」小节挂载 ZoomCfg(存储清理之后、弹层收尾之前)
49
+ assert.match(dash, /'界面'\),\s*React\.createElement\('div', \{ style: \{ display: 'flex', gap: 8, alignItems: 'center' \} \},\s*React\.createElement\(ZoomCfg, null\)/)
50
+ assert.ok(dash.indexOf('界面缩放') >= 0, '弹层缺「界面缩放」标签')
51
+ })
52
+
53
+ // ===== ④ 弹层坐标补偿(zoom 子树内 fixed 弹层的 top/right/maxH 还原)=====
54
+ test('④补偿:⚙ 弹层定位除以缩放档位(getBoundingClientRect 可视坐标 → CSS px),zoom=1 时与旧行为一致', () => {
55
+ assert.match(dash, /var z = state\.uiZoom \|\| 1/)
56
+ assert.match(dash, /setPos\(\{ top: \(r\.bottom \+ 4\) \/ z, right: Math\.max\(8, window\.innerWidth - r\.right\) \/ z, maxH: Math\.max\(140, \(window\.innerHeight - r\.bottom - 16\) \/ z\) \}\)/)
57
+ // maxHeight 改读补偿后的 pos.maxH(不再用未补偿的 calc(100vh - ...))
58
+ assert.match(dash, /maxHeight: \(pos\.maxH \|\| 300\) \+ 'px'/)
59
+ })
60
+
61
+ // ===== ⑤ README 双份 =====
62
+ test('⑤README 双份:根/包逐字一致 + 看板 UI 区补「界面缩放」说明行(档位/键名/即时生效)', () => {
63
+ const r1 = readFileSync(new URL('../../../README.md', import.meta.url), 'utf8')
64
+ const r2 = readFileSync(new URL('../README.md', import.meta.url), 'utf8')
65
+ assert.equal(r1, r2, 'README 双份必须逐字一致(npm run sync-readme)')
66
+ assert.ok(r1.indexOf('界面缩放') >= 0, 'README 缺「界面缩放」说明行')
67
+ assert.ok(r1.indexOf('dsh-agent-board-ui-zoom') >= 0, 'README 说明行缺 localStorage 键名')
68
+ assert.ok(r1.indexOf('90% / 100%(默认)/ 110% / 125% / 150%') >= 0, 'README 说明行缺五档清单')
69
+ })
70
+
71
+ // ===== ⑥ 产物重组装 =====
72
+ test('⑥产物:模块源关键标记逐一出现在 lib/client.js(需 npm run build-client 重组装)', () => {
73
+ for (const m of ['dsh-agent-board-ui-zoom', 'UI_ZOOM_LEVELS', 'readUiZoom', 'zoom: uiZoom', 'ZoomCfg', '界面缩放']) {
74
+ assert.ok(built.indexOf(m) >= 0, 'client.js 缺标记:' + m + '(需 npm run build-client 重组装)')
75
+ }
76
+ })