dsh-activity-pane 0.7.0 → 0.8.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.
package/scripts/check.mjs CHANGED
@@ -19,10 +19,10 @@ import {
19
19
  cardSignature,
20
20
  cleanPreview,
21
21
  clampPaneWidth,
22
+ chatLatestAssistantSettled,
22
23
  pagedHistoryEvents,
23
24
  delegationActive,
24
25
  progressAnchor,
25
- detailLoadPlan,
26
26
  conversationWorkItems,
27
27
  conversationTimelineFromHistory,
28
28
  foldWorkGroups,
@@ -30,7 +30,6 @@ import {
30
30
  foldedHistoryTimeline,
31
31
  historyInstructionAnchor,
32
32
  openTurnStartFromEvents,
33
- openTurnStartMissing,
34
33
  escapeCssString,
35
34
  firstPhysicalLine,
36
35
  fmtAbsoluteDateTime,
@@ -54,12 +53,18 @@ import {
54
53
  movedToRecentIds,
55
54
  movedToActiveIds,
56
55
  modelMetadata,
57
- needsHistorySnapshot,
56
+ modelFromHistoryEvents,
57
+ reasoningEffortFromHistoryEvents,
58
+ catalogModelEntries,
58
59
  lastTurnEndFromEvents,
59
60
  lastTurnEndFromTimings,
60
61
  lastTurnDurationFromEvents,
61
62
  lastTurnDurationFromTimings,
62
63
  lastTurnDuration,
64
+ applyTurnEventToStats,
65
+ reconcileTurnStats,
66
+ normalizeBusyMs,
67
+ totalBusyDisplayMs,
63
68
  pendingText,
64
69
  progressHalfLifeSec,
65
70
  progressOf,
@@ -204,6 +209,14 @@ unbindCard();
204
209
  assert.equal(cardListeners.size, 0, "card 卸载移除 click/keydown 监听");
205
210
 
206
211
  // ---- R-01-005/AC-01 回归:移动端切换会话后抑制原生 composer 自动聚焦(不弹软键盘) ----
212
+ // 宿主 DOM 契约(dsh-client-ui-conversation ComposerContentEditable):composer 输入面是
213
+ // Lexical contenteditable div,显式标记 data-composer-input。旧 textarea[data-phase] 在宿主
214
+ // DOM 上零命中会使抑制整体退化为空操作(软键盘回归根因),故钉住选择器防再漂移。
215
+ assert.equal(
216
+ COMPOSER_SELECTOR,
217
+ "[data-composer-input]",
218
+ "composer 选择器须锚定宿主 contenteditable div 的显式标记 data-composer-input",
219
+ );
207
220
  const composerEl = {
208
221
  focused: true,
209
222
  matches: (selector) => selector === COMPOSER_SELECTOR,
@@ -330,76 +343,113 @@ assert.deepEqual(
330
343
  buildEntries(subagentUnderFork, [], {}, null, null, ["source"]).map((entry) => entry.id),
331
344
  ["fork", "nested-subagent"],
332
345
  "归档 fork 来源不影响独立 fork 及其真实子代理子树");
333
- // models/history 加载决策行为链:首读 → 在途不重发 → 失败置空后可见期内不热重试 → 离开可见清理后重回可重试
334
- const loadDetail = {};
346
+ // ---- R-01-012/AC-17、AC-18 子代理模型溯源:history 尾扫最近一条携带 source.model 的 assistant/message ----
347
+ const subModelEvents = [
348
+ { event: { type: "turn/start", seq: 1, data: { turn: 1 } } },
349
+ { event: { type: "assistant/message", seq: 2, data: { turn: 1, step: 1, message: { role: "assistant", source: { kind: "model", provider: "deepseek", model: "model-a" } } } } },
350
+ { event: { type: "tool/call", seq: 3, data: { turn: 1, step: 1, callId: "c1", name: "bash", arguments: "{}" } } },
351
+ { event: { type: "assistant/message", seq: 4, data: { turn: 1, step: 2, message: { role: "assistant", source: { kind: "model", provider: "deepseek", model: "model-b" } } } } },
352
+ ];
335
353
  assert.deepEqual(
336
- detailLoadPlan({ detail: loadDetail }),
337
- { subagent: false, model: true, history: false },
338
- "冷会话首次决策发起 models 读取",
354
+ modelFromHistoryEvents(subModelEvents),
355
+ { model: "model-b", reasoning: "" },
356
+ "子代理模型溯源取最近一条携带 source.model 的 assistant/message(R-01-012/AC-17)",
339
357
  );
340
- assert.equal(
341
- detailLoadPlan({ detail: loadDetail, modelInflight: true }).model,
342
- false,
343
- "读取在途时不重复发起",
344
- );
345
- loadDetail.model = { model: "", reasoning: "" };
346
- assert.equal(detailLoadPlan({ detail: loadDetail }).model, false, "失败置空后可见期内不热重试");
347
- assert.equal(detailLoadPlan({ detail: {} }).model, true, "离开可见清理后重回可见允许重试");
348
358
  assert.deepEqual(
349
- detailLoadPlan({ detail: {}, isSubagent: true }),
350
- { subagent: true, model: false, history: false },
351
- "子代理不发起 models 读取",
359
+ modelFromHistoryEvents([{ type: "assistant/message", seq: 9, data: { message: { source: { provider: "p", model: "bare" } } } }]),
360
+ { model: "bare", reasoning: "" },
361
+ "裸事件形态与 {event} 包装同样兼容(R-01-012/AC-17)",
352
362
  );
363
+ assert.equal(modelFromHistoryEvents([]), null, "空事件流无溯源,保持空白(R-01-012/AC-18)");
353
364
  assert.equal(
354
- detailLoadPlan({ detail: {}, historyNeeded: true }).history,
355
- true,
356
- "无快照冷会话决策发起 history 读取",
365
+ modelFromHistoryEvents([{ event: { type: "user/message", seq: 1, data: { message: { content: [] } } } }]),
366
+ null,
367
+ "无 assistant/message 时不得以其它事件冒充模型(R-01-012/AC-18)",
357
368
  );
358
369
  assert.equal(
359
- detailLoadPlan({ detail: {}, historyNeeded: true, snapshotReady: true }).history,
360
- false,
361
- "原生快照已就绪且窗口数据齐全时不发 history 读取",
370
+ modelFromHistoryEvents([{ event: { type: "assistant/message", seq: 2, data: { message: { source: { provider: "p" } } } } }]),
371
+ null,
372
+ "source.model 缺失时不得冒充(R-01-012/AC-18)",
362
373
  );
363
- // R-01-013/AC-03、AC-04:最近卡窗口快照缺用户或 agent 预览时补读一次 history。
364
374
  assert.equal(
365
- detailLoadPlan({ detail: { history: [{ event: { seq: 1 } }] }, snapshotReady: true, previewFallbackNeeded: true }).history,
366
- true,
367
- "最近卡预览不完整时即使已有早到 history 也重新补读一次",
375
+ modelFromHistoryEvents([null, { event: undefined }, { event: { type: "assistant/message", seq: 3, data: { turn: 1, step: 1, message: { role: "assistant", source: { provider: "p", model: "ok" } } } } }]).model,
376
+ "ok",
377
+ "畸形条目跳过不抛错,命中项照常提取(R-01-012/AC-17)",
368
378
  );
379
+
380
+ // ---- R-01-012/AC-17 子代理模型读取触发信号:快照最新助手节点已定案即触发 ----
381
+ const settledAssistantSnapshot = {
382
+ chat: {
383
+ order: ["k1", "k2", "k3", "k4"],
384
+ nodes: new Map([
385
+ ["k1", { kind: "user", data: { content: [] } }],
386
+ ["k2", { kind: "assistant-step", data: { status: "settled", turn: 1, step: 1, blocks: [] } }],
387
+ ["k3", { kind: "tool-call", data: { status: "settled" } }],
388
+ ["k4", { kind: "tool-call", data: { status: "running" } }],
389
+ ]),
390
+ },
391
+ };
392
+ assert.equal(chatLatestAssistantSettled(settledAssistantSnapshot), true, "最新助手节点已定案即触发模型读取(R-01-012/AC-17)");
369
393
  assert.equal(
370
- detailLoadPlan({ detail: { history: [], previewFallbackLoaded: true }, snapshotReady: true, previewFallbackNeeded: true }).history,
394
+ chatLatestAssistantSettled({
395
+ chat: {
396
+ order: ["k1", "k2"],
397
+ nodes: new Map([
398
+ ["k1", { kind: "user", data: { content: [] } }],
399
+ ["k2", { kind: "assistant-step", data: { status: "running", turn: 1, step: 2, blocks: [] } }],
400
+ ]),
401
+ },
402
+ }),
371
403
  false,
372
- "最近卡预览 fallback 已尝试后可见期内不热重试",
404
+ "最新助手节点仍在流式(未定案)时不触发,避免早读扑空(R-01-012/AC-18)",
373
405
  );
374
- // R-01-009/AC-12:等待卡转入 awaiting 后,即使旧 history 已加载也必须补读最新边界。
375
- assert.equal(
376
- detailLoadPlan({ detail: { history: [{ event: { seq: 1 } }] }, snapshotReady: true, durationFallbackNeeded: true }).history,
377
- true,
378
- "等待卡耗时 fallback 在已有旧 history 时仍发起一次最新 history 读取",
406
+ assert.equal(chatLatestAssistantSettled(null), false, "无快照不触发(R-01-012/AC-18)");
407
+ assert.equal(chatLatestAssistantSettled({ chat: { order: [], nodes: new Map() } }), false, "空窗口不触发(R-01-012/AC-18)");
408
+
409
+ // ---- R-01-012/AC-17 目录分组解析显示名与 effort:provider 模型 id → {name, reasoning} 索引 ----
410
+ assert.deepEqual(
411
+ catalogModelEntries([
412
+ { id: "g1", models: [{ id: "glm-5.3-flash-512k", name: "(6000D) GLM-5.3-Flash 512K", reasoning: "high" }, { id: "m2", name: "模型乙" }] },
413
+ { id: "g2", models: [{ id: "m3", name: "模型丙", reasoning: "low" }] },
414
+ ]),
415
+ { "glm-5.3-flash-512k": { name: "(6000D) GLM-5.3-Flash 512K", reasoning: "high" }, m2: { name: "模型乙", reasoning: "" }, m3: { name: "模型丙", reasoning: "low" } },
416
+ "多分组展平为 id→{name, reasoning} 索引(R-01-012/AC-17)",
417
+ );
418
+ assert.deepEqual(
419
+ catalogModelEntries([null, {}, { models: null }, { models: [null, {}, { id: "", name: "x" }, { id: "ok", name: "" }, { id: "good", name: "好", reasoning: "medium" }] }]),
420
+ { good: { name: "好", reasoning: "medium" } },
421
+ "畸形分组与畸形条目跳过,不抛错(R-01-012/AC-18)",
379
422
  );
423
+ assert.deepEqual(catalogModelEntries(null), {}, "目录缺失返回空索引,调用方回退显示原始溯源 id(R-01-012/AC-18)");
424
+
425
+ // ---- R-01-012/AC-17 reasoning effort 提取:history 尾扫最新一条 request/header 配置 ----
426
+ const headerEvent = (config) => ({ event: { type: "request/header", seq: 1, data: { header: { config } } } });
380
427
  assert.equal(
381
- detailLoadPlan({ detail: { history: [], durationFallbackLoaded: true }, snapshotReady: true, durationFallbackNeeded: true }).history,
382
- false,
383
- "等待卡耗时 fallback 已尝试后可见期内不重复读取",
428
+ reasoningEffortFromHistoryEvents([
429
+ { event: { type: "user/message", seq: 0, data: {} } },
430
+ headerEvent({ provider: "p", model: "m", reasoningEffort: "low" }),
431
+ { event: { type: "assistant/message", seq: 2, data: { message: { source: { provider: "p", model: "ok" } } } } },
432
+ ]),
433
+ "low",
434
+ "请求头事件的 config.reasoningEffort 提取(R-01-012/AC-17)",
384
435
  );
385
- // R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底:快照就绪但窗口缺锚点数据(开放回合起点/用户行在窗口外)时补读 history
386
436
  assert.equal(
387
- detailLoadPlan({ detail: {}, snapshotReady: true, windowComplete: false }).history,
388
- true,
389
- "快照就绪但窗口缺锚点数据时发起 history 补读",
437
+ reasoningEffortFromHistoryEvents([headerEvent({ provider: "p", model: "m", reasoningEffort: "low" }), headerEvent({ provider: "p", model: "m", reasoningEffort: "high" })]),
438
+ "high",
439
+ "多条请求头取最新纪元(R-01-012/AC-17)",
390
440
  );
391
441
  assert.equal(
392
- detailLoadPlan({ detail: {}, snapshotReady: true, windowComplete: true }).history,
393
- false,
394
- "快照窗口锚点数据齐全时不发 history 读取",
442
+ reasoningEffortFromHistoryEvents([headerEvent({ provider: "p", model: "m", reasoningEffort: "low" }), headerEvent({ provider: "p", model: "m" })]),
443
+ null,
444
+ "最新请求头未声明 effort 即停,不回扫已废弃纪元(R-01-012/AC-18)",
395
445
  );
446
+ assert.equal(reasoningEffortFromHistoryEvents([]), null, "空事件流无 effort(R-01-012/AC-18)");
396
447
  assert.equal(
397
- detailLoadPlan({ detail: { history: [] }, snapshotReady: true, windowComplete: false }).history,
398
- false,
399
- "窗口补读失败置空后可见期内不热重试",
448
+ reasoningEffortFromHistoryEvents([null, { event: undefined }, { event: { type: "request/header", seq: 3, data: {} } }]),
449
+ null,
450
+ "畸形条目与缺失 header 跳过不抛错(R-01-012/AC-18)",
400
451
  );
401
452
 
402
- // ---- R-01-012/AC-16 模型选择切换经目录订阅推送更新,一次性读取仅作初值 ----
403
453
  // 目录 store 快照形状({current, groups, routable, status, ...})与 RPC value 同形兼容,经同一归一。
404
454
  assert.deepEqual(
405
455
  modelMetadata({
@@ -1044,6 +1094,278 @@ assert.equal(fmtElapsedMs(3_599_500), "1h0m0s", "秒数四舍五入进位到小
1044
1094
  assert.equal(fmtElapsedMs(NaN), "", "NaN 时长归一为空");
1045
1095
  assert.equal(fmtElapsedMs(Infinity), "", "Infinity 时长归一为空");
1046
1096
  assert.equal(fmtElapsedMs(-1), "", "负时长归一为空");
1097
+ // ---- R-01-020 会话累计运行时长:事件流配对记账与显示合成 ----
1098
+ // R-01-020/AC-02:全部结束原因均计入,回合间空闲不计入。
1099
+ {
1100
+ const events = [
1101
+ { type: "turn/start", seq: 1, time: 1_000 },
1102
+ { type: "turn/end", seq: 2, time: 3_000 }, // completed:+2s
1103
+ { type: "turn/start", seq: 3, time: 10_000 },
1104
+ { type: "turn/end", seq: 4, time: 10_500, data: { reason: { kind: "aborted" } } }, // 中断:+0.5s
1105
+ { type: "turn/start", seq: 5, time: 20_000 },
1106
+ { type: "turn/end", seq: 6, time: 21_000, data: { reason: { kind: "error" } } }, // 错误:+1s
1107
+ ];
1108
+ let stats = { busyMs: null, openTurnStart: null };
1109
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1110
+ assert.equal(stats.busyMs, 3_500, "R-01-020/AC-02 全部结束原因计入、空闲不计入");
1111
+ assert.equal(stats.openTurnStart, null, "R-01-020/AC-02 全部回合闭合后无开放起点");
1112
+ }
1113
+ // R-01-020/AC-02:起点缺失或逆序的回合跳过累加,不制造负值。
1114
+ {
1115
+ let stats = { busyMs: null, openTurnStart: null };
1116
+ stats = applyTurnEventToStats(stats, { type: "turn/end", seq: 1, time: 5_000 });
1117
+ assert.equal(stats.busyMs, null, "R-01-020/AC-02 孤儿 turn/end 不累加");
1118
+ stats = applyTurnEventToStats(stats, { type: "turn/start", seq: 2, time: 8_000 });
1119
+ stats = applyTurnEventToStats(stats, { type: "turn/end", seq: 3, time: 7_000 });
1120
+ assert.equal(stats.busyMs, null, "R-01-020/AC-02 时间逆序回合跳过");
1121
+ assert.equal(stats.openTurnStart, null, "R-01-020/AC-02 逆序回合仍清空起点");
1122
+ }
1123
+ // R-01-020/AC-04:水位幂等——转移函数为纯累加,重复事件必须由调用侧 seq ≤ watermark
1124
+ // 检查跳过(host.mjs 实时登记与回填写入前均做该检查);同批事件重复应用会翻倍。
1125
+ {
1126
+ const replay = [
1127
+ { type: "turn/start", seq: 7, time: 1_000 },
1128
+ { type: "turn/end", seq: 8, time: 2_000 },
1129
+ ];
1130
+ let stats = { busyMs: null, openTurnStart: null };
1131
+ for (const event of replay) stats = applyTurnEventToStats(stats, event);
1132
+ assert.equal(stats.busyMs, 1_000, "R-01-020/AC-04 单次应用配对累计");
1133
+ // 模拟调用侧水位:seq ≤ watermark 的事件跳过,重复推送不重复计数。
1134
+ for (const event of replay) {
1135
+ if (event.seq <= 8) continue;
1136
+ stats = applyTurnEventToStats(stats, event);
1137
+ }
1138
+ assert.equal(stats.busyMs, 1_000, "R-01-020/AC-04 水位检查下重复推送不重复计数");
1139
+ }
1140
+ // R-01-020/AC-01
1141
+ // R-01-020/AC-03:开放回合实时增量与起点不可得降级。
1142
+ assert.equal(totalBusyDisplayMs({ busyMs: 3_500, openTurnStart: 30_000, now: 32_000 }), 5_500, "累计加开放回合实时已耗时");
1143
+ assert.equal(totalBusyDisplayMs({ busyMs: 3_500, openTurnStart: null, now: 32_000 }), 3_500, "起点不可得只显示已完成累计");
1144
+ assert.equal(totalBusyDisplayMs({ busyMs: null, openTurnStart: 30_000, now: 31_000 }), 1_000, "首回合进行中显示实时增量");
1145
+ // R-01-020/AC-06:无任何有效计时数据返回 null(调用方不显示,不以 0 冒充)。
1146
+ assert.equal(totalBusyDisplayMs({ busyMs: null, openTurnStart: null, now: 32_000 }), null, "无数据显示 null");
1147
+ assert.equal(totalBusyDisplayMs({ busyMs: null, openTurnStart: null, now: null }), null, "now 缺失且无累计为 null");
1148
+ assert.equal(totalBusyDisplayMs({ busyMs: 0, openTurnStart: null, now: null }), 0, "显式 0 累计仍是有效数据");
1149
+ // R-01-020/AC-06:记账字段归一——null/undefined 直通为 null,不得被 Number(null)===0
1150
+ // 误归一为 0(否则开放回合合成出 now−0 的天文数字)。
1151
+ assert.equal(normalizeBusyMs(null), null, "busyMs null 归一为 null");
1152
+ assert.equal(normalizeBusyMs(undefined), null, "busyMs undefined 归一为 null");
1153
+ assert.equal(normalizeBusyMs(null), null, "openTurnStart null 归一为 null");
1154
+ assert.equal(normalizeBusyMs(0), 0, "显式 0 仍是有效数字");
1155
+ assert.equal(normalizeBusyMs("123"), 123, "数字字符串可归一");
1156
+ assert.equal(normalizeBusyMs("abc"), null, "非数字字符串归一为 null");
1157
+ assert.equal(normalizeBusyMs(Number.NaN), null, "NaN 归一为 null");
1158
+ // R-01-020/AC-05:存量回合经全量事件重放补齐(与实时登记同一转移函数)。
1159
+ {
1160
+ const backfillEvents = [
1161
+ { type: "turn/start", seq: 1, time: 100 },
1162
+ { type: "turn/end", seq: 2, time: 600 },
1163
+ { type: "turn/start", seq: 3, time: 900 },
1164
+ ];
1165
+ let stats = { busyMs: null, openTurnStart: null };
1166
+ for (const event of backfillEvents) stats = applyTurnEventToStats(stats, event);
1167
+ assert.equal(stats.busyMs, 500, "R-01-020/AC-05 存量回合重放补齐累计");
1168
+ assert.equal(stats.openTurnStart, 900, "R-01-020/AC-05 重放恢复开放回合起点");
1169
+ // 水位缺口增量(宿主 ensureTurnStatsFresh 已有记录路径):从已有记账出发,
1170
+ // 仅应用 seq > watermark 的事件,补停机缺口且不重复计数(R-01-020/AC-04、AC-05)。
1171
+ const gapEvents = [
1172
+ { type: "turn/start", seq: 1, time: 100 },
1173
+ { type: "turn/end", seq: 2, time: 600 },
1174
+ { type: "turn/start", seq: 3, time: 900 },
1175
+ { type: "turn/end", seq: 4, time: 1_400 },
1176
+ ];
1177
+ const gapState = { busyMs: 500, openTurnStart: null };
1178
+ const watermark = 2;
1179
+ let gapResult = gapState;
1180
+ for (const event of gapEvents) {
1181
+ if (event.seq <= watermark) continue;
1182
+ gapResult = applyTurnEventToStats(gapResult, event);
1183
+ }
1184
+ assert.equal(gapResult.busyMs, 1_000, "R-01-020/AC-05 增量重放补齐水位缺口回合");
1185
+ assert.equal(gapResult.openTurnStart, null, "R-01-020/AC-05 增量重放后无开放回合");
1186
+ }
1187
+ // ---- R-01-020/AC-07:回合内阻塞等待配对记账与停表 ----
1188
+ // 审批等待:approval/asked–decided 配对,回合结算扣除等待区间。
1189
+ {
1190
+ const events = [
1191
+ { type: "turn/start", seq: 1, time: 1_000 },
1192
+ { type: "approval/asked", seq: 2, time: 2_000, data: { id: "a1", toolName: "bash" } },
1193
+ { type: "approval/decided", seq: 3, time: 5_000, data: { id: "a1", outcome: "allowed-once" } },
1194
+ { type: "turn/end", seq: 4, time: 6_000, data: { reason: { kind: "completed" } } },
1195
+ ];
1196
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1197
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1198
+ assert.equal(stats.busyMs, 2_000, "R-01-020/AC-07 回合结算扣除审批等待 3s");
1199
+ assert.equal(stats.openWaitStart, null, "R-01-020/AC-07 回合结束后无未配对等待");
1200
+ assert.equal(stats.waitedMs, null, "R-01-020/AC-07 回合结束后等待累计清零");
1201
+ }
1202
+ // 提问等待:ask_user_question 的 tool/call–tool/result 配对。
1203
+ {
1204
+ const events = [
1205
+ { type: "turn/start", seq: 1, time: 1_000 },
1206
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c1", name: "ask_user_question" } },
1207
+ { type: "tool/result", seq: 3, time: 7_000, data: { message: { source: { kind: "tool", callId: "c1" } } } },
1208
+ { type: "turn/end", seq: 4, time: 8_000, data: { reason: { kind: "completed" } } },
1209
+ ];
1210
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1211
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1212
+ assert.equal(stats.busyMs, 2_000, "R-01-020/AC-07 回合结算扣除提问等待 5s");
1213
+ }
1214
+ // 非提问工具调用不开等待;kind 不一致的结算事件忽略。
1215
+ {
1216
+ const events = [
1217
+ { type: "turn/start", seq: 1, time: 1_000 },
1218
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c2", name: "bash" } },
1219
+ ];
1220
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1221
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1222
+ assert.equal(stats.openWaitStart, null, "R-01-020/AC-07 非提问工具调用不开等待");
1223
+ const mismatch = [
1224
+ { type: "tool/call", seq: 3, time: 2_000, data: { callId: "c3", name: "ask_user_question" } },
1225
+ { type: "approval/decided", seq: 4, time: 3_000, data: { id: "a9", outcome: "allowed-once" } },
1226
+ ];
1227
+ for (const event of mismatch) stats = applyTurnEventToStats(stats, event);
1228
+ assert.equal(stats.openWaitStart, 2_000, "R-01-020/AC-07 kind 不一致的结算事件不关闭提问等待");
1229
+ assert.equal(stats.openWaitKind, "question", "R-01-020/AC-07 提问等待保持开启");
1230
+ const settle = [{ type: "tool/result", seq: 5, time: 4_000, data: { message: { source: { kind: "tool", callId: "c3" } } } }];
1231
+ for (const event of settle) stats = applyTurnEventToStats(stats, event);
1232
+ assert.equal(stats.waitedMs, 2_000, "R-01-020/AC-07 提问等待按配对边界结算");
1233
+ }
1234
+ // callId/id 不匹配的结算事件忽略,等待保持开启直至配对边界(DESIGN:同 callId 结算)。
1235
+ {
1236
+ const events = [
1237
+ { type: "turn/start", seq: 1, time: 1_000 },
1238
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c1", name: "ask_user_question" } },
1239
+ { type: "tool/result", seq: 3, time: 3_000, data: { message: { source: { kind: "tool", callId: "cX" } } } },
1240
+ ];
1241
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
1242
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1243
+ assert.equal(stats.openWaitStart, 2_000, "R-01-020/AC-07 callId 不匹配的 tool/result 不关闭提问等待");
1244
+ assert.equal(stats.openWaitId, "c1", "R-01-020/AC-07 等待记录配对 callId");
1245
+ const settle = [{ type: "tool/result", seq: 4, time: 4_000, data: { message: { source: { kind: "tool", callId: "c1" } } } }];
1246
+ for (const event of settle) stats = applyTurnEventToStats(stats, event);
1247
+ assert.equal(stats.waitedMs, 2_000, "R-01-020/AC-07 同 callId 的 result 结算等待");
1248
+ }
1249
+ {
1250
+ const events = [
1251
+ { type: "turn/start", seq: 1, time: 1_000 },
1252
+ { type: "approval/asked", seq: 2, time: 2_000, data: { id: "a1", toolName: "bash" } },
1253
+ { type: "approval/decided", seq: 3, time: 3_000, data: { id: "aX", outcome: "allowed-once" } },
1254
+ ];
1255
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, openWaitId: null, waitedMs: null };
1256
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1257
+ assert.equal(stats.openWaitStart, 2_000, "R-01-020/AC-07 id 不匹配的 approval/decided 不关闭审批等待");
1258
+ const settle = [{ type: "approval/decided", seq: 4, time: 5_000, data: { id: "a1", outcome: "rejected" } }];
1259
+ for (const event of settle) stats = applyTurnEventToStats(stats, event);
1260
+ assert.equal(stats.waitedMs, 3_000, "R-01-020/AC-07 同 id 的 decided 结算审批等待");
1261
+ }
1262
+ // 等待串行不嵌套:未配对等待期间的新等待边界忽略。
1263
+ {
1264
+ const events = [
1265
+ { type: "turn/start", seq: 1, time: 1_000 },
1266
+ { type: "approval/asked", seq: 2, time: 2_000, data: { id: "a1", toolName: "bash" } },
1267
+ { type: "approval/asked", seq: 3, time: 2_500, data: { id: "a2", toolName: "write" } },
1268
+ { type: "approval/decided", seq: 4, time: 4_000, data: { id: "a1", outcome: "rejected" } },
1269
+ ];
1270
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1271
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1272
+ assert.equal(stats.waitedMs, 2_000, "R-01-020/AC-07 串行等待自首个边界起算、不重复计时");
1273
+ }
1274
+ // turn/end 强制结算未配对等待:等待尾部不落到运行时长里。
1275
+ {
1276
+ const events = [
1277
+ { type: "turn/start", seq: 1, time: 1_000 },
1278
+ { type: "approval/asked", seq: 2, time: 2_000, data: { id: "a1", toolName: "bash" } },
1279
+ { type: "turn/end", seq: 3, time: 10_000, data: { reason: { kind: "aborted" } } },
1280
+ ];
1281
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1282
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1283
+ assert.equal(stats.busyMs, 1_000, "R-01-020/AC-07 回合结束强制结算未配对等待");
1284
+ assert.equal(stats.openTurnStart, null, "R-01-020/AC-07 强制结算后回合闭合");
1285
+ }
1286
+ // 换回合重置等待累计。
1287
+ {
1288
+ const events = [
1289
+ { type: "turn/start", seq: 1, time: 1_000 },
1290
+ { type: "tool/call", seq: 2, time: 2_000, data: { callId: "c1", name: "ask_user_question" } },
1291
+ { type: "tool/result", seq: 3, time: 3_000, data: { message: { source: { kind: "tool", callId: "c1" } } } },
1292
+ { type: "turn/end", seq: 4, time: 3_500, data: { reason: { kind: "completed" } } },
1293
+ { type: "turn/start", seq: 5, time: 9_000 },
1294
+ ];
1295
+ let stats = { busyMs: null, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null };
1296
+ for (const event of events) stats = applyTurnEventToStats(stats, event);
1297
+ assert.equal(stats.busyMs, 1_500, "R-01-020/AC-07 首回合结算扣除提问等待");
1298
+ assert.equal(stats.waitedMs, null, "R-01-020/AC-07 新回合重置等待累计");
1299
+ }
1300
+ // 显示合成(AC-07):等待期间冻结——now 推进而显示值不变。
1301
+ {
1302
+ const state = { busyMs: 1_000, openTurnStart: 5_000, waitedMs: 2_000, openWaitStart: 9_000 };
1303
+ const frozen = totalBusyDisplayMs({ ...state, now: 10_000 });
1304
+ assert.equal(frozen, 3_000, "R-01-020/AC-07 等待开始时刻的运行时长被冻结显示");
1305
+ assert.equal(totalBusyDisplayMs({ ...state, now: 20_000 }), frozen, "R-01-020/AC-07 等待期间显示值不随 now 增长");
1306
+ }
1307
+ // 显示合成(AC-07):等待结算与回合结算处显示值连续,恢复运行后续走。
1308
+ {
1309
+ // 回合 1000 起,等待 2000–4000(waitedMs=2000);now=5000 时显示 2000。
1310
+ const running = { busyMs: 1_000, openTurnStart: 1_000, waitedMs: 2_000, openWaitStart: null };
1311
+ assert.equal(totalBusyDisplayMs({ ...running, now: 5_000 }), 3_000, "R-01-020/AC-07 等待结束后从冻结值继续累计");
1312
+ // turn/end t=6000 结算 busyMs = 1000 + (6000 − 1000 − 2000) = 4000;结算前一刻显示 4000。
1313
+ assert.equal(totalBusyDisplayMs({ ...running, now: 6_000 }), 4_000, "R-01-020/AC-07 回合结算前一刻显示值与结算后一致");
1314
+ assert.equal(totalBusyDisplayMs({ busyMs: 4_000, openTurnStart: null, waitedMs: null, openWaitStart: null, now: 6_000 }), 4_000, "R-01-020/AC-07 回合结算后显示 busyMs 无跳变");
1315
+ }
1316
+ // ---- R-01-020/AC-04、AC-05:reconcileTurnStats 统一收敛 ----
1317
+ // 无记录:全量重放。
1318
+ {
1319
+ const next = reconcileTurnStats(null, [
1320
+ { type: "turn/start", seq: 1, time: 1_000 },
1321
+ { type: "turn/end", seq: 2, time: 3_000 },
1322
+ ]);
1323
+ assert.equal(next.busyMs, 2_000, "R-01-020/AC-05 无记录全量重放补齐累计");
1324
+ assert.equal(next.watermarkSeq, 2, "R-01-020/AC-05 全量重放推进水位");
1325
+ }
1326
+ // 水位超前于日志最大 seq(seq 空间被重编,如 dsh 0.1.5 V3 迁移):全量重放自愈,
1327
+ // 卡死的开放回合被清除、总耗时不再无限增长(原缺陷回归)。
1328
+ {
1329
+ const next = reconcileTurnStats(
1330
+ { busyMs: 100, openTurnStart: 5_000, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 208_536 },
1331
+ [
1332
+ { type: "turn/start", seq: 1, time: 1_000 },
1333
+ { type: "turn/end", seq: 2, time: 3_000 },
1334
+ ],
1335
+ );
1336
+ assert.equal(next.busyMs, 2_000, "R-01-020/AC-04 水位超前时全量重放重算累计");
1337
+ assert.equal(next.openTurnStart, null, "R-01-020/AC-04 水位超前时卡死的开放回合被清除");
1338
+ assert.equal(next.watermarkSeq, 2, "R-01-020/AC-04 自愈后水位回归当前 seq 空间");
1339
+ }
1340
+ // 正常增量:只应用 seq > watermark 的事件,不重复计数。
1341
+ {
1342
+ const next = reconcileTurnStats(
1343
+ { busyMs: 500, openTurnStart: null, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 2 },
1344
+ [
1345
+ { type: "turn/start", seq: 1, time: 100 },
1346
+ { type: "turn/end", seq: 2, time: 600 },
1347
+ { type: "turn/start", seq: 3, time: 900 },
1348
+ { type: "turn/end", seq: 4, time: 1_400 },
1349
+ ],
1350
+ );
1351
+ assert.equal(next.busyMs, 1_000, "R-01-020/AC-05 增量收敛只补水位缺口");
1352
+ assert.equal(next.watermarkSeq, 4, "R-01-020/AC-05 增量收敛推进水位");
1353
+ }
1354
+ // 启动扫描:closeOpenTurn 把残留开放回合按日志最后事件时刻强制结算,尾部等待不计数。
1355
+ {
1356
+ const next = reconcileTurnStats(
1357
+ { busyMs: 100, openTurnStart: 5_000, openWaitStart: null, openWaitKind: null, waitedMs: null, watermarkSeq: 1 },
1358
+ [
1359
+ { type: "turn/start", seq: 1, time: 5_000 },
1360
+ { type: "tool/call", seq: 2, time: 6_000, data: { callId: "c1", name: "ask_user_question" } },
1361
+ { type: "tool/result", seq: 3, time: 7_000, data: { message: { source: { kind: "tool", callId: "c1" } } } },
1362
+ ],
1363
+ { closeOpenTurn: true },
1364
+ );
1365
+ assert.equal(next.busyMs, 1_100, "R-01-020/AC-04 启动扫描按日志末事件时刻结算残留开放回合");
1366
+ assert.equal(next.openTurnStart, null, "R-01-020/AC-04 启动扫描后无残留开放回合");
1367
+ assert.equal(next.waitedMs, null, "R-01-020/AC-04 启动扫描后等待累计清零");
1368
+ }
1047
1369
  // R-01-013/AC-05:历史卡同时显示本地绝对日期时间与相对年龄,异常输入不制造虚假时间。
1048
1370
  const relativeMinute = 60_000;
1049
1371
  const relativeHour = 60 * relativeMinute;
@@ -1137,8 +1459,6 @@ const bodyLabelItems = conversationWorkItems({
1137
1459
  chat: { order: ["bd"], nodes: { get: (key) => ({ key, kind: "assistant-step", data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "text", text: "纯正文" }] } }) } },
1138
1460
  });
1139
1461
  assert.equal(bodyLabelItems[0].label, "助手", "正文工作项 label 为中文「助手」(R-01-012/AC-09)");
1140
- assert.equal(needsHistorySnapshot({ chat: { order: [] } }), true, "空 chat snapshot 需要 history fallback");
1141
- assert.equal(needsHistorySnapshot({ chat: { order: ["item"] } }), false, "已 hydrate 的 chat snapshot 优先使用 order");
1142
1462
  const models = modelMetadata({
1143
1463
  current: { provider: "p", model: "m", reasoningEffort: "high" },
1144
1464
  groups: [{ id: "p", models: [{ id: "m", name: "Model M", reasoning: { efforts: [{ id: "high", name: "High" }] } }] }],
@@ -1473,6 +1793,46 @@ assert.equal(coldInterrupted[0].status, "stopped", "interrupted 结果落定 sto
1473
1793
  const coldOrphan = conversationTimelineFromHistory([hToolResult(1, "hc9")], 10);
1474
1794
  assert.equal(coldOrphan.length, 1, "call 在窗口外的孤儿 result 仍成行(信息不丢失)");
1475
1795
  assert.equal(coldOrphan[0].status, "done", "孤儿 result 落定 done,不造 running 行");
1796
+ // ---- V3 log 路径行为证据(T-123 dsh 0.1.5 适配):0.1.5 无独立 context 事件,非用户 source 的
1797
+ // user/message 即宿主 ContextMessageNode;流式期间 eventSource 以 transient assistant/live-chunk
1798
+ // 条目携带增量,须累积为 running live 行(否则运行卡无 running 圆点,R-01-009/AC-09 呈现缺失)----
1799
+ const hContext = (seq, source, text = "上下文正文") => ({ event: { type: "user/message", seq, data: { source, content: [{ type: "text", text }] } } });
1800
+ const ctxInject = conversationTimelineFromHistory([
1801
+ hUser(1, "指令"),
1802
+ hContext(2, { kind: "plugin", plugin: "dsh-activity-pane" }),
1803
+ hAgent(3, "回复"),
1804
+ ], 10);
1805
+ assert.equal(ctxInject[1].kind, "context", "非用户 source 的 user/message 映射为上下文行(镜像宿主 ContextMessageNode)");
1806
+ assert.equal(ctxInject[1].label, "上下文注入", "注入角色标题为上下文注入");
1807
+ assert.equal(ctxInject[1].summary, "dsh-activity-pane", "插件 source 摘要取 plugin 名(镜像原生 provenance.label)");
1808
+ const ctxRecall = conversationTimelineFromHistory([
1809
+ hContext(1, { kind: "session-reference", references: [{ label: "会话甲" }, { label: "会话乙" }, { label: "会话甲" }] }),
1810
+ ], 10);
1811
+ assert.equal(ctxRecall[0].label, "跨会话召回", "session-reference source 映射为召回角色");
1812
+ assert.equal(ctxRecall[0].summary, "会话甲, 会话乙", "召回摘要取引用标题去重逗号拼接(镜像原生 collect/joined)");
1813
+ const hLiveChunk = (seq, attemptId, text, type = "text-delta") => ({
1814
+ type: "transient",
1815
+ event: { type: "assistant/live-chunk", seq, time: 0, data: { attemptId, turn: 1, step: 0, chunk: { type, text } } },
1816
+ });
1817
+ const streaming = conversationTimelineFromHistory([hUser(1, "流式指令"), hLiveChunk(1.9, "a1", "正在"), hLiveChunk(1.95, "a1", "流式输出")], 10);
1818
+ assert.equal(streaming.length, 2, "流式期间用户行与 live 行并存");
1819
+ assert.equal(streaming[1].kind, "assistant", "live-chunk 累积为 assistant 行");
1820
+ assert.equal(streaming[1].status, "running", "流式 live 行为 running(运行卡 running 圆点来源)");
1821
+ assert.equal(streaming[1].live, true, "live 行携带 live 标记");
1822
+ assert.equal(streaming[1].text, "正在流式输出", "text-delta 按到达序累积为流式正文");
1823
+ const streamingReasoning = conversationTimelineFromHistory([hLiveChunk(1.8, "a2", "思考中", "reasoning-delta")], 10);
1824
+ assert.equal(streamingReasoning[0].status, "running", "reasoning-delta 同样呈现 running 行");
1825
+ assert.equal(streamingReasoning[0].detail, "思考中", "reasoning-delta 累积进 detail");
1826
+ assert.equal(streamingReasoning[0].text, "", "reasoning-delta 不混入正文");
1827
+ const settledStream = foldedHistoryTimeline([hUser(1, "流式指令"), hAgent(2, "最终回复")], 10);
1828
+ assert.ok(settledStream.every((row) => row.status !== "running"), "回合落定后 transient 条目出窗,无 running 残留");
1829
+ // 阻塞等待呈现(settleIdle):折叠前落定残留 running 行,组标题/状态由已定案成员派生
1830
+ // (快照路径 settleWhenIdle 前置语义对齐,R-01-009/AC-09「仅执行中行闪烁」)。
1831
+ const blockedAskRunning = foldedHistoryTimeline([hUser(1, "指令"), hToolCall(2, "ha")], 10);
1832
+ assert.equal(blockedAskRunning.at(-1).label, "正在运行", "未落定呈现保留「正在运行」组标题");
1833
+ const blockedAskSettled = foldedHistoryTimeline([hUser(1, "指令"), hToolCall(2, "ha")], 10, "", true);
1834
+ assert.equal(blockedAskSettled.at(-1).status, "done", "阻塞等待呈现下折叠前落定残留 running 行");
1835
+ assert.equal(blockedAskSettled.at(-1).label, "运行了命令", "落定后组标题由已定案成员派生(非「正在运行」)");
1476
1836
  // historyInstructionAnchor:尾扫最近一条非空文本真实用户消息(R-01-012/AC-12 快照窗口外兜底)
1477
1837
  assert.equal(historyInstructionAnchor([hUser(1, "旧指令"), hAgent(2, "回复"), hUser(3, "新指令")])?.text, "新指令", "锚行取最近一条用户消息");
1478
1838
  assert.equal(historyInstructionAnchor([hUser(1, " "), hAgent(2, "回复")]), null, "空文本用户消息不作锚");
@@ -1674,6 +2034,16 @@ assert.deepEqual(
1674
2034
  { outputTokens: 0, inputTokens: 0, cacheHitPct: 0, rateTokS: 8 },
1675
2035
  "统计合并保留有效的零值,仅对缺失字段回退(R-01-009/AC-13)",
1676
2036
  );
2037
+ // R-01-009/AC-15 暂停子代理卡的冻结口径与等待卡共用同一合并语义:
2038
+ // 当前投影缺失的字段保留暂停前最后已知值,均缺失时回退当前投影,不伪造 0。
2039
+ assert.deepEqual(
2040
+ mergeRuntimeStats(
2041
+ { outputTokens: 512, inputTokens: 900, cacheHitPct: 62, rateTokS: 32 },
2042
+ { outputTokens: null, inputTokens: 1, cacheHitPct: null, rateTokS: 5 },
2043
+ ),
2044
+ { outputTokens: 512, inputTokens: 900, cacheHitPct: 62, rateTokS: 32 },
2045
+ "暂停子代理卡冻结暂停前最后已知统计,缺失字段才回退当前投影(R-01-009/AC-15)",
2046
+ );
1677
2047
  assert.deepEqual(
1678
2048
  usageSummary({ uncachedInputTokens: 100, cacheReadTokens: 700, cacheWriteTokens: 200 }),
1679
2049
  { inputTokens: 1_000, cacheHitPct: 70 },
@@ -1798,33 +2168,6 @@ assert.equal(
1798
2168
  assert.equal(openTurnStartFromEvents([turnStartEv(1, 1, Number.NaN)]), null, "turn/start 时刻非法时无可用起点");
1799
2169
  assert.equal(openTurnStartFromEvents([]), null, "空事件无开放回合起点");
1800
2170
  assert.equal(openTurnStartFromEvents(null), null, "非数组输入归一 null");
1801
- // 补读触发口径(R-01-009/AC-06):仅「运行中 + 轮内订阅已建立 + 快照无开放回合起点」算缺口
1802
- assert.equal(
1803
- openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: true, liveStartTime: null }),
1804
- true,
1805
- "运行中且快照无开放回合起点判定为缺口(超长回合冷窗口)",
1806
- );
1807
- assert.equal(
1808
- openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: true, liveStartTime: 1000 }),
1809
- false,
1810
- "窗口内含开放回合起点时不是缺口",
1811
- );
1812
- assert.equal(
1813
- openTurnStartMissing({ snapshotReady: true, running: false, hasLiveness: false, liveStartTime: null }),
1814
- false,
1815
- "等待/空闲会话(非运行、无 liveness 记录)不算缺口、不触发补读",
1816
- );
1817
- assert.equal(
1818
- openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: false, liveStartTime: null }),
1819
- false,
1820
- "轮内订阅尚未建立时不算缺口(下一帧建立后再判定)",
1821
- );
1822
- assert.equal(
1823
- openTurnStartMissing({ snapshotReady: false, running: true, hasLiveness: true, liveStartTime: null }),
1824
- false,
1825
- "快照未就绪走 historyNeeded 原路径,不算窗口缺口",
1826
- );
1827
-
1828
2171
  // ---- R-01-009/AC-07 工作项时间线的状态与主会话窗口语义摘要(无行级耗时,C-012)----
1829
2172
  const statusTimeline = conversationWorkItems({
1830
2173
  chat: {
@@ -2918,9 +3261,9 @@ assert.deepEqual(
2918
3261
  );
2919
3262
 
2920
3263
  // ---- R-01-014/AC-05 补充数据失败降级为空字段并可重试 ----
2921
- // (行为链详见 R-01-012/AC-01 的 detailLoadPlan 锚点:失败置空 → 可见期内不热重试 →
2922
- // 离开可见清理 → 重回可见允许重试。)
2923
- assert.equal(detailLoadPlan({ detail: { model: { model: "", reasoning: "" } } }).model, false, "失败置空即降级为空字段");
3264
+ // (dsh 0.1.5 起 per-session models RPC 移除,模型提取随日志读取统一进行:失败置空
3265
+ // 语义由 R-01-012/AC-18 的空白归一单测与本文件日志读取门禁锚点共同
3266
+ // 承载——失败不置标记、下一可见期允许重试。)
2924
3267
 
2925
3268
  // ---- R-01-015/AC-02 拖拽宽度夹取 | R-01-015/AC-04 持久化恢复归一 ----
2926
3269
  assert.equal(clampPaneWidth(280), 280, "范围内宽度原样保留");
@@ -3076,8 +3419,8 @@ assert.ok(
3076
3419
  assert.ok(!bundle.includes("dshcf") && !bundle.includes("autoCollapseActive"), "无 dsh-auto-collapse 探测残留(R-01-017、C-017)");
3077
3420
  assert.ok(!bundle.includes("nativeWorkItemRow") && !bundle.includes("cloneNativeIcon") && !bundle.includes("nativeIconsByTraceKey"), "原生行匹配/图标克隆机器无残留(C-017)");
3078
3421
  assert.ok(!bundle.includes("mergeTraceStatus") && !bundle.includes("allowNativePresentation"), "行状态直接采用核心派生值,无合并/切换层(C-017)");
3079
- assert.ok(bundle.includes("api.history"), "冷会话使用 native history 一次性补齐");
3080
- assert.ok(bundle.includes("api.models"), "模型/reasoning 使用 native models 数据");
3422
+ assert.ok(bundle.includes(".page("), "冷会话使用 remote 日志分页一次性补齐(Session V3)");
3423
+ assert.ok(bundle.includes(".modelCatalog("), "模型/reasoning 使用部署级 modelCatalog 数据");
3081
3424
  assert.ok(bundle.includes("dap-token-stats"), "token 统计 DOM 位于进度条之后");
3082
3425
  assert.ok(
3083
3426
  bundle.includes('makeEl("span", "dap-token-main")') && bundle.includes('makeEl("span", "dap-token-time")'),
@@ -3086,7 +3429,7 @@ assert.ok(
3086
3429
  assert.ok(bundle.includes("function renderTokenStats"), "运行卡统计行继续复用既有渲染逻辑(R-01-009/AC-05)");
3087
3430
  // R-01-009/AC-13 等待卡保留进入等待前最后已知的 token 统计
3088
3431
  assert.ok(
3089
- bundle.includes('return [head, row, makeEl("div", "dap-trace"), statsRow, foot];'),
3432
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), makeStatsRow(), foot];'),
3090
3433
  "等待卡骨架在时间线后保留统计行(R-01-009/AC-13)",
3091
3434
  );
3092
3435
  assert.ok(
@@ -3112,7 +3455,7 @@ assert.ok(!bundle.includes("≈"), "速率不再携带约等于符号(R-01-009
3112
3455
  assert.ok(bundle.includes("dap-history-line"), "历史卡包含用户/agent 两条消息预览行");
3113
3456
  // R-01-013/AC-12 最近历史卡保留最近回合统计
3114
3457
  assert.ok(
3115
- bundle.includes('return [head, row, userLine, agentLine, statsRow, makeEl("div", "dap-note")];'),
3458
+ bundle.includes('return [head, row, userLine, agentLine, makeStatsRow(), makeEl("div", "dap-note")];'),
3116
3459
  "历史卡统计行位于助手预览后、活动时间前",
3117
3460
  );
3118
3461
  assert.ok(bundle.includes("statsFromProjection"), "历史卡统计复用列表投影派生函数(R-01-013/AC-12)");
@@ -3183,7 +3526,7 @@ assert.ok(bundle.includes("function activeSessionIds(byId = {})"), "活动子代
3183
3526
  // ---- R-01-016/AC-01 等待卡保留最近工作项时间线 ----
3184
3527
  assert.ok(
3185
3528
  bundle.includes("awaitHead.append(capsule);") &&
3186
- bundle.includes('return [head, row, makeEl("div", "dap-trace"), statsRow, foot];'),
3529
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), makeStatsRow(), foot];'),
3187
3530
  "awaiting 骨架在标题行与统计行、末行两段(胶囊+正文)之间含时间线,固定耗时由统计行承载(R-01-016/AC-01、R-01-009/AC-12,C-043)",
3188
3531
  );
3189
3532
  // ---- R-01-002/AC-10 完成提醒卡「移入历史」按钮 ----
@@ -3197,7 +3540,7 @@ assert.ok(
3197
3540
  "按钮点击/键盘激活写回 ack 且阻断卡片跳转(R-01-002/AC-10)",
3198
3541
  );
3199
3542
  assert.ok(bundle.includes('confirm.hidden = entry.waitClass !== "done"'), "仅完成提醒卡显示「移入历史」按钮,阻塞等待卡不显示(R-01-002/AC-10)");
3200
- assert.ok(bundle.includes("new window.EventSource(`${ACK_API_BASE}/acks/stream`)"), "完成确认状态经 SSE 通道订阅(R-01-002/AC-11、AC-12)");
3543
+ assert.ok(bundle.includes("new window.EventSource(`${PANE_API_BASE}/acks/stream`)"), "完成确认状态经 SSE 通道订阅(R-01-002/AC-11、AC-12)");
3201
3544
  // R-01-002/AC-12 缺陷回归:移动 PWA 后台恢复后 ack 通道必须自愈(EventSource CLOSED/半开
3202
3545
  // 不再自动重连),否则完成等待中的会话被误判入历史区直至整页重载。
3203
3546
  assert.ok(
@@ -3212,7 +3555,7 @@ assert.ok(
3212
3555
  bundle.includes('document.removeEventListener("visibilitychange", onVisibilityResume)') && bundle.includes('window.removeEventListener("pageshow", onPageShow)'),
3213
3556
  "卸载时移除 ack 通道自愈监听(R-01-002/AC-12)",
3214
3557
  );
3215
- assert.ok(bundle.includes("fetch(`${ACK_API_BASE}/ack`"), "确认写回经宿主侧 ack 路由(R-01-002/AC-10、AC-11)");
3558
+ assert.ok(bundle.includes("fetch(`${PANE_API_BASE}/ack`"), "确认写回经宿主侧 ack 路由(R-01-002/AC-10、AC-11)");
3216
3559
  assert.ok(
3217
3560
  !bundle.includes("updateCompletedHolds") && !bundle.includes("heldCompletedIds") && !bundle.includes("prevActiveMainIds"),
3218
3561
  "响应保持易失记账全套移除(C-030)",
@@ -3225,12 +3568,12 @@ assert.ok(
3225
3568
  assert.equal(
3226
3569
  bundle.split('makeEl("span", "dap-pct")').length - 1,
3227
3570
  1,
3228
- "百分比文本元素全 bundle 仅运行卡骨架一处创建(R-01-009/AC-06)",
3571
+ "百分比文本元素经 makeProgressRow 共享骨架单点创建(R-01-009/AC-06、AC-14)",
3229
3572
  );
3230
3573
  assert.equal(
3231
3574
  bundle.split('querySelector(".dap-pct")').length - 1,
3232
3575
  1,
3233
- "百分比文本写入全 bundle 仅运行卡渲染分支一处(R-01-009/AC-06)",
3576
+ "百分比文本写入经 renderProgressRow 单点承载(R-01-009/AC-06、AC-14)",
3234
3577
  );
3235
3578
  // ---- R-01-016/AC-04 时间线数据在途时显示加载指示、返回就地填充 ----
3236
3579
  assert.ok(
@@ -3278,8 +3621,24 @@ assert.ok(!bundle.includes('"Think"') && !bundle.includes('"Assistant"'), "bundl
3278
3621
  assert.ok(bundle.includes('"思考"') && bundle.includes('"助手"'), "bundle 含中文「思考」「助手」标签(R-01-012/AC-09、AC-10)");
3279
3622
  assert.ok(bundle.includes("session.subscribe"), "运行卡通过 native session subscribe 接收实时推送");
3280
3623
  assert.ok(
3281
- clientSource.includes('const inject = ["connection", "sessions", "workspaces"];'),
3282
- "sessions/workspaces 通过 client inject 注入,不依赖服务发现定时器",
3624
+ clientSource.includes('const inject = ["sessions", "workspaces", "uiSession", "remote.session"];'),
3625
+ "sessions/workspaces/uiSession/remote.session 通过 client inject 注入,不依赖服务发现定时器",
3626
+ );
3627
+ assert.ok(
3628
+ bundle.includes("uiSession?.pendingInteractions") && bundle.includes("pendingInteraction: interaction.kind"),
3629
+ "0.1.5 等待行动经 uiSession.pendingInteractions 快照回填行副本(sessions 行不再承载 pendingInteraction)",
3630
+ );
3631
+ assert.ok(
3632
+ bundle.includes("historyDeepReadDone = true") && bundle.includes("historyDeepReadDone !== true"),
3633
+ "日志深翻每可见期至多一次:完成即置位(失败同样置位不热重试),重入可见由详情记账清理放行重试(R-01-014/AC-05)",
3634
+ );
3635
+ assert.ok(
3636
+ clientSource.includes("deepReadNeeded && !historyLoads.has(id)"),
3637
+ "深翻在途守卫:同一会话深翻在途不重复入队,防 page RPC 风暴与加载指示在途性高频抖动(R-01-014/AC-05)",
3638
+ );
3639
+ assert.ok(
3640
+ clientSource.includes('!detail.log && detail.snapshot?.openState !== "open"'),
3641
+ "open 仅在日志窗口缺席时发起:已水合会话不逐帧重发 open,防在途翻转驱动加载指示抖动",
3283
3642
  );
3284
3643
  assert.ok(!bundle.includes("serviceTimer"), "服务发现不得保留后台定时器");
3285
3644
  assert.ok(!bundle.includes("frameProbeTimer"), "宿主 frame 发现不得保留后台定时器");
@@ -3311,24 +3670,33 @@ assert.ok(bundle.includes("loadingPreviews"), "预览字段级加载指示并入
3311
3670
  assert.ok(bundle.includes("renderTraceLoading"), "时间线区数据在途时显示加载行");
3312
3671
  assert.ok(clientSource.includes('e2eParams.get("dap-e2e-model-delay")'), "detail 渐进 E2E 接缝由显式 URL fragment 启用");
3313
3672
  assert.ok(clientSource.includes("Math.min(requestedModelDelay, 1_000)"), "detail 渐进 E2E 延迟上限为 1 秒");
3314
- assert.ok(clientSource.includes("if (!subagent && e2eModelDelayMs === 0) subscribeModelDirectory(id, detail);"), "fixture 模式仅绕开 model directory 抢先初值");
3315
- assert.ok(clientSource.includes("delayedModelCall(() => api.models({ sessionId: id }))"), "detail fixture 延迟正式 models RPC,不伪造 model response");
3673
+ assert.ok(
3674
+ clientSource.includes("if (!subagent) {") &&
3675
+ clientSource.includes("subscribeModelDirectory(id, detail);") &&
3676
+ clientSource.includes("loadDirectoryOnce(id);"),
3677
+ "仅主会话建立 model directory 订阅与一次性 load(子代理目录不可用不订阅)",
3678
+ );
3679
+ assert.ok(
3680
+ clientSource.includes('delayedModelCall(() => (typeof directory.load === "function" ? directory.load() : null))'),
3681
+ "detail fixture 延迟目录一次性 load,不伪造 model response",
3682
+ );
3316
3683
  assert.ok(clientSource.includes("Promise.resolve().then(call)"), "detail fixture 保留 models RPC 同步异常的 Promise catch 降级语义");
3317
3684
  assert.ok(clientSource.includes("e2eModelDelayWaiters.clear()"), "卸载时取消并结清 detail fixture 延迟,不残留 timer/promise");
3318
3685
  assert.ok(bundle.includes("promise.then(queueSync, queueSync)"), "补充数据逐个完成即重绘(先就绪先显示)");
3319
3686
  assert.ok(bundle.includes("LOAD_CONCURRENCY"), "冷数据读取经并发池限制慢网挤占");
3320
3687
  assert.ok(bundle.includes("session.open"), "运行卡通过 native session open hydrate 非当前会话");
3321
3688
  assert.ok(bundle.includes("sessionOpenLoads"), "session.open 请求与 cold history fallback 不重复");
3322
- // R-01-012/AC-16 模型目录订阅:store 推送更新、只订阅不 load、随可见性/卸载清理
3689
+ // R-01-012/AC-16 模型目录订阅:store 推送更新、随可见性/卸载清理;dsh 0.1.5 起目录
3690
+ // store 惰性加载,订阅后补一次一次性 load(C-024 的 generation 竞争以最新操作胜出)
3323
3691
  assert.ok(bundle.includes('ctx.get("modelDirectories")'), "模型实时选择来自原生 modelDirectories 服务(可选软依赖)");
3324
3692
  assert.ok(bundle.includes("directory.store.subscribe"), "订阅目录 store 推送模型选择变更");
3325
- assert.ok(!bundle.includes("directory.load("), "不调用目录 load(),不与 select() 竞争 generation(C-024)");
3693
+ assert.ok(bundle.includes("directory.load"), "目录 store 一次性 load:0.1.5 惰性加载下不 load 不产出当前选择");
3326
3694
  assert.ok(bundle.includes("pruneSubscriptions(modelDirectorySubs, visibleIds)"), "模型目录订阅随可见性先 unsubscribe 再除名");
3327
3695
  assert.ok(bundle.includes("pruneSubscriptions(modelDirectorySubs, new Set())"), "卸载时模型目录订阅整体退订归零");
3328
3696
  assert.ok(bundle.includes("detail.modelLive"), "目录订阅已产值时晚到的一次性 RPC 不回写旧值");
3329
3697
  assert.ok(!bundle.includes("events.mux"), "不常驻全局 mux,当前会话使用原生 session subscribe");
3330
3698
  assert.ok(
3331
- bundle.includes('return [head, row, makeEl("div", "dap-trace"), progressRow, statsRow];'),
3699
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), progressRow, makeStatsRow()];'),
3332
3700
  "running 卡 token 统计骨架位于进度条骨架之后",
3333
3701
  );
3334
3702
  assert.ok(
@@ -3353,12 +3721,21 @@ assert.ok(
3353
3721
  !bundle.includes("ctx.get(\"dsh-answer-pet\")"),
3354
3722
  "不得以服务方式依赖第三方宠物插件",
3355
3723
  );
3356
- // R-02-004/AC-02(演进,C-030):完成确认写回是唯一 HTTP 请求——自家宿主侧路由、
3357
- // 用户操作触发的一次性 POST,非状态轮询;轮内状态仍只来自原生订阅推送与 SSE 推送。
3358
- // fetch 唯一性由下条断言钉住:轮询需要重复请求,唯一 fetch 即排除轮询形态。
3724
+ // R-02-004/AC-02(演进,C-030、C-074):HTTP 请求仅两处——完成确认写回(用户操作触发的
3725
+ // 一次性 POST)与累计运行时长懒回填触发(每会话至多一次的一次性 GET,随可见会话触发),
3726
+ // 均非状态轮询;轮内状态与回合统计仍只来自原生订阅推送与 SSE 推送。
3727
+ // fetch/EventSource 数量由下两条断言钉住:轮询需要重复请求,受限的调用面即排除轮询形态。
3359
3728
  assert.ok(
3360
- (bundle.match(/fetch\(/g) ?? []).length === 1 && bundle.includes("fetch(`${ACK_API_BASE}/ack`"),
3361
- "唯一的 fetch 调用是完成确认写回,指向宿主侧自家路由(R-01-002/AC-10、C-030)",
3729
+ (bundle.match(/fetch\(/g) ?? []).length === 2
3730
+ && bundle.includes("fetch(`${PANE_API_BASE}/ack`")
3731
+ && bundle.includes("fetch(`${PANE_API_BASE}/busy?ids="),
3732
+ "HTTP 请求仅完成确认写回与 busy 懒回填触发两处,指向宿主侧自家路由(R-01-002/AC-10、R-01-020、C-030、C-074)",
3733
+ );
3734
+ assert.ok(
3735
+ (bundle.match(/new window\.EventSource\(/g) ?? []).length === 2
3736
+ && bundle.includes("${PANE_API_BASE}/acks/stream")
3737
+ && bundle.includes("${PANE_API_BASE}/busy/stream"),
3738
+ "SSE 订阅仅 acks 与 busy 两条通道,均连接即收全量快照(C-030、C-074)",
3362
3739
  );
3363
3740
 
3364
3741
  // ---- R-02-003/AC-02 卸载时清理注入元素、样式与监听 ----
@@ -3872,6 +4249,63 @@ assert.ok(
3872
4249
  "等待当前卡重声明蓝色描边与光晕(组合选择器压过等待态橙色)",
3873
4250
  );
3874
4251
 
4252
+ // ---- R-01-012/AC-17~AC-18、R-01-009/AC-14~AC-15 子代理卡模型/进度/统计契约 ----
4253
+ // 子代理模型经既有 history 溯源提取(models RPC 对子代理被宿主 agent-busy 拒绝)。
4254
+ assert.ok(bundle.includes("modelFromHistoryEvents") && bundle.includes("chatLatestAssistantSettled"), "子代理模型溯源提取与触发信号进入 bundle(R-01-012/AC-17)");
4255
+ assert.ok(
4256
+ clientSource.includes("if (planSubagentModelRead) {") && clientSource.includes("detail.modelReadDone = true;"),
4257
+ "history 读取落地时为子代理提取模型溯源并记账每次可见期单次尝试(R-01-012/AC-17)",
4258
+ );
4259
+ assert.ok(
4260
+ clientSource.includes("chatLatestAssistantSettled(detailSnapshot)"),
4261
+ "模型读取时机锚定已定案助手行或非运行状态(R-01-012/AC-17)",
4262
+ );
4263
+ assert.ok(
4264
+ clientSource.includes("entry.kind === \"subagent\" && historyLoads.has(entry.id)"),
4265
+ "子代理溯源读取在途时模型区显示加载指示(R-01-014/AC-02、R-01-012/AC-17)",
4266
+ );
4267
+ // 溯源 id → 显示名:目录分组经主会话既有两条到达路径就地收割,渲染时对子代理条目解析。
4268
+ assert.ok(
4269
+ clientSource.includes("const catalogEntries = Object.create(null);"),
4270
+ "目录条目索引为无原型对象,模型 id 恰为继承键名时不得穿透回退(R-01-012/AC-17)",
4271
+ );
4272
+ assert.ok(
4273
+ clientSource.includes("harvestCatalog(snap.groups);") &&
4274
+ clientSource.includes("harvestCatalog(value.groups);"),
4275
+ "目录分组在 store 订阅与一次性 modelCatalog 两条到达路径就地收割(R-01-012/AC-17)",
4276
+ );
4277
+ assert.ok(
4278
+ clientSource.includes("detail.model.reasoning = reasoningEffortFromHistoryEvents(events) ?? \"\";"),
4279
+ "子代理 history 落地时折叠最新请求头 effort(R-01-012/AC-17)",
4280
+ );
4281
+ assert.ok(
4282
+ clientSource.includes("if (catalogEntry?.name) entry.model = catalogEntry.name;") &&
4283
+ clientSource.includes("if (!entry.reasoning && catalogEntry?.reasoning) entry.reasoning = catalogEntry.reasoning;"),
4284
+ "渲染时子代理溯源 id 经目录解析为显示名与 effort 回退,未覆盖保留原始(R-01-012/AC-17、AC-18)",
4285
+ );
4286
+ // 运行中子代理卡与主会话运行卡同构:进度行 + 统计行进骨架,渲染按同一锚点与曲线。
4287
+ assert.ok(
4288
+ clientSource.includes("return [row, makeEl(\"div\", \"dap-subtrace\"), progressRow, makeStatsRow()];") &&
4289
+ clientSource.includes("row.append(makeEl(\"span\", \"dap-dot\"), makeEl(\"span\", \"dap-title\"), model, makeEl(\"span\", \"dap-total-time\"));"),
4290
+ "子代理卡骨架:模型名入标题行右侧,进度行与统计行承载(R-01-009/AC-14、R-01-012/AC-17)",
4291
+ );
4292
+ assert.ok(
4293
+ clientSource.includes("progressOf({ elapsedMs, halfLifeSec: progressHalfLifeSec({ rateTokS: projectionStats.rateTokS }) })"),
4294
+ "运行中子代理卡按与运行卡相同的锚点口径现算进度(R-01-009/AC-14)",
4295
+ );
4296
+ assert.ok(
4297
+ clientSource.includes("entry.kind === \"subagent\" && runLikeIds.has(entry.id)"),
4298
+ "运行卡时钟条件纳入运行中子代理卡(R-01-009/AC-14)",
4299
+ );
4300
+ // 非运行子代理卡:进度条整行隐藏、统计与最近回合耗时冻结。
4301
+ assert.ok(
4302
+ clientSource.includes("} else if (!progressRow.hidden) {") && clientSource.includes("progressRow.hidden = true;"),
4303
+ "锚点空闲时子代理卡进度行整行隐藏(R-01-009/AC-15)",
4304
+ );
4305
+ assert.ok(
4306
+ clientSource.includes("entry.progress = null;") && bundle.includes("[data-dsh-activity-pane] .dap-progress[hidden] { display: none; }"),
4307
+ "非运行子代理卡冻结统计并隐藏进度行(R-01-009/AC-15)",
4308
+ );
3875
4309
 
3876
4310
  // ---- R-01-002/AC-10~AC-12 宿主侧完成确认契约(C-030)----
3877
4311
  // R-01-002/AC-12 刷新/重连恢复:状态由宿主侧持久化承载,不依赖客户端在线观测。
@@ -3887,6 +4321,14 @@ assert.ok(hostSource.includes("const API_PATH = '/dsh-activity-pane/api'") && ho
3887
4321
  assert.ok(hostSource.includes("'/acks/stream'") && hostSource.includes("text/event-stream"), "SSE 推送通道(AC-11、AC-12)");
3888
4322
  assert.ok(hostSource.includes("'/ack'") && hostSource.includes("ackedAt: Date.now()"), "ack 写回路由(AC-10~AC-12)");
3889
4323
  assert.ok(hostSource.includes("streamClients") && hostSource.includes("for (const res of streamClients)"), "SSE 连接集合随卸载全数关闭");
4324
+ assert.ok(
4325
+ hostSource.includes("ctx.connection?.requestRejection?.(req)") && hostSource.includes("res.end('unauthorized')"),
4326
+ "自定义 webServer 路由经 connection.requestRejection 鉴权门(A1-08:不继承宿主鉴权,acks/busy 读写通道不得裸奔)",
4327
+ );
4328
+ assert.ok(
4329
+ bundle.includes("} else if (nextSeat !== seat) {") && bundle.includes("流式观察者换绑到新节点"),
4330
+ "宿主原地替换槽容器时流式观察者换绑到新节点,不留已分离旧节点(借鉴 -mig 并行实现)",
4331
+ );
3890
4332
 
3891
4333
 
3892
4334
  // ---- E2E runner 浏览器生命周期契约(C-046、C-047,T-085)----