dsh-activity-pane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3536 @@
1
+ // dsh-activity-pane 核心单元检查与 client bundle 契约校验。
2
+ //
3
+ // 测试锚点:本文件被 CONVENTIONS.md 的 `测试锚点路径` 登记为测试文件,
4
+ // 以一行的 `// R-gg-nnn/AC-nn` 注释锚定 PRD 验收点;GUI 交互类验收点
5
+ // 见 scripts/acceptance.mjs(人工验收清单)。
6
+ import assert from "node:assert/strict";
7
+ import { readFile, mkdir } from "node:fs/promises";
8
+ import { execFileSync } from "node:child_process";
9
+ import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import {
12
+ askQuestionsPreview,
13
+ awaitBadgeStats,
14
+ awaitBadgeTone,
15
+ awaitNoteText,
16
+ timelineQuestionPreview,
17
+ buildEntries,
18
+ buildRecent,
19
+ cardSignature,
20
+ cleanPreview,
21
+ clampPaneWidth,
22
+ pagedHistoryEvents,
23
+ delegationActive,
24
+ progressAnchor,
25
+ detailLoadPlan,
26
+ conversationWorkItems,
27
+ conversationTimelineFromHistory,
28
+ foldWorkGroups,
29
+ foldedConversationTimeline,
30
+ foldedHistoryTimeline,
31
+ historyInstructionAnchor,
32
+ openTurnStartFromEvents,
33
+ openTurnStartMissing,
34
+ escapeCssString,
35
+ firstPhysicalLine,
36
+ fmtElapsedMs,
37
+ fmtTokens,
38
+ isActiveRow,
39
+ shouldSubscribeToSession,
40
+ activeSessionIds,
41
+ completionReminder,
42
+ errorReminder,
43
+ ERROR_NOTE_MAX,
44
+ ERROR_NOTE_FALLBACK,
45
+ truncateErrorNote,
46
+ trackBoxes,
47
+ trackRuns,
48
+ isSubagentRow,
49
+ countBadgeState,
50
+ listLoadState,
51
+ messagePreviews,
52
+ movedToRecentIds,
53
+ movedToActiveIds,
54
+ modelMetadata,
55
+ needsHistorySnapshot,
56
+ lastTurnEndFromEvents,
57
+ lastTurnEndFromTimings,
58
+ pendingText,
59
+ progressHalfLifeSec,
60
+ progressOf,
61
+ pruneInvisibleEntries,
62
+ pruneSubscriptions,
63
+ runtimeStats,
64
+ shouldCancelOpenRetry,
65
+ subagentTitle,
66
+ summarizeToolArguments,
67
+ usageSummary,
68
+ workspaceHue,
69
+ resolveWorkspaceHues,
70
+ workspaceInfoForSession,
71
+ } from "../src/core.mjs";
72
+ import {
73
+ COMPOSER_SELECTOR,
74
+ bindBackdropDismiss,
75
+ bindCardActivation,
76
+ openSession,
77
+ shouldDismissDrawerOnActivation,
78
+ suppressComposerAutofocus,
79
+ } from "../src/navigation.mjs";
80
+
81
+ const root = dirname(dirname(fileURLToPath(import.meta.url)));
82
+
83
+ // ---- R-01-005/AC-01 点击跳转回归:卡片快照变化不能拦截原生导航 ----
84
+ let openedSession = null;
85
+ assert.equal(
86
+ openSession(
87
+ {
88
+ list: { getSnapshot: () => ({ ids: [] }) },
89
+ open: (id) => {
90
+ openedSession = id;
91
+ },
92
+ },
93
+ "stale-card",
94
+ ),
95
+ true,
96
+ "即使另一份 list 快照不含目标,点击仍直接调用 sessions.open",
97
+ );
98
+ assert.equal(openedSession, "stale-card");
99
+ assert.equal(
100
+ openSession(
101
+ { open: () => {
102
+ throw new Error("not ready");
103
+ } },
104
+ "not-ready",
105
+ ),
106
+ false,
107
+ "sessions.open 失败时交给调用方进入 refresh/retry",
108
+ );
109
+
110
+ // ---- R-01-005/AC-01、R-02-003/AC-01 card 自身 click/键盘监听与卸载 ----
111
+ const cardListeners = new Map();
112
+ const card = {
113
+ dataset: { sessionId: "card-a" },
114
+ addEventListener(type, listener) {
115
+ cardListeners.set(type, listener);
116
+ },
117
+ removeEventListener(type, listener) {
118
+ if (cardListeners.get(type) === listener) cardListeners.delete(type);
119
+ },
120
+ };
121
+ const cardOpened = [];
122
+ const cardSessions = { open: (id) => cardOpened.push(id) };
123
+ const unbindCard = bindCardActivation(card, (id) => openSession(cardSessions, id));
124
+ const activateEvent = (type, key = undefined) => {
125
+ let prevented = false;
126
+ let stopped = false;
127
+ cardListeners.get(type)?.({
128
+ type,
129
+ currentTarget: card,
130
+ key,
131
+ preventDefault: () => { prevented = true; },
132
+ stopPropagation: () => { stopped = true; },
133
+ });
134
+ return { prevented, stopped };
135
+ };
136
+ assert.deepEqual(activateEvent("click"), { prevented: true, stopped: true }, "card click 可激活并阻止宿主继续处理");
137
+ assert.deepEqual(activateEvent("keydown", "Enter"), { prevented: true, stopped: true }, "card Enter 可激活");
138
+ assert.deepEqual(activateEvent("keydown", " "), { prevented: true, stopped: true }, "card Space 可激活");
139
+ assert.deepEqual(activateEvent("keydown", "Escape"), { prevented: false, stopped: false }, "其它按键不激活 card");
140
+ card.dataset.sessionId = "card-b";
141
+ activateEvent("click");
142
+ assert.deepEqual(cardOpened, ["card-a", "card-a", "card-a", "card-b"], "复用同一 card 时读取最新 session id");
143
+ unbindCard();
144
+ assert.equal(cardListeners.size, 0, "card 卸载移除 click/keydown 监听");
145
+
146
+ // ---- R-01-005/AC-01 回归:移动端切换会话后抑制原生 composer 自动聚焦(不弹软键盘) ----
147
+ const composerEl = {
148
+ focused: true,
149
+ matches: (selector) => selector === COMPOSER_SELECTOR,
150
+ blur() {
151
+ this.focused = false;
152
+ },
153
+ };
154
+ const docListeners = new Map();
155
+ const fakeDoc = {
156
+ activeElement: composerEl,
157
+ addEventListener(type, listener, capture) {
158
+ docListeners.set(`${type}:${capture}`, listener);
159
+ },
160
+ removeEventListener(type, listener, capture) {
161
+ if (docListeners.get(`${type}:${capture}`) === listener) docListeners.delete(`${type}:${capture}`);
162
+ },
163
+ };
164
+ let endFocusWindow = null;
165
+ suppressComposerAutofocus(fakeDoc, (fn) => {
166
+ endFocusWindow = fn;
167
+ });
168
+ assert.equal(composerEl.focused, false, "激活时 composer 已持焦则立即 blur");
169
+ const onFocusIn = docListeners.get("focusin:true");
170
+ assert.equal(typeof onFocusIn, "function", "已安装捕获阶段 focusin 监听");
171
+ composerEl.focused = true;
172
+ onFocusIn({ target: composerEl });
173
+ assert.equal(composerEl.focused, false, "窗口内 composer 自动聚焦被 blur");
174
+ onFocusIn({ target: { matches: () => false, blur: () => assert.fail("非 composer 不应被 blur") } });
175
+ endFocusWindow();
176
+ assert.equal(docListeners.size, 0, "窗口结束后移除 focusin 监听");
177
+ composerEl.focused = true;
178
+ assert.equal(composerEl.focused, true, "监听移除后不再干预聚焦");
179
+ suppressComposerAutofocus(null);
180
+ suppressComposerAutofocus({});
181
+
182
+ // ---- R-01-005/AC-02 打开重试链取消判定与卡片定位选择器转义 ----
183
+ assert.equal(
184
+ shouldCancelOpenRetry({ targetId: "a", currentId: "a", activatedId: "a" }),
185
+ true,
186
+ "重试目标已成为当前会话(他途到达)时取消本链",
187
+ );
188
+ assert.equal(
189
+ shouldCancelOpenRetry({ targetId: "a", currentId: null, activatedId: "b" }),
190
+ true,
191
+ "用户激活其它卡片后旧重试链被新意图取代",
192
+ );
193
+ assert.equal(
194
+ shouldCancelOpenRetry({ targetId: "a", currentId: "b", activatedId: "a" }),
195
+ false,
196
+ "本链目标即最新激活意图且未到达时保留重试",
197
+ );
198
+ assert.equal(
199
+ shouldCancelOpenRetry({ targetId: "a", currentId: null, activatedId: null }),
200
+ false,
201
+ "无到达也无新意图时保留重试",
202
+ );
203
+ assert.equal(
204
+ shouldCancelOpenRetry({ targetId: null, currentId: "a", activatedId: "a" }),
205
+ true,
206
+ "非法目标直接取消",
207
+ );
208
+ assert.equal(escapeCssString('a"b\\c'), 'a\\"b\\\\c', "先转义反斜杠再转义引号,选择器不破裂");
209
+ assert.equal(escapeCssString(""), "", "空 id 转义为空,加引号选择器仍合法");
210
+ assert.equal(escapeCssString(42), "42", "非字符串 id 归一为字符串");
211
+ assert.equal(escapeCssString("a\nb"), "a\\a b", "换行按 CSS 字符串码位转义");
212
+ assert.equal(escapeCssString("a\rb"), "a\\d b", "回车按 CSS 字符串码位转义");
213
+ assert.equal(escapeCssString("a\fb"), "a\\c b", "换页按 CSS 字符串码位转义");
214
+ assert.equal(escapeCssString("a\0b"), "a�b", "NUL 归一为替换字符");
215
+ // R-01-012/AC-01
216
+ assert.equal(isSubagentRow({ parentId: "ghost" }, {}), false, "父级不在列表时按主会话处理(仍允许 models 读取)");
217
+ assert.equal(isSubagentRow({ parentId: "p" }, { p: { id: "p" } }), true, "直属子代理判定命中时跳过 models 读取");
218
+ // models/history 加载决策行为链:首读 → 在途不重发 → 失败置空后可见期内不热重试 → 离开可见清理后重回可重试
219
+ const loadDetail = {};
220
+ assert.deepEqual(
221
+ detailLoadPlan({ detail: loadDetail }),
222
+ { subagent: false, model: true, history: false },
223
+ "冷会话首次决策发起 models 读取",
224
+ );
225
+ assert.equal(
226
+ detailLoadPlan({ detail: loadDetail, modelInflight: true }).model,
227
+ false,
228
+ "读取在途时不重复发起",
229
+ );
230
+ loadDetail.model = { model: "", reasoning: "" };
231
+ assert.equal(detailLoadPlan({ detail: loadDetail }).model, false, "失败置空后可见期内不热重试");
232
+ assert.equal(detailLoadPlan({ detail: {} }).model, true, "离开可见清理后重回可见允许重试");
233
+ assert.deepEqual(
234
+ detailLoadPlan({ detail: {}, isSubagent: true }),
235
+ { subagent: true, model: false, history: false },
236
+ "子代理不发起 models 读取",
237
+ );
238
+ assert.equal(
239
+ detailLoadPlan({ detail: {}, historyNeeded: true }).history,
240
+ true,
241
+ "无快照冷会话决策发起 history 读取",
242
+ );
243
+ assert.equal(
244
+ detailLoadPlan({ detail: {}, historyNeeded: true, snapshotReady: true }).history,
245
+ false,
246
+ "原生快照已就绪且窗口数据齐全时不发 history 读取",
247
+ );
248
+ // R-01-013/AC-03、AC-04:最近卡窗口快照缺用户或 agent 预览时补读一次 history。
249
+ assert.equal(
250
+ detailLoadPlan({ detail: { history: [{ event: { seq: 1 } }] }, snapshotReady: true, previewFallbackNeeded: true }).history,
251
+ true,
252
+ "最近卡预览不完整时即使已有早到 history 也重新补读一次",
253
+ );
254
+ assert.equal(
255
+ detailLoadPlan({ detail: { history: [], previewFallbackLoaded: true }, snapshotReady: true, previewFallbackNeeded: true }).history,
256
+ false,
257
+ "最近卡预览 fallback 已尝试后可见期内不热重试",
258
+ );
259
+ // R-01-009/AC-06、R-01-012/AC-12 冷窗口兜底:快照就绪但窗口缺锚点数据(开放回合起点/用户行在窗口外)时补读 history
260
+ assert.equal(
261
+ detailLoadPlan({ detail: {}, snapshotReady: true, windowComplete: false }).history,
262
+ true,
263
+ "快照就绪但窗口缺锚点数据时发起 history 补读",
264
+ );
265
+ assert.equal(
266
+ detailLoadPlan({ detail: {}, snapshotReady: true, windowComplete: true }).history,
267
+ false,
268
+ "快照窗口锚点数据齐全时不发 history 读取",
269
+ );
270
+ assert.equal(
271
+ detailLoadPlan({ detail: { history: [] }, snapshotReady: true, windowComplete: false }).history,
272
+ false,
273
+ "窗口补读失败置空后可见期内不热重试",
274
+ );
275
+
276
+ // ---- R-01-012/AC-16 模型选择切换经目录订阅推送更新,一次性读取仅作初值 ----
277
+ // 目录 store 快照形状({current, groups, routable, status, ...})与 RPC value 同形兼容,经同一归一。
278
+ assert.deepEqual(
279
+ modelMetadata({
280
+ current: { provider: "p", model: "m2", reasoningEffort: "low" },
281
+ groups: [{ id: "p", models: [{ id: "m2", name: "Model M2", reasoning: { efforts: [{ id: "low", name: "Low" }] } }] }],
282
+ routable: true,
283
+ failures: [],
284
+ status: "ready",
285
+ error: null,
286
+ }),
287
+ { model: "Model M2", reasoning: "Low" },
288
+ "目录 store 推送快照直接归一为切换后的模型上下文",
289
+ );
290
+ // 订阅清理行为链:不可见 id 先 unsubscribe 再除名;可见 id 保留;单个 unsubscribe 抛错不阻断其余清理。
291
+ const subCalls = [];
292
+ const subMap = new Map([
293
+ ["stay", () => subCalls.push("stay")],
294
+ ["gone", () => subCalls.push("gone")],
295
+ ["bad", () => {
296
+ throw new Error("unsubscribe failed");
297
+ }],
298
+ ["gone2", () => subCalls.push("gone2")],
299
+ ]);
300
+ pruneSubscriptions(subMap, new Set(["stay"]));
301
+ assert.deepEqual(subCalls, ["gone", "gone2"], "不可见订阅被 unsubscribe,抛错不阻断后续清理");
302
+ assert.deepEqual([...subMap.keys()], ["stay"], "可见订阅保留、其余除名,监听器不残留");
303
+ pruneSubscriptions(null, new Set()); // 非 Map 输入静默忽略
304
+
305
+ // ---- R-01-008/AC-03 点击遮罩(抽屉外部)收起抽屉 ----
306
+ const backdropListeners = new Map();
307
+ const backdrop = {
308
+ addEventListener(type, listener) {
309
+ backdropListeners.set(type, listener);
310
+ },
311
+ removeEventListener(type, listener) {
312
+ if (backdropListeners.get(type) === listener) backdropListeners.delete(type);
313
+ },
314
+ };
315
+ let backdropDismissed = 0;
316
+ const unbindBackdrop = bindBackdropDismiss(backdrop, () => {
317
+ backdropDismissed += 1;
318
+ });
319
+ const backdropActivate = (type) => {
320
+ let prevented = false;
321
+ let stopped = false;
322
+ backdropListeners.get(type)?.({
323
+ type,
324
+ preventDefault: () => { prevented = true; },
325
+ stopPropagation: () => { stopped = true; },
326
+ });
327
+ return { prevented, stopped };
328
+ };
329
+ assert.deepEqual(backdropActivate("click"), { prevented: true, stopped: true }, "遮罩 click 收起抽屉并阻止宿主继续处理");
330
+ assert.equal(backdropDismissed, 1);
331
+ assert.deepEqual(backdropActivate("keydown"), { prevented: false, stopped: false }, "遮罩非 click 事件不收起");
332
+ assert.equal(backdropDismissed, 1);
333
+ const noopUnbind = bindBackdropDismiss(null, () => {});
334
+ assert.equal(typeof noopUnbind, "function", "非法输入返回 no-op 卸载函数");
335
+ noopUnbind();
336
+ unbindBackdrop();
337
+ assert.equal(backdropListeners.size, 0, "遮罩卸载移除 click 监听");
338
+ backdropActivate("click");
339
+ assert.equal(backdropDismissed, 1, "卸载后点击不再收起");
340
+
341
+ // ---- R-01-008/AC-06 二次激活当前会话卡片收起移动端抽屉 ----
342
+ assert.equal(
343
+ shouldDismissDrawerOnActivation({ targetId: "s1", currentId: "s1", mobile: true, drawerOpen: true }),
344
+ true,
345
+ "移动断点抽屉打开时激活当前会话卡片转为收起抽屉",
346
+ );
347
+ assert.equal(
348
+ shouldDismissDrawerOnActivation({ targetId: "s2", currentId: "s1", mobile: true, drawerOpen: true }),
349
+ false,
350
+ "激活非当前卡片仍走会话切换",
351
+ );
352
+ assert.equal(
353
+ shouldDismissDrawerOnActivation({ targetId: "s1", currentId: "s1", mobile: false, drawerOpen: true }),
354
+ false,
355
+ "桌面断点不收起(无抽屉形态)",
356
+ );
357
+ assert.equal(
358
+ shouldDismissDrawerOnActivation({ targetId: "s1", currentId: "s1", mobile: true, drawerOpen: false }),
359
+ false,
360
+ "抽屉未打开不收起",
361
+ );
362
+ assert.equal(
363
+ shouldDismissDrawerOnActivation({ targetId: "s1", currentId: null, mobile: true, drawerOpen: true }),
364
+ false,
365
+ "无当前会话不误判收起",
366
+ );
367
+ assert.equal(
368
+ shouldDismissDrawerOnActivation({ targetId: "", currentId: null, mobile: true, drawerOpen: true }),
369
+ false,
370
+ "空目标 id 不误判收起",
371
+ );
372
+ // ---- R-01-002/AC-01 待确认 | R-01-002/AC-02 待审查/提问中 ----
373
+ assert.equal(pendingText("approval"), "待确认");
374
+ assert.equal(pendingText("plan-review"), "待审查");
375
+ assert.equal(pendingText("question"), "提问中");
376
+ assert.equal(pendingText("approval"), "待确认");
377
+
378
+ // ---- R-01-002/AC-03 完成提醒以绿色成功卡面呈现(C-040) ----
379
+ // 未知阻塞种类兜底「待处理」(不冒充已知类型);完成态判定见下方 buildEntries 断言(R-01-001/AC-01)。
380
+ // 完成提醒卡不再显示类型徽标:pendingText 仅为阻塞等待承载(C-040)。
381
+ assert.equal(pendingText("unknown-kind"), "待处理");
382
+
383
+ // ---- R-01-002/AC-09 等待卡末行提示:动作+后果;待回复的问题由结构化列表另行承载 ----
384
+ assert.equal(awaitNoteText("blocked", "approval"), "等待你确认授权后继续");
385
+ assert.equal(awaitNoteText("blocked", "plan-review"), "等待你审查计划后继续");
386
+ assert.equal(awaitNoteText("blocked", "question"), "等待你回答问题后继续", "问题不可得时回落动作说明");
387
+ assert.equal(awaitNoteText("done", undefined), "继续对话,或移入历史");
388
+ // 宽度上界回归(R-01-002/AC-09):done 末行须与「移入历史」按钮同排在默认 280px 窗格
389
+ // 单行完整可见——内容区约 258px,按钮+gap 约占 66px,留文字约 192px;11px 全角字宽
390
+ // 即 11px/字符,故文案(含标点)不得超过 17 个全角字符。改长必触发省略号吃掉行动引导。
391
+ {
392
+ const note = awaitNoteText("done", undefined);
393
+ assert.ok(
394
+ [...note].length <= 17,
395
+ `完成提醒末行文案宽度上界:不超过 17 个全角字符(当前 ${[...note].length},R-01-002/AC-09)`,
396
+ );
397
+ }
398
+ assert.equal(awaitNoteText("blocked", "unknown-kind"), "等待你处理后继续", "未知阻塞种类中性兜底(评审修正)");
399
+
400
+ // ---- R-01-002/AC-09 结构化提问预览:保留原始序号,最多 3 条并携带省略语义 ----
401
+ assert.deepEqual(
402
+ askQuestionsPreview(JSON.stringify({ questions: [{ header: "方案确认", question: "采用哪个方案方向?", options: [] }] })),
403
+ { items: [{ index: 1, text: "采用哪个方案方向?" }], omitted: false },
404
+ "单个可展示问题保留为结构化条目,渲染层据此使用 bullet list",
405
+ );
406
+ assert.deepEqual(
407
+ askQuestionsPreview(JSON.stringify({ questions: [{ header: "演示选择", question: "这是一个测试用的单项选择题,你会看到哪种效果?" }, { header: "多选演示", question: "再多试一个可多选的问题(可以都不选直接跳过吗?不行的话随便点):" }] })),
408
+ {
409
+ items: [
410
+ { index: 1, text: "这是一个测试用的单项选择题,你会看到哪种效果?" },
411
+ { index: 2, text: "再多试一个可多选的问题(可以都不选直接跳过吗?不行的话随便点)" },
412
+ ],
413
+ omitted: false,
414
+ },
415
+ "多个问题逐条保留并剥除行尾多余冒号,渲染层据此使用编号列表",
416
+ );
417
+ assert.deepEqual(
418
+ askQuestionsPreview(JSON.stringify({ questions: [{ question: "第一行\n第二行不应出现" }] })),
419
+ { items: [{ index: 1, text: "第一行" }], omitted: false },
420
+ "多行问题只取物理首行",
421
+ );
422
+ assert.deepEqual(
423
+ askQuestionsPreview(JSON.stringify({ questions: [{ header: "仅头问题" }, { question: "第二题" }] })),
424
+ { items: [{ index: 1, text: "仅头问题" }, { index: 2, text: "第二题" }], omitted: false },
425
+ "问题正文缺失时回落该条 header",
426
+ );
427
+ assert.deepEqual(
428
+ askQuestionsPreview(JSON.stringify({ questions: [{ question: "问1" }, { question: "问2" }, { question: "问3" }, { question: "问4" }] })),
429
+ { items: [{ index: 1, text: "问1" }, { index: 2, text: "问2" }, { index: 3, text: "问3" }], omitted: true },
430
+ "最多展示 3 条问题并携带省略项语义",
431
+ );
432
+ assert.deepEqual(
433
+ askQuestionsPreview(JSON.stringify({ questions: [{ question: "问1" }, { options: [] }, { question: "问3" }, { question: "问4" }] })),
434
+ { items: [{ index: 1, text: "问1" }, { index: 3, text: "问3" }, { index: 4, text: "问4" }], omitted: false },
435
+ "中间问题不可得时跳过,编号仍对应原数组位置",
436
+ );
437
+ assert.deepEqual(
438
+ askQuestionsPreview(JSON.stringify({ questions: [{ options: [] }, { question: "问2" }] })),
439
+ { items: [{ index: 2, text: "问2" }], omitted: false },
440
+ "只有一个可展示问题时仍保留其原始位置,渲染层按可展示条数选择 bullet list",
441
+ );
442
+ assert.equal(askQuestionsPreview(JSON.stringify({ questions: [{ options: [] }] })), null, "各条均无 header 也无正文时返回 null,由调用方回落动作说明");
443
+ assert.equal(askQuestionsPreview("not-json"), null);
444
+ assert.equal(askQuestionsPreview(JSON.stringify({ questions: [] })), null);
445
+ assert.equal(askQuestionsPreview(undefined), null);
446
+ const timelineQuestions = { items: [{ index: 1, text: "要合并回 main 吗?" }, { index: 2, text: "需要先跑测试吗?" }], omitted: false };
447
+ assert.deepEqual(
448
+ timelineQuestionPreview([
449
+ { fold: true, label: "正在运行", question: null },
450
+ { fold: true, label: "正在运行", question: timelineQuestions },
451
+ ]),
452
+ timelineQuestions,
453
+ "结构化提问预览穿透折叠分组上浮组行",
454
+ );
455
+ assert.equal(timelineQuestionPreview([{ fold: true, label: "已思考" }]), null);
456
+ assert.equal(timelineQuestionPreview(undefined), null);
457
+
458
+ // ---- R-01-003/AC-03 工作区归属 ----
459
+ const workspaces = [
460
+ { title: "Ops", path: "/srv/ops", sessionIds: ["sA"] },
461
+ { title: "Mail", path: "/srv/mail", sessionIds: [] },
462
+ ];
463
+ assert.equal(workspaceInfoForSession("sA", workspaces).title, "Ops");
464
+ assert.equal(
465
+ workspaceInfoForSession("sB", workspaces, { sB: { cwd: "/srv/mail" } }).title,
466
+ "Mail",
467
+ );
468
+ assert.equal(workspaceInfoForSession("sX", workspaces).title, "");
469
+
470
+ // ---- R-01-003/AC-08 工作区身份归一与徽标色相稳定性 ----
471
+ assert.deepEqual(workspaceInfoForSession("sA", workspaces), { title: "Ops", key: "/srv/ops" }, "归属命中时身份以路径为准(R-01-003/AC-08)");
472
+ assert.deepEqual(
473
+ workspaceInfoForSession("sB", workspaces, { sB: { cwd: "/srv/mail" } }),
474
+ { title: "Mail", key: "/srv/mail" },
475
+ "cwd 匹配命中时身份同样以路径为准(R-01-003/AC-08)",
476
+ );
477
+ assert.deepEqual(workspaceInfoForSession("sX", workspaces), { title: "", key: "" }, "无归属时名称与身份皆空(R-01-003/AC-08)");
478
+ assert.deepEqual(
479
+ workspaceInfoForSession("sC", [{ title: "Solo", sessionIds: ["sC"] }]),
480
+ { title: "Solo", key: "Solo" },
481
+ "工作区无路径时身份以名称兜底(R-01-003/AC-08)",
482
+ );
483
+ assert.equal(workspaceHue(""), null, "空身份不派生色相(R-01-003/AC-08)");
484
+ assert.equal(workspaceHue(" "), null, "空白身份不派生色相(R-01-003/AC-08)");
485
+ assert.equal(workspaceHue("/srv/ops"), workspaceHue("/srv/ops"), "同一工作区身份恒得同一色相(R-01-003/AC-08)");
486
+ assert.notEqual(workspaceHue("/srv/ops"), workspaceHue("/srv/mail"), "不同工作区身份色相可区分(R-01-003/AC-08、AC-09)");
487
+ const hueOps = workspaceHue("/srv/ops");
488
+ assert.ok(Number.isInteger(hueOps), "色相为整数(R-01-003/AC-08)");
489
+ assert.equal(workspaceHue("Solo"), workspaceHue(String("So" + "lo")), "派生只依赖身份字符串、与运行状态无关(R-01-003/AC-08)");
490
+
491
+ // ---- R-01-003/AC-09 全弧均匀取色:[30,320] 避红弧、291 个取值 ----
492
+ for (const hueKey of ["/srv/ops", "/srv/mail", "/srv/web", "Solo", "/opt/alpha", "/opt/beta"]) {
493
+ const hue = workspaceHue(hueKey);
494
+ assert.ok(
495
+ hue >= 30 && hue <= 320,
496
+ `色相 ${hue} 落在避红弧 [30,320] 内,不落入红色警戒区(R-01-003/AC-09)`,
497
+ );
498
+ }
499
+ // 长公共前缀的现实工作区身份两两可区分(djb2 低位聚集回归防护;
500
+ // 样本取自 T-070 后东家复现「同显蓝色」的现场路径形态,非任意键,勿随意替换)
501
+ const hueFamily = [
502
+ "/home/cailei/proj/dsh-activity-pane",
503
+ "/home/cailei/proj/dsh",
504
+ "/home/cailei/proj/answer-pet",
505
+ "/home/cailei/proj/blog",
506
+ "/home/cailei/proj/notes",
507
+ ];
508
+ const familyHues = hueFamily.map((key) => workspaceHue(key));
509
+ assert.equal(new Set(familyHues).size, hueFamily.length, "长公共前缀工作区色相两两不同(R-01-003/AC-09)");
510
+ // 聚集回归(性质级):50 个长公共前缀身份应广泛分散,而非挤进相邻取值
511
+ const hueSpread = new Set();
512
+ for (let i = 0; i < 50; i += 1) hueSpread.add(workspaceHue(`/home/user/proj/ws-${i}`));
513
+ assert.ok(hueSpread.size >= 40, `50 个长前缀身份分散到 ${hueSpread.size} 个不同色相(≥40,R-01-003/AC-09)`);
514
+
515
+ // ---- R-01-003/AC-12 OKLCH 七色感知锚点 + 步进 3 跨色区槽位消解 ----
516
+ const workspaceHueAnchors = [55, 100, 145, 190, 235, 280, 325];
517
+ const realWorkspaceCluster = [
518
+ "/home/cailei/ops",
519
+ "/home/cailei/proj/docsim",
520
+ "/home/cailei/proj/dsh-activity-pane",
521
+ "/home/cailei/proj/dsh-control-center",
522
+ ];
523
+ const resolvedCluster = resolveWorkspaceHues(realWorkspaceCluster);
524
+ assert.deepEqual(
525
+ [...resolvedCluster.entries()],
526
+ [
527
+ ["/home/cailei/ops", 280],
528
+ ["/home/cailei/proj/docsim", 55],
529
+ ["/home/cailei/proj/dsh-activity-pane", 190],
530
+ ["/home/cailei/proj/dsh-control-center", 100],
531
+ ],
532
+ "真实撞槽子集以 +3 探测确定性拆分到蓝紫/橙/青/黄绿明显色区(R-01-003/AC-12)",
533
+ );
534
+ assert.deepEqual(
535
+ [...resolveWorkspaceHues([...realWorkspaceCluster].reverse(), "", " ", realWorkspaceCluster[0]).entries()],
536
+ [...resolvedCluster.entries()],
537
+ "输入顺序、重复项与空白身份不影响消解映射(R-01-003/AC-08、AC-12)",
538
+ );
539
+ assert.deepEqual([...resolveWorkspaceHues(null).entries()], [], "无身份集合返回空映射(R-01-003/AC-12)");
540
+ const sevenHues = [...resolveWorkspaceHues(Array.from({ length: 7 }, (_, index) => `/home/user/proj/seven-${index}`)).values()];
541
+ assert.deepEqual([...sevenHues].sort((a, b) => a - b), workspaceHueAnchors, "七个工作区恰占满七个避红 OKLCH 感知锚点(R-01-003/AC-12)");
542
+ const oklabHueDistance = (hueA, hueB, chroma) => {
543
+ const a = hueA * Math.PI / 180;
544
+ const b = hueB * Math.PI / 180;
545
+ return Math.hypot(chroma * Math.cos(a) - chroma * Math.cos(b), chroma * Math.sin(a) - chroma * Math.sin(b));
546
+ };
547
+ for (let i = 0; i < sevenHues.length; i += 1)
548
+ for (let j = i + 1; j < sevenHues.length; j += 1) {
549
+ assert.ok(oklabHueDistance(sevenHues[i], sevenHues[j], 0.16) >= 0.11, "深色主题七锚点任意两色 OKLab 距离至少 0.11(R-01-003/AC-12)");
550
+ assert.ok(oklabHueDistance(sevenHues[i], sevenHues[j], 0.15) >= 0.11, "浅色主题七锚点任意两色 OKLab 距离至少 0.11(R-01-003/AC-12)");
551
+ }
552
+ const crowdedHues = resolveWorkspaceHues(Array.from({ length: 20 }, (_, index) => `/home/user/proj/crowded-${index}`));
553
+ assert.equal(crowdedHues.size, 20, "超容量集合仍为每个身份返回色相并有限终止(R-01-003/AC-12)");
554
+ assert.ok([...crowdedHues.values()].every((hue) => workspaceHueAnchors.includes(hue)), "超容量时仍只使用七个避红 OKLCH 感知锚点(R-01-003/AC-12)");
555
+ const anchorUses = workspaceHueAnchors.map((anchor) => [...crowdedHues.values()].filter((hue) => hue === anchor).length);
556
+ assert.ok(Math.max(...anchorUses) - Math.min(...anchorUses) <= 1, "超容量时七锚点复用计数差不超过 1(R-01-003/AC-12)");
557
+
558
+ // ---- R-01-001/AC-01 活动卡片逐条显示 | R-01-003/AC-01 子代理嵌套 | R-01-003/AC-02 子代理结束即消失 | R-01-006/AC-01 当前会话 ----
559
+ const snapshot = {
560
+ ids: ["sA", "sA-c1", "sA-c2", "sB", "sX"],
561
+ byId: {
562
+ sA: { id: "sA", displayTitle: "主A", running: true, completed: false },
563
+ "sA-c1": { id: "sA-c1", displayTitle: "子1", running: true, parentId: "sA" },
564
+ "sA-c2": { id: "sA-c2", displayTitle: "子2", running: false, completed: true, parentId: "sA" },
565
+ sB: { id: "sB", displayTitle: "主B", running: false, completed: true },
566
+ sX: { id: "sX", displayTitle: "主X", running: false, completed: false },
567
+ },
568
+ current: "sA",
569
+ subagentsByParent: {
570
+ sA: { entries: [{ id: "sA-c1", label: "子代理一号" }] },
571
+ },
572
+ };
573
+ const entries = buildEntries(snapshot, workspaces, {}, new Map([["sB", { lastTurnEnd: 1000, ackedAt: null }]]));
574
+ assert.deepEqual(
575
+ entries.map((e) => [e.id, e.kind, e.depth, e.title]),
576
+ [
577
+ ["sA", "running", 0, "主A"],
578
+ ["sA-c1", "subagent", 1, "子代理一号"],
579
+ ["sB", "awaiting", 0, "主B"],
580
+ ],
581
+ "运行/等待主会话出现、子代理嵌套缩进、完成的子代理消失",
582
+ );
583
+ assert.equal(entries[0].isCurrent, true, "当前会话高亮标记");
584
+ assert.equal(entries[1].parentId, "sA", "子代理条目保留直属母会话 id");
585
+ assert.deepEqual(trackRuns(entries), [{ parentId: "sA", depth: 1, childIds: ["sA-c1"] }], "唯一可见子代理产生一条母会话轨道运行");
586
+ const hierarchyEntries = [
587
+ { id: "root", kind: "running", depth: 0 },
588
+ { id: "A", kind: "subagent", parentId: "root", depth: 1 },
589
+ { id: "G", kind: "subagent", parentId: "A", depth: 2 },
590
+ { id: "B", kind: "subagent", parentId: "root", depth: 1 },
591
+ ];
592
+ assert.deepEqual(
593
+ trackRuns(hierarchyEntries),
594
+ [
595
+ { parentId: "root", depth: 1, childIds: ["A", "B"] },
596
+ { parentId: "A", depth: 2, childIds: ["G"] },
597
+ ],
598
+ "P→A→G→B:root 与 A 各一条连续轨道,跨孙级区间由同一元素覆盖(断线回归)",
599
+ );
600
+ assert.deepEqual(trackRuns(hierarchyEntries.slice(0, 3)), [
601
+ { parentId: "root", depth: 1, childIds: ["A"] },
602
+ { parentId: "A", depth: 2, childIds: ["G"] },
603
+ ], "无后续同级时轨道収于各自末级子代理");
604
+ assert.deepEqual(trackRuns([hierarchyEntries[0]]), [], "无子代理的母会话不产生轨道");
605
+ assert.deepEqual(trackRuns([{ kind: "subagent", parentId: "p", depth: 1 }]), [], "无 id 条目不产生轨道");
606
+ assert.deepEqual(
607
+ trackRuns([...hierarchyEntries, { id: "X", kind: "subagent", parentId: "root", depth: 3 }]),
608
+ [
609
+ { parentId: "root", depth: 1, childIds: ["A", "B"] },
610
+ { parentId: "A", depth: 2, childIds: ["G"] },
611
+ ],
612
+ "非直属条目不纳入轨道、不改末级",
613
+ );
614
+ assert.deepEqual(
615
+ trackRuns([
616
+ { id: "root", kind: "running", depth: 0 },
617
+ { id: "X", kind: "subagent", parentId: "root", depth: 2 },
618
+ { id: "A", kind: "subagent", parentId: "root", depth: 1 },
619
+ ]),
620
+ [{ parentId: "root", depth: 1, childIds: ["A"] }],
621
+ "异常深度条目先来也不污染轨道:直属性按母会话条目深度+1 判定,与顺序无关",
622
+ );
623
+ const hierarchyRuns = trackRuns(hierarchyEntries);
624
+ const hierarchyRects = {
625
+ root: { top: 0, height: 40, left: 8 },
626
+ A: { top: 46, height: 30, left: 24 },
627
+ G: { top: 82, height: 30, left: 40 },
628
+ B: { top: 118, height: 30, left: 24 },
629
+ };
630
+ assert.deepEqual(
631
+ trackBoxes(hierarchyRuns[0], (id) => hierarchyRects[id] ?? null, 16),
632
+ {
633
+ track: { top: 40, left: 17, height: 94 },
634
+ stubs: [
635
+ { top: 61, left: 18, width: 6 },
636
+ { top: 133, left: 18, width: 6 },
637
+ ],
638
+ },
639
+ "root 竖轨起于母会话底缘、穿过 G 所在区间、延伸进末级 B 的收口行;A/B 横线从竖轨右缘到各卡片左缘(几何回归)",
640
+ );
641
+ assert.deepEqual(
642
+ trackBoxes(hierarchyRuns[1], (id) => hierarchyRects[id] ?? null, 16),
643
+ {
644
+ track: { top: 76, left: 33, height: 22 },
645
+ stubs: [{ top: 97, left: 34, width: 6 }],
646
+ },
647
+ "A 竖轨起于 A 底缘、延伸进 G 的收口行(末级精确収口),横线同理相接",
648
+ );
649
+ assert.equal(
650
+ trackBoxes(hierarchyRuns[0], () => ({ top: 0, height: 0, left: 0 }), 16),
651
+ null,
652
+ "折叠/隐藏态零高度读数不绘制轨道,展开后由 ResizeObserver 重算",
653
+ );
654
+ assert.equal(trackBoxes(hierarchyRuns[0], () => null, 16), null, "卡片缺失时跳过该轨道(下轮渲染自愈)");
655
+ assert.equal(entries[2].workspaceTitle, "", "无归属则无工作区徽标");
656
+ assert.equal(entries[0].workspaceKey, "/srv/ops", "活动条目携带工作区身份(路径优先,R-01-003/AC-08)");
657
+ assert.equal(entries[1].workspaceKey, "", "子代理徽标隐藏、身份置空(R-01-003/AC-08)");
658
+ assert.equal(entries[2].workspaceKey, "", "无归属条目身份为空(R-01-003/AC-08)");
659
+ const recentKeyEntries = buildRecent(
660
+ { ids: ["sA"], byId: { sA: { id: "sA", displayTitle: "主A", running: false, completed: false, updatedAt: 1000 } }, current: null },
661
+ workspaces,
662
+ 2000,
663
+ );
664
+ assert.equal(recentKeyEntries[0]?.workspaceKey, "/srv/ops", "最近条目同样携带工作区身份(R-01-003/AC-08)");
665
+ // ---- R-01-003/AC-05 活动子代理补齐所有非活动母会话 ----
666
+ const inheritedActivity = {
667
+ ids: ["root", "parent", "child"],
668
+ byId: {
669
+ root: { id: "root", displayTitle: "根母会话", running: false, completed: false, updatedAt: 1900 },
670
+ parent: { id: "parent", displayTitle: "中间母会话", running: false, completed: false, parentId: "root", updatedAt: 1900 },
671
+ child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "parent" },
672
+ },
673
+ current: null,
674
+ };
675
+ assert.deepEqual(
676
+ [...activeSessionIds(inheritedActivity.byId)].sort(),
677
+ ["child", "parent", "root"],
678
+ "活动子代理沿 parentId 链补齐所有有效母会话",
679
+ );
680
+ assert.deepEqual(
681
+ buildEntries(inheritedActivity, []).map((entry) => [entry.id, entry.kind, entry.depth]),
682
+ [["root", "running", 0], ["parent", "subagent", 1], ["child", "subagent", 2]],
683
+ "委托周期中主会话母会话保持 running 呈现、子代理母会话保持 subagent 呈现,层级深度不变(R-01-003/AC-05)",
684
+ );
685
+ assert.deepEqual(
686
+ buildEntries(inheritedActivity, []).map((entry) => entry.descendantActive),
687
+ [true, true, false],
688
+ "委托周期母会话携带 descendantActive 标记(R-01-003/AC-05)",
689
+ );
690
+ assert.deepEqual(buildRecent(inheritedActivity, [], 2000), [], "活动祖先不进入最近历史区");
691
+ assert.equal(
692
+ shouldSubscribeToSession({ id: "parent", kind: "running" }, inheritedActivity.byId),
693
+ false,
694
+ "委托周期母会话不建立轮内状态订阅(宿主 running 为准,R-02-004/AC-01)",
695
+ );
696
+ // ---- R-01-002/AC-03、R-01-010/AC-06 委托周期压制完成提醒 ----
697
+ const delegCompleted = {
698
+ ids: ["root", "child"],
699
+ byId: {
700
+ root: { id: "root", displayTitle: "母会话", running: false, updatedAt: 1900 },
701
+ child: { id: "child", displayTitle: "活动子会话", running: true, parentId: "root" },
702
+ },
703
+ current: null,
704
+ };
705
+ assert.deepEqual(
706
+ buildEntries(delegCompleted, []).map((entry) => [entry.id, entry.kind, entry.pendingText ?? null]),
707
+ [["root", "running", null], ["child", "subagent", null]],
708
+ "存在活动后代时完成确认不产出「已完成」、卡片保持运行呈现(R-01-002/AC-03)",
709
+ );
710
+ assert.deepEqual(
711
+ buildEntries(delegCompleted, [], {}, null, new Set(["root"])).map((entry) => [entry.id, entry.kind, entry.pendingText ?? null]),
712
+ [["root", "running", null], ["child", "subagent", null]],
713
+ "存在活动后代时完成提醒不生效、卡片保持运行呈现(R-01-010/AC-06)",
714
+ );
715
+ assert.deepEqual(
716
+ buildEntries({ ids: ["root"], byId: { root: delegCompleted.byId.root }, current: null }, [], {}, new Map([["root", { lastTurnEnd: 1500, ackedAt: null }]])).map((entry) => [entry.id, entry.kind, entry.pendingText ?? null, entry.waitClass ?? null, entry.noteText ?? null]),
717
+ [["root", "awaiting", null, "done", "继续对话,或移入历史"]],
718
+ "后代全部结束后完成提醒恢复显示(R-01-002/AC-03、AC-09)",
719
+ );
720
+ // ---- R-01-003/AC-05、R-01-009/AC-06 耗尽空窗(后代结束、settle 回合未启动)保持运行呈现 ----
721
+ const drainGap = {
722
+ ids: ["root"],
723
+ byId: { root: { id: "root", displayTitle: "母会话", running: false, updatedAt: 1900 } },
724
+ current: null,
725
+ };
726
+ assert.deepEqual(
727
+ buildEntries(drainGap, [], {}, null, new Set(["root"])).map((entry) => [entry.id, entry.kind, entry.pendingText ?? null, entry.descendantActive]),
728
+ [["root", "running", null, false]],
729
+ "耗尽空窗内委托周期保持运行呈现、完成提醒不生效;descendantActive 仍为当帧原始后代活性(R-01-003/AC-05、R-01-002/AC-03)",
730
+ );
731
+ assert.deepEqual(
732
+ buildEntries(drainGap, [], {}, new Map([["root", { lastTurnEnd: 1500, ackedAt: null }]]), null).map((entry) => [entry.id, entry.kind, entry.waitClass ?? null]),
733
+ [["root", "awaiting", "done"]],
734
+ "无委托周期记账时同一快照回到等待呈现(空窗保持来自渲染层 delegatingIds 注入)",
735
+ );
736
+ // 分区不变量:空窗内不入最近历史,周期结束后才入。
737
+ const drainGapIdle = {
738
+ ids: ["root"],
739
+ byId: { root: { id: "root", displayTitle: "母会话", running: false, completed: false, updatedAt: 1900 } },
740
+ current: null,
741
+ };
742
+ assert.equal(
743
+ buildRecent(drainGapIdle, [], 2000, undefined, {}, [], null, new Set(["root"])).length,
744
+ 0,
745
+ "耗尽空窗内委托周期会话不入最近历史(R-01-010 分区不变量)",
746
+ );
747
+ assert.equal(buildRecent(drainGapIdle, [], 2000, undefined, {}, [], null).length, 1, "委托周期结束后才入最近历史");
748
+ // delegationActive:耗尽宽限内视为委托周期(空窗保持),超时退出。
749
+ assert.equal(delegationActive({ mode: "delegating", anchor: 1000, turnStart: 9000, drainedAt: null }, 50000), true, "委托周期中视为活动");
750
+ assert.equal(delegationActive({ mode: "delegating", anchor: 1000, turnStart: 9000, drainedAt: 30000 }, 31000), true, "耗尽宽限内视为活动(空窗保持运行呈现)");
751
+ assert.equal(delegationActive({ mode: "delegating", anchor: 1000, turnStart: 9000, drainedAt: 30000 }, 91001), false, "耗尽宽限超时退出委托周期");
752
+ assert.equal(delegationActive({ mode: "turn", anchor: 1000, turnStart: 1000, drainedAt: null }, 50000), false, "非委托周期不视为活动");
753
+ assert.equal(delegationActive(null, 50000), false, "无记账不视为委托周期");
754
+ assert.equal(
755
+ shouldSubscribeToSession({ id: "child", kind: "subagent" }, inheritedActivity.byId),
756
+ true,
757
+ "运行中的子代理建立轮内状态订阅",
758
+ );
759
+
760
+ // ---- R-01-001/AC-02 无活动会话时为空态 | R-02-001/AC-01、R-02-001/AC-02 独立数据源 ----
761
+ // 核心映射只消费 DSH 原生快照结构(无任何第三方数据源引用)。
762
+ const snapshotOnly = { ids: [], byId: {}, current: null };
763
+ assert.deepEqual(buildEntries(snapshotOnly, []), [], "空快照产出空条目");
764
+
765
+ // ---- R-01-002/AC-01..02 等待优先于运行态 | 阻塞等待条目携带 waitClass/pendingKind/noteText ----
766
+ const pendingSnap = {
767
+ ids: ["sP"],
768
+ byId: {
769
+ sP: { id: "sP", displayTitle: "主P", running: true, pendingInteraction: "approval" },
770
+ },
771
+ current: null,
772
+ };
773
+ const pendingEntries = buildEntries(pendingSnap, []);
774
+ assert.equal(pendingEntries[0].kind, "awaiting", "pending 覆盖 running");
775
+ assert.equal(pendingEntries[0].pendingText, "待确认");
776
+ assert.equal(pendingEntries[0].waitClass, "blocked", "待确认为阻塞等待(R-01-002/AC-08)");
777
+ assert.equal(pendingEntries[0].pendingKind, "approval");
778
+ assert.equal(pendingEntries[0].noteText, "等待你确认授权后继续", "阻塞等待备注行说明动作与后果(R-01-002/AC-09)");
779
+ // 待回复卡:条目携带时间线末条 ask 工作项的结构化提问预览(R-01-002/AC-09)。
780
+ const questionSnap = {
781
+ ids: ["sQ"],
782
+ byId: { sQ: { id: "sQ", displayTitle: "主Q", running: false, pendingInteraction: "question" } },
783
+ current: null,
784
+ };
785
+ const questionPreview = {
786
+ items: [
787
+ { index: 1, text: "这是一个测试用的单项选择题,你会看到哪种效果?" },
788
+ { index: 2, text: "再多试一个可多选的问题(可以都不选直接跳过吗?不行的话随便点)" },
789
+ ],
790
+ omitted: false,
791
+ };
792
+ const questionEntries = buildEntries(questionSnap, [], {
793
+ sQ: { timeline: [{ fold: true, label: "正在运行", summary: "等待回答", question: questionPreview }] },
794
+ });
795
+ assert.equal(questionEntries[0].pendingText, "提问中");
796
+ assert.equal(questionEntries[0].pendingKind, "question");
797
+ assert.equal(questionEntries[0].noteText, "等待你回答问题后继续", "提问列表由 questionPreview 单独承载,noteText 保留回落文案");
798
+ assert.deepEqual(questionEntries[0].questionPreview, questionPreview, "待回复条目携带结构化提问列表");
799
+ const questionFallback = buildEntries(questionSnap, [], { sQ: { timeline: [] } });
800
+ assert.equal(questionFallback[0].noteText, "等待你回答问题后继续", "问题不可得时回落动作说明");
801
+ assert.equal(questionFallback[0].questionPreview, null, "问题不可得时不创建空列表");
802
+
803
+ // ---- R-01-002/AC-06 计数徽标底色跟随等待构成(C-040、C-043):错误 > 阻塞 > 完成 ----
804
+ assert.equal(awaitBadgeTone([]), null, "无等待行动无 tone");
805
+ assert.equal(awaitBadgeTone([{ kind: "running" }]), null, "运行卡不参与 tone");
806
+ assert.equal(awaitBadgeTone([{ kind: "awaiting", waitClass: "done" }]), "done", "全部等待为完成提醒时取绿色调");
807
+ assert.equal(awaitBadgeTone([{ kind: "subagent", waitClass: "blocked" }]), null, "子代理不计入 tone");
808
+ assert.equal(awaitBadgeTone([{ kind: "awaiting", waitClass: "error" }]), "error", "存在错误提醒即取红色调(最紧迫)");
809
+ assert.equal(
810
+ awaitBadgeTone([{ kind: "awaiting", waitClass: "done" }, { kind: "awaiting", waitClass: "blocked" }]),
811
+ "blocked",
812
+ "存在任一阻塞等待即取金色调:紧迫信号优先于完成提醒",
813
+ );
814
+ assert.equal(
815
+ awaitBadgeTone([{ kind: "awaiting", waitClass: "done" }, { kind: "awaiting", waitClass: "error" }]),
816
+ "error",
817
+ "错误提醒优先于完成提醒",
818
+ );
819
+ assert.equal(
820
+ awaitBadgeTone([
821
+ { kind: "awaiting", waitClass: "done" },
822
+ { kind: "awaiting", waitClass: "error" },
823
+ { kind: "awaiting", waitClass: "blocked" },
824
+ ]),
825
+ "error",
826
+ "错误 > 阻塞 > 完成 优先级(C-043)",
827
+ );
828
+ assert.equal(
829
+ awaitBadgeTone([
830
+ { kind: "awaiting", waitClass: "blocked" },
831
+ { kind: "awaiting", waitClass: "error" },
832
+ ]),
833
+ "error",
834
+ "错误提醒在阻塞等待之后出现时仍取红色调,优先级不依赖条目顺序(R-01-002/AC-06)",
835
+ );
836
+
837
+ // ---- R-01-001/AC-05 徽标计数口径:只统计主会话,子代理不计入 | R-01-002/AC-06 阻塞计数 ----
838
+ assert.deepEqual(awaitBadgeStats([]), { waiting: 0, blocked: 0, total: 0 }, "空列表为 0/0(R-01-001/AC-06)");
839
+ assert.deepEqual(
840
+ awaitBadgeStats([
841
+ { id: "a", kind: "running" },
842
+ { id: "b", kind: "awaiting", waitClass: "blocked" },
843
+ { id: "c", kind: "awaiting", waitClass: "done" },
844
+ { id: "d", kind: "subagent" },
845
+ ]),
846
+ { waiting: 2, blocked: 1, total: 3 },
847
+ "分子=awaiting 主会话数,分母=running+awaiting 主会话数;blocked 只计阻塞等待",
848
+ );
849
+ // ---- R-01-001/AC-01 可重复的确定性渲染(见下) | R-02-003/AC-01 渲染签名去重 ----
850
+ const e1 = buildEntries(snapshot, workspaces);
851
+ const e2 = buildEntries(snapshot, workspaces);
852
+ assert.equal(cardSignature(e1), cardSignature(e2), "相同状态签名相等→跳过重绘");
853
+ assert.notEqual(
854
+ cardSignature(e1),
855
+ cardSignature([{ ...e1[0], title: "改" }]),
856
+ "状态变化签名必变",
857
+ );
858
+ assert.notEqual(
859
+ cardSignature(e1),
860
+ cardSignature([{ ...e1[0], workspaceKey: "/srv/elsewhere" }]),
861
+ "工作区身份变化签名必变,徽标色相随之重绘(R-01-003/AC-08)",
862
+ );
863
+
864
+ // ---- R-01-003/AC-01 无目录 label 时回退显示标题 ----
865
+ assert.equal(
866
+ subagentTitle("sA", "sA-c2", snapshot.byId, snapshot.subagentsByParent),
867
+ "子2",
868
+ );
869
+
870
+ // R-01-009/AC-01
871
+ // R-01-009/AC-02
872
+ // R-01-009/AC-03
873
+ // R-01-009/AC-05
874
+ // ---- 当前动作进入工作项/统计字段,不再拼接状态前缀 ----
875
+ assert.deepEqual(
876
+ runtimeStats({ elapsedMs: 125000, outputTokens: 1200, rateTokS: 12.3 }),
877
+ { elapsedMs: 125000, outputTokens: 1200, rateTokS: 12.3 },
878
+ "运行统计保留原始字段,不生成独立当前动作文案",
879
+ );
880
+ assert.equal(fmtElapsedMs(47_000), "47s", "时长短格式");
881
+ assert.equal(fmtElapsedMs(193_000), "3m13s", "时长分秒格式");
882
+ // R-01-009/AC-03
883
+ assert.equal(fmtElapsedMs(NaN), "", "NaN 时长归一为空");
884
+ assert.equal(fmtElapsedMs(Infinity), "", "Infinity 时长归一为空");
885
+ assert.equal(fmtElapsedMs(-1), "", "负时长归一为空");
886
+
887
+ // R-01-012/AC-01
888
+ // R-01-012/AC-02
889
+ // R-01-012/AC-03
890
+ // R-01-012/AC-04
891
+ // ---- 活动卡模型上下文、主窗口 order 最近 4 项与 live 项 ----
892
+ assert.equal(firstPhysicalLine(" \n 第一行 \n第二行"), "第一行", "物理首行跳过空行且保留行语义");
893
+ assert.equal(firstPhysicalLine("\n\t"), "", "全空白消息预览为空");
894
+ const chatNodes = new Map([
895
+ ["u", { key: "u", kind: "user", anchorSeq: 1, data: { content: [{ type: "text", text: "用户任务\n补充" }] } }],
896
+ ["a", { key: "a", kind: "assistant-step", anchorSeq: 2, data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "text", text: "已完成\n详情" }] } }],
897
+ ["t", { key: "t", kind: "tool-call", anchorSeq: 3, data: { root: { kind: "tool-result", callId: "c1", call: { name: "read", argsRaw: '{"path":"/tmp/a"}' }, callTime: 10, time: 35, isError: false } } }],
898
+ ["live", { key: "live", kind: "assistant-step", anchorSeq: 4, data: { status: "running", turn: 2, step: 0, blocks: [{ kind: "text", text: "正在输出" }] } }],
899
+ ["old", { key: "old", kind: "assistant-step", anchorSeq: 0, data: { status: "settled", blocks: [{ kind: "text", text: "旧项" }] } }],
900
+ ]);
901
+ const chatSnapshot = {
902
+ chat: { order: ["old", "u", "a", "t", "live"], nodes: { get: (key) => chatNodes.get(key) } },
903
+ };
904
+ const timeline = conversationWorkItems(chatSnapshot);
905
+ assert.deepEqual(timeline.map((item) => item.text), ["用户任务\n补充", "已完成\n详情", "read", "正在输出"], "工作项严格按主窗口 order 取最近 4 项并包含当前项");
906
+ assert.equal(timeline[2].detail, "/tmp/a", "工具详情沿用白名单摘要");
907
+ assert.equal(timeline[2].label, "Read", "工具标题复用主网页的 Read 语义");
908
+ assert.equal(timeline[2].toolName, "read", "工作项保留原始 tool name(折叠分组成员派生输入)");
909
+ assert.equal(timeline[2].callId, "c1", "工作项保留 call id(折叠分组成员派生输入)");
910
+ assert.equal(timeline[2].summary, "/tmp/a", "工具行摘要与标题分层");
911
+ const thinkItem = conversationWorkItems({
912
+ chat: { order: ["think"], nodes: { get: () => ({ kind: "assistant-step", data: { turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "Planning path" }] } }) } },
913
+ })[0];
914
+ assert.equal(thinkItem.label, "思考", "推理工作项 label 为中文「思考」(R-01-012/AC-10)");
915
+ assert.equal(thinkItem.summary, "Planning path", "推理工作项摘要单独保留");
916
+ const grepItem = conversationWorkItems({
917
+ chat: { order: ["grep"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "grep-1", call: { name: "grep", argsRaw: '{"pattern":"foo"}' } } } }) } },
918
+ })[0];
919
+ assert.equal(grepItem.label, "Grep", "grep 标题与主会话网页一致");
920
+ const globItem = conversationWorkItems({
921
+ chat: { order: ["glob"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "glob-1", call: { name: "glob", argsRaw: "{}" } } } }) } },
922
+ })[0];
923
+ assert.equal(globItem.label, "Glob", "glob 标题与主会话网页一致(SEARCH_TITLES: glob → Glob)");
924
+ const webFetchItem = conversationWorkItems({
925
+ chat: { order: ["wf"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "wf-1", call: { name: "web_fetch", argsRaw: '{"url":"https://a.b"}' } } } }) } },
926
+ })[0];
927
+ assert.equal(webFetchItem.label, "Fetch", "web_fetch 标题与主会话网页一致(WEB_TITLES: web_fetch → Fetch)");
928
+ const cordisItem = conversationWorkItems({
929
+ chat: { order: ["c"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "c-1", call: { name: "cordis_run", argsRaw: "{}" } } } }) } },
930
+ })[0];
931
+ assert.equal(cordisItem.label, "Run Cordis Plugin", "cordis 动作标题使用主会话网页完整动宾文案");
932
+ const outsideCurrent = conversationWorkItems({
933
+ chat: { order: ["u1", "u2", "u3", "u4", "u5"], nodes: { get: (key) => ({ key, kind: "user", data: { content: [{ type: "text", text: key }] } }) } },
934
+ partial: { turn: 2, step: 0, blocks: [{ kind: "text", text: "当前项" }] },
935
+ });
936
+ assert.deepEqual(outsideCurrent.map((item) => item.text), ["u3", "u4", "u5", "当前项"], "当前项不在 order 时仅替换最旧项并保持 order 尾部");
937
+ const oldAssistantCurrent = conversationWorkItems({
938
+ chat: {
939
+ order: ["oldAssistant", "u2", "u3", "u4", "u5"],
940
+ nodes: { get: (key) => key === "oldAssistant" ? { key, kind: "assistant-step", data: { blocks: [{ kind: "text", text: "旧当前" }] } } : { key, kind: "user", data: { content: [{ type: "text", text: key }] } } },
941
+ },
942
+ partial: { turn: 3, step: 0, blocks: [{ kind: "text", text: "当前更新" }] },
943
+ });
944
+ assert.deepEqual(oldAssistantCurrent.map((item) => item.text), ["u3", "u4", "u5", "当前更新"], "order 尾部外的旧 assistant 被 live 当前项原位替换");
945
+ // R-01-012/AC-09、AC-10 数据层 label 中文归一:正文「助手」、思考「思考」
946
+ const bodyLabelItems = conversationWorkItems({
947
+ chat: { order: ["bd"], nodes: { get: (key) => ({ key, kind: "assistant-step", data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "text", text: "纯正文" }] } }) } },
948
+ });
949
+ assert.equal(bodyLabelItems[0].label, "助手", "正文工作项 label 为中文「助手」(R-01-012/AC-09)");
950
+ assert.equal(needsHistorySnapshot({ chat: { order: [] } }), true, "空 chat snapshot 需要 history fallback");
951
+ assert.equal(needsHistorySnapshot({ chat: { order: ["item"] } }), false, "已 hydrate 的 chat snapshot 优先使用 order");
952
+ const models = modelMetadata({
953
+ current: { provider: "p", model: "m", reasoningEffort: "high" },
954
+ groups: [{ id: "p", models: [{ id: "m", name: "Model M", reasoning: { efforts: [{ id: "high", name: "High" }] } }] }],
955
+ });
956
+ assert.deepEqual(models, { model: "Model M", reasoning: "High" }, "模型名与 reasoning level 复用 native catalog 文案");
957
+ const previews = messagePreviews({ snapshot: chatSnapshot });
958
+ assert.equal(previews.userPreview, "用户任务", "活动快照取最近用户物理首行");
959
+ assert.equal(previews.agentPreview, "正在输出", "活动快照取最近 agent 物理首行");
960
+ assert.deepEqual(
961
+ conversationTimelineFromHistory([
962
+ { event: { type: "user/message", seq: 1, data: { source: { kind: "user" }, content: [{ type: "text", text: "历史用户" }] } } },
963
+ { event: { type: "assistant/message", seq: 2, data: { message: { content: [{ type: "text", text: "历史回复" }] } } } },
964
+ ]),
965
+ [
966
+ // R-01-012/AC-05 冷路径用户行同样携带「用户」标签
967
+ { id: "user:1", kind: "user", icon: "user", label: "用户", text: "历史用户", detail: null, status: "done" },
968
+ { id: "assistant:2", kind: "assistant", icon: "assistant", text: "历史回复", detail: null, status: "done" },
969
+ ],
970
+ "冷会话 history 按原始事件顺序降级",
971
+ );
972
+
973
+ // ---- R-01-017 折叠时间线(无条件折叠分组呈现,不依赖 dsh-auto-collapse)----
974
+ // R-01-017/AC-02 硬边界:用户输入与正文打断分组;工具+思考混排并入同组
975
+ const foldNodes = new Map([
976
+ ["u1", { key: "u1", kind: "user", anchorSeq: 1, data: { content: [{ type: "text", text: "查一下" }] } }],
977
+ ["g1", { key: "g1", kind: "tool-call", anchorSeq: 2, data: { root: { kind: "tool-result", callId: "g1", call: { name: "grep", argsRaw: '{"pattern":"foo"}' }, isError: false } } }],
978
+ ["th1", { key: "th1", kind: "assistant-step", anchorSeq: 3, data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "思考一\n思考二" }] } }],
979
+ ["b1", { key: "b1", kind: "assistant-step", anchorSeq: 4, data: { status: "settled", turn: 1, step: 1, blocks: [{ kind: "text", text: "结论正文" }] } }],
980
+ ["b2", { key: "b2", kind: "tool-call", anchorSeq: 5, data: { root: { kind: "tool-result", callId: "b2", call: { name: "bash", argsRaw: '{"command":"ls -la"}' }, isError: false } } }],
981
+ ]);
982
+ const foldSnapshot = { chat: { order: ["u1", "g1", "th1", "b1", "b2"], nodes: { get: (key) => foldNodes.get(key) } } };
983
+ const folded = foldedConversationTimeline(foldSnapshot);
984
+ assert.equal(folded.length, 4, "用户输入、混排组、正文、尾部工具各占一行(R-01-017/AC-02)");
985
+ assert.equal(folded[0].kind, "user", "用户输入项原样保留");
986
+ // R-01-012/AC-05 用户行 label 为中文「用户」(与 assistant 行「助手/思考」标签同构)
987
+ assert.equal(folded[0].label, "用户", "用户输入项 label 为中文「用户」(R-01-012/AC-05)");
988
+ assert.equal(folded[1].fold, true, "混排工作项合并为分组行");
989
+ assert.equal(folded[1].label, "运行了命令", "完成态工具+思考组标题取工具去向(R-01-017/AC-03)");
990
+ assert.match(folded[1].summary, /^思考一/, "组摘要携带推理文本内容(R-01-017/AC-04)");
991
+ assert.equal(folded[1].icon, "bash", "tool 组行图标为命令图标(IconApiOutline14)");
992
+ assert.equal(folded[2].kind, "assistant", "正文为独立行(硬边界不并入分组)");
993
+ assert.equal(folded[2].text, "结论正文", "正文内容保留");
994
+ assert.equal(folded[3].fold, true, "正文后的工具独立成组,不跨正文合并(R-01-017/AC-02)");
995
+ // R-01-017/AC-02 reasoning+正文同一节点:前置推理并入前组、正文为硬边界(splitThinkByBody 前置语义)
996
+ const splitNodes = new Map([
997
+ ["k", { key: "k", kind: "assistant-step", anchorSeq: 1, data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "推理前置" }, { kind: "text", text: "正文输出" }] } }],
998
+ ["q", { key: "q", kind: "tool-call", anchorSeq: 2, data: { root: { kind: "tool-result", callId: "q", call: { name: "grep", argsRaw: "{}" } } } }],
999
+ ]);
1000
+ const split = foldWorkGroups(
1001
+ [{ id: "k", kind: "assistant", label: "思考", text: "正文输出", summary: "推理前置", detail: "推理前置", icon: "assistant", status: "done" },
1002
+ { id: "q", kind: "tool", toolName: "grep", label: "Grep", summary: "a", icon: "search", status: "done" }],
1003
+ 4);
1004
+ assert.equal(split[0].fold, true, "reasoning 先行独立成思考组");
1005
+ assert.equal(split[0].label, "已思考", "纯思考完成组标题为已思考(R-01-017/AC-03)");
1006
+ assert.equal(split[0].summary, "推理前置", "思考组摘要为该推理文本(R-01-017/AC-04)");
1007
+ assert.equal(split[0].icon, "assistant", "思考组行图标为思考图标");
1008
+ // R-01-017/AC-02 reasoning+正文同节点剥离:推理只归组摘要,正文行不得重复显示推理文本(验收修正)
1009
+ // R-01-012/AC-09 正文行 label 为中文「助手」
1010
+ assert.equal(split[1].label, "助手", "正文行不再复用思考标签,label 为中文「助手」(R-01-012/AC-09)");
1011
+ assert.equal(split[1].text, "正文输出", "正文为独立行且内容保留");
1012
+ assert.equal(split[1].summary, "正文输出", "正文行摘要为正文而非推理文本");
1013
+ assert.equal(split[1].detail, null, "正文行剥离推理文本");
1014
+ assert.equal(split[1].stripNative, true, "正文行带 stripNative 标记,剥离推理文本避免重复呈现");
1015
+ assert.ok(!String(split[1].summary).includes("推理"), "正文行不与组摘要重复");
1016
+ assert.equal(split[2].fold, true, "正文后的工具独立成组");
1017
+ // R-01-017/AC-02 验收反馈:工具组行后紧跟的同节点正文行不得与组摘要重复推理文本(东家现场场景)
1018
+ const dedup = foldWorkGroups(
1019
+ [{ id: "t1", kind: "tool", toolName: "bash", label: "Bash", summary: "ls -la", icon: "bash", status: "done" },
1020
+ { id: "k1", kind: "assistant", label: "思考", text: "这是正文", summary: "推理文本", detail: "推理文本", icon: "assistant", status: "done" }],
1021
+ 4);
1022
+ assert.equal(dedup[0].label, "运行了命令", "前组标题为运行了命令(R-01-017/AC-03)");
1023
+ assert.equal(dedup[0].summary, "推理文本", "推理文本归组摘要(R-01-017/AC-04)");
1024
+ assert.equal(dedup[0].icon, "bash", "tool 组行图标为命令图标(IconApiOutline14,与 auto-collapse chip 同源)");
1025
+ assert.equal(dedup[1].label, "助手", "下一行为正文行而非思考行,label 为中文「助手」(R-01-012/AC-09)");
1026
+ assert.equal(dedup[1].summary, "这是正文", "正文行内容不与组摘要重复");
1027
+ assert.equal(dedup[1].detail, null, "正文行不携带推理文本");
1028
+ // R-01-017/AC-03 运行中工具/思考标题与摘要
1029
+ const runTool = foldedConversationTimeline({
1030
+ chat: { order: ["r1", "r2"], nodes: { get: (key) => new Map([
1031
+ ["r1", { key: "r1", kind: "tool-call", data: { root: { kind: "tool-call", callId: "r1", call: { name: "bash", argsRaw: '{"command":"npm run build"}' } } } }],
1032
+ ["r2", { key: "r2", kind: "tool-call", data: { root: { kind: "tool-call", callId: "r2", call: { name: "grep", argsRaw: '{"pattern":"x"}' } } } }],
1033
+ ]).get(key) } },
1034
+ });
1035
+ assert.equal(runTool[0].label, "正在运行", "运行中工具组标题为正在运行");
1036
+ assert.equal(runTool[0].status, "running", "运行中状态聚合");
1037
+ assert.equal(runTool[0].icon, "bash", "运行中 tool 组行图标同为命令图标");
1038
+ // R-01-017/AC-03 双运行成员的优先序:running tool 标题/摘要优先于 running think(评审对齐 vendor updateChip)。
1039
+ const bothRunning = foldedConversationTimeline({
1040
+ chat: { order: ["br1", "br2"], nodes: { get: (key) => new Map([
1041
+ ["br1", { key: "br1", kind: "tool-call", data: { root: { kind: "tool-call", callId: "br1", call: { name: "bash", argsRaw: '{"command":"make"}' } } } }],
1042
+ ["br2", { key: "br2", kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "思考中\n最新想法" }] } }],
1043
+ ]).get(key) } },
1044
+ });
1045
+ assert.equal(bothRunning[0].label, "正在运行", "tool 与 think 同时运行时标题取正在运行");
1046
+ assert.equal(bothRunning[0].summary, "make", "同时运行时的组摘要取执行中工具摘要(AC-04 限定于无执行中工具的分组)");
1047
+ const runThink = foldedConversationTimeline({
1048
+ chat: { order: ["rt"], nodes: { get: () => ({ kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "第一行\n最新行" }] } }) } },
1049
+ });
1050
+ assert.equal(runThink[0].label, "正在思考", "运行中思考组标题为正在思考(R-01-017/AC-03)");
1051
+ assert.equal(runThink[0].summary, "最新行", "流式思考摘要取最新行(R-01-017/AC-04)");
1052
+ // R-01-017/AC-03 编辑了文件 / 上下文注入 标题判定
1053
+ const editGroup = foldedConversationTimeline({
1054
+ chat: { order: ["e1"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "e1", call: { name: "edit", argsRaw: "{}" } } } }) } },
1055
+ });
1056
+ assert.equal(editGroup[0].label, "编辑了文件", "含 Edit/Write 成员显示编辑了文件");
1057
+ assert.equal(editGroup[0].icon, "bash", "编辑了文件组行图标同为命令图标");
1058
+ const ctxGroup = foldedConversationTimeline({
1059
+ chat: { order: ["c1", "c2"], nodes: { get: (key) => new Map([
1060
+ ["c1", { key: "c1", kind: "context", data: { content: [{ type: "text", text: "注入一" }], provenance: { role: "inject", label: "文件 /a.txt" } } }],
1061
+ ["c2", { key: "c2", kind: "context", data: { content: [{ type: "text", text: "注入二" }], provenance: { role: "inject", label: "文件 /b.txt" } } }],
1062
+ ]).get(key) } },
1063
+ });
1064
+ assert.equal(ctxGroup.length, 1, "连续 context 合并为一组(R-01-017/AC-02)");
1065
+ assert.equal(ctxGroup[0].label, "上下文注入", "全 context 组标题为上下文注入(R-01-017/AC-03)");
1066
+ assert.equal(ctxGroup[0].kind, "context", "context 组行 kind 复用 context 语义");
1067
+ assert.equal(ctxGroup[0].icon, "context", "context 组行图标为上下文图标");
1068
+ // R-01-017/AC-05 状态聚合:error > stopped > done,running 优先
1069
+ const errGroup = foldedConversationTimeline({
1070
+ chat: { order: ["x1", "x2"], nodes: { get: (key) => new Map([
1071
+ ["x1", { key: "x1", kind: "tool-call", data: { root: { kind: "tool-result", callId: "x1", call: { name: "edit", argsRaw: "{}" }, isError: true } } }],
1072
+ ["x2", { key: "x2", kind: "tool-call", data: { root: { kind: "tool-result", callId: "x2", call: { name: "bash", argsRaw: "{}" }, isError: false } } }],
1073
+ ]).get(key) } },
1074
+ });
1075
+ assert.equal(errGroup[0].status, "error", "任一成员错误聚合为 error");
1076
+ assert.equal(errGroup[0].label, "编辑了文件", "错误组标题仍按成员构成判定");
1077
+ const stopGroup = foldedConversationTimeline({
1078
+ chat: { order: ["s1"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "s1", call: { name: "bash", argsRaw: "{}" }, error: { code: "interrupted" } } } }) } },
1079
+ });
1080
+ assert.equal(stopGroup[0].status, "stopped", "中断成员聚合为 stopped");
1081
+ const promoteFold = foldedConversationTimeline({
1082
+ chat: { order: ["p1"], nodes: { get: () => ({ key: "p1", kind: "tool-call", data: { root: { kind: "tool-result", callId: "p1", call: { name: "bash", argsRaw: '{"command":"x"}' } } } }) } },
1083
+ running: true,
1084
+ });
1085
+ assert.equal(promoteFold[0].status, "running", "R-01-009/AC-10 尾部提升作用于折叠分组行(R-01-017/AC-05)");
1086
+ // R-01-017/AC-06 limit 截断与顺序
1087
+ const manyFlat = [
1088
+ { id: "u1", kind: "user", icon: "user", text: "hi", detail: null, status: "done" },
1089
+ { id: "t1", kind: "tool", toolName: "bash", label: "Bash", summary: "a", icon: "bash", status: "done" },
1090
+ { id: "b1", kind: "assistant", label: "助手", text: "正文一", summary: "正文一", icon: "assistant", status: "done" },
1091
+ { id: "t2", kind: "tool", toolName: "bash", label: "Bash", summary: "b", icon: "bash", status: "done" },
1092
+ { id: "b2", kind: "assistant", label: "助手", text: "正文二", summary: "正文二", icon: "assistant", status: "done" },
1093
+ { id: "t3", kind: "tool", toolName: "grep", label: "Grep", summary: "c", icon: "search", status: "done" },
1094
+ ];
1095
+ const manyGroups = foldWorkGroups(manyFlat, 4);
1096
+ assert.equal(manyGroups.length, 4, "折叠呈现下最多显示最近 4 个分组行(R-01-017/AC-06)");
1097
+ assert.equal(manyGroups[3].id, "fold:work:t3", "顺序与主窗口一致,末位为最新工作项所在组");
1098
+ assert.equal(manyGroups[0].kind, "assistant", "窗口内首行为最近正文(更早的用户项与 t1 组被挤出)");
1099
+ assert.equal(manyGroups[0].text, "正文一", "正文行内容保留");
1100
+ const groupIdStable = foldWorkGroups(manyFlat, 4)[3];
1101
+ assert.equal(groupIdStable.id, manyGroups[3].id, "分组 id 稳定供渲染层 DOM 复用");
1102
+
1103
+ // ---- R-01-012/AC-12~AC-15 指令锚行:末尾进入、触顶停留、第二行顶替(C-039)----
1104
+ const anchorNodes = new Map([
1105
+ ["a-u1", { key: "a-u1", kind: "user", anchorSeq: 1, data: { content: [{ type: "text", text: "修复登录页" }] } }],
1106
+ ["a-b1", { key: "a-b1", kind: "assistant-step", anchorSeq: 2, data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "text", text: "正文一" }] } }],
1107
+ ["a-t1", { key: "a-t1", kind: "tool-call", anchorSeq: 3, data: { root: { kind: "tool-result", callId: "a-t1", call: { name: "bash", argsRaw: '{"command":"make"}' }, isError: false } } }],
1108
+ ["a-b2", { key: "a-b2", kind: "assistant-step", anchorSeq: 4, data: { status: "settled", turn: 1, step: 1, blocks: [{ kind: "text", text: "正文二" }] } }],
1109
+ ["a-t2", { key: "a-t2", kind: "tool-call", anchorSeq: 5, data: { root: { kind: "tool-result", callId: "a-t2", call: { name: "grep", argsRaw: '{"pattern":"x"}' }, isError: false } } }],
1110
+ ["a-b3", { key: "a-b3", kind: "assistant-step", anchorSeq: 6, data: { status: "settled", turn: 1, step: 2, blocks: [{ kind: "text", text: "正文三" }] } }],
1111
+ ]);
1112
+ const anchorBaseOrder = ["a-u1", "a-b1", "a-t1", "a-b2", "a-t2", "a-b3"];
1113
+ const anchorTimeline = (order, nodes = anchorNodes, limit = 4) =>
1114
+ foldedConversationTimeline({ chat: { order, nodes: { get: (key) => nodes.get(key) } } }, limit);
1115
+ const anchorIds = (rows) => rows.map((row) => row.id);
1116
+ // AC-12:时间线为空时首条用户消息直接占据第一行并停留为锚行
1117
+ const firstOnly = anchorTimeline(["a-u1"]);
1118
+ assert.deepEqual(anchorIds(firstOnly), ["a-u1"], "空时间线首条用户消息独占第一行(R-01-012/AC-12)");
1119
+ assert.equal(firstOnly[0].anchor, true, "空时间线首条用户消息直接停留为指令锚行(R-01-012/AC-12)");
1120
+ assert.equal(firstOnly[0].kind, "user", "锚行保留用户行语义供渲染层复用图标/标签/下划线(R-01-012/AC-12)");
1121
+ assert.equal(firstOnly[0].label, "用户", "锚行 label 为中文「用户」(R-01-012/AC-05)");
1122
+ assert.equal(firstOnly[0].text, "修复登录页", "锚行内容为指令文本(R-01-012/AC-12)");
1123
+ // AC-13:用户消息随新行到达滚动至第一行时停留为锚行,不再参与后续滚动
1124
+ const shortPin = anchorTimeline(["a-u1", "a-b1", "a-t1"]);
1125
+ assert.deepEqual(anchorIds(shortPin), ["a-u1", "a-b1", "fold:work:a-t1"], "短窗口内用户消息位于第一行即停留(R-01-012/AC-13)");
1126
+ assert.equal(shortPin[0].anchor, true, "第一行用户消息以锚行停留(R-01-012/AC-13)");
1127
+ const touchTop = anchorTimeline(["a-u1", "a-b1", "a-t1", "a-b2"]);
1128
+ assert.deepEqual(anchorIds(touchTop), ["a-u1", "a-b1", "fold:work:a-t1", "a-b2"], "滚动触顶瞬间行位置不变(R-01-012/AC-13)");
1129
+ assert.equal(touchTop[0].anchor, true, "触顶用户行停留为锚行(R-01-012/AC-13)");
1130
+ const anchored = anchorTimeline(anchorBaseOrder);
1131
+ assert.deepEqual(anchorIds(anchored), ["a-u1", "a-b2", "fold:work:a-t2", "a-b3"], "触顶后锚行停留首行、其后为最近 3 个工作显示行(R-01-012/AC-13、R-01-017/AC-06)");
1132
+ assert.equal(anchored[0].anchor, true, "停留行以 anchor 标记前置语义(R-01-012/AC-13)");
1133
+ assert.ok(anchored.slice(1).every((row) => row.anchor !== true), "仅首行带锚标记(R-01-012/AC-13)");
1134
+ // AC-14:新工作项进入时间线,锚行位置与内容保持不变,仅推动其后工作显示行
1135
+ const laterNodes = new Map(anchorNodes);
1136
+ laterNodes.set("a-t3", { key: "a-t3", kind: "tool-call", anchorSeq: 7, data: { root: { kind: "tool-result", callId: "a-t3", call: { name: "bash", argsRaw: '{"command":"ls"}' }, isError: false } } });
1137
+ assert.deepEqual(anchorIds(anchorTimeline([...anchorBaseOrder, "a-t3"], laterNodes)), ["a-u1", "fold:work:a-t2", "a-b3", "fold:work:a-t3"], "新动作进入时锚行不变、仅其后工作行滚动(R-01-012/AC-14)");
1138
+ // AC-12 核心回归:回合结束后新指令作为普通显示行追加在时间线末尾,已有内容不清空
1139
+ const turn2Nodes = new Map(anchorNodes);
1140
+ turn2Nodes.set("a-u2", { key: "a-u2", kind: "user", anchorSeq: 7, data: { content: [{ type: "text", text: "追加指令" }] } });
1141
+ const arrived = anchorTimeline([...anchorBaseOrder, "a-u2"], turn2Nodes);
1142
+ assert.deepEqual(anchorIds(arrived), ["a-u1", "fold:work:a-t2", "a-b3", "a-u2"], "新指令到达末尾时旧锚停留、工作行保留不清空(R-01-012/AC-12、AC-14)");
1143
+ assert.equal(arrived.at(-1).kind, "user", "新指令为时间线末行普通用户行(R-01-012/AC-12)");
1144
+ assert.equal(arrived.at(-1).anchor ?? false, false, "新指令尚未滚动至第二行,不带锚标记(R-01-012/AC-15)");
1145
+ // AC-12:新指令随后续新行到达向上滚动
1146
+ turn2Nodes.set("a-b4", { key: "a-b4", kind: "assistant-step", anchorSeq: 8, data: { status: "settled", turn: 2, step: 0, blocks: [{ kind: "text", text: "正文四" }] } });
1147
+ const scrolled = anchorTimeline([...anchorBaseOrder, "a-u2", "a-b4"], turn2Nodes);
1148
+ assert.deepEqual(anchorIds(scrolled), ["a-u1", "a-b3", "a-u2", "a-b4"], "新指令随新行到达向上滚动一行(R-01-012/AC-12)");
1149
+ // AC-15:新指令滚动至第二行时取代旧锚行成为第一行,其后各行上移一行、暂减一行
1150
+ turn2Nodes.set("a-b5", { key: "a-b5", kind: "assistant-step", anchorSeq: 9, data: { status: "settled", turn: 2, step: 1, blocks: [{ kind: "text", text: "正文五" }] } });
1151
+ const replaced = anchorTimeline([...anchorBaseOrder, "a-u2", "a-b4", "a-b5"], turn2Nodes);
1152
+ assert.deepEqual(anchorIds(replaced), ["a-u2", "a-b4", "a-b5"], "新指令到第二行时顶替旧锚、其后各行上移且暂减一行(R-01-012/AC-15)");
1153
+ assert.equal(replaced[0].anchor, true, "顶替后新指令停留为第一行锚行(R-01-012/AC-15)");
1154
+ assert.equal(replaced[0].text, "追加指令", "顶替行内容为新指令(R-01-012/AC-15)");
1155
+ assert.ok(!replaced.some((row) => row.text === "修复登录页"), "旧指令锚行随顶替消失(R-01-012/AC-15)");
1156
+ // AC-15:顶替后新行到达恢复总预算,不从窗口之外回填旧行
1157
+ turn2Nodes.set("a-b6", { key: "a-b6", kind: "assistant-step", anchorSeq: 10, data: { status: "settled", turn: 2, step: 2, blocks: [{ kind: "text", text: "正文六" }] } });
1158
+ const refilled = anchorTimeline([...anchorBaseOrder, "a-u2", "a-b4", "a-b5", "a-b6"], turn2Nodes);
1159
+ assert.deepEqual(anchorIds(refilled), ["a-u2", "a-b4", "a-b5", "a-b6"], "顶替后新行到达恢复 4 行(R-01-012/AC-15)");
1160
+ // AC-15 短窗同口径:时间线不足一窗时,更近用户消息位于显示第二行即顶替(按行位置而非窗口计数)
1161
+ const shortReplace = anchorTimeline(["a-u1", "a-u2", "a-b1"], turn2Nodes);
1162
+ assert.deepEqual(anchorIds(shortReplace), ["a-u2", "a-b1"], "短窗口内新指令位于第二行即顶替旧锚(R-01-012/AC-15)");
1163
+ assert.equal(shortReplace[0].anchor, true, "顶替后新指令停留为第一行锚行(R-01-012/AC-15)");
1164
+ const shortNoReplace = anchorTimeline(["a-u1", "a-b1", "a-u2"], turn2Nodes);
1165
+ assert.deepEqual(anchorIds(shortNoReplace), ["a-u1", "a-b1", "a-u2"], "新指令位于第三行时旧锚停留、不顶替(R-01-012/AC-15)");
1166
+ assert.equal(shortNoReplace[0].anchor, true, "旧锚停留至新指令滚动至第二行(R-01-012/AC-15)");
1167
+ assert.equal(refilled[0].anchor, true, "恢复后新指令保持第一行锚行(R-01-012/AC-15)");
1168
+ // limit 边界:max=1 时窗口收缩为空、仅锚行一行
1169
+ const tinyWindow = foldedConversationTimeline({ chat: { order: anchorBaseOrder, nodes: { get: (key) => anchorNodes.get(key) } } }, 1);
1170
+ assert.equal(tinyWindow.length, 1, "max=1 时总行数 1:锚行独占、窗口收缩为空(R-01-012/AC-13)");
1171
+ assert.equal(tinyWindow[0].anchor, true, "max=1 时锚行仍停留首行(R-01-012/AC-13)");
1172
+ assert.equal(tinyWindow[0].text, "修复登录页", "max=1 时锚行内容正确(R-01-012/AC-13)");
1173
+ // AC-15 负向:空文本用户行不参与停留与顶替,仅作为普通显示行滚动
1174
+ const emptyHeadNodes = new Map(anchorNodes);
1175
+ emptyHeadNodes.set("a-e2", { key: "a-e2", kind: "user", anchorSeq: 7, data: { content: [{ type: "text", text: " " }] } });
1176
+ emptyHeadNodes.set("a-b4", { key: "a-b4", kind: "assistant-step", anchorSeq: 8, data: { status: "settled", turn: 2, step: 0, blocks: [{ kind: "text", text: "正文四" }] } });
1177
+ const emptyScroll = anchorTimeline([...anchorBaseOrder, "a-e2", "a-b4"], emptyHeadNodes);
1178
+ assert.deepEqual(anchorIds(emptyScroll), ["a-u1", "a-b3", "a-e2", "a-b4"], "空文本用户行作为普通行滚动、不顶替旧锚(R-01-012/AC-15)");
1179
+ assert.equal(emptyScroll[0].text, "修复登录页", "空文本行到达后旧锚保留(R-01-012/AC-15)");
1180
+ const emptyShort = anchorTimeline(["a-e2", "a-b1"], emptyHeadNodes);
1181
+ assert.deepEqual(anchorIds(emptyShort), ["a-e2", "a-b1"], "空文本用户行在时间线内按普通显示行(R-01-012/AC-15)");
1182
+ assert.ok(emptyShort.every((row) => row.anchor !== true), "空文本用户行永不停留为锚行(R-01-012/AC-15)");
1183
+ // ×3 扩窗收集不足时经廉价前走命中窗口前的用户节点(不做全序转换)
1184
+ const longNodes = new Map([["L-u1", { key: "L-u1", kind: "user", anchorSeq: 0, data: { content: [{ type: "text", text: "深层指令" }] } }]]);
1185
+ const longOrder = ["L-u1"];
1186
+ for (let n = 0; n < 7; n += 1) {
1187
+ const bKey = `L-b${n}`;
1188
+ const tKey = `L-t${n}`;
1189
+ longNodes.set(bKey, { key: bKey, kind: "assistant-step", anchorSeq: n * 2 + 1, data: { status: "settled", turn: 1, step: n, blocks: [{ kind: "text", text: `正文${n}` }] } });
1190
+ longNodes.set(tKey, { key: tKey, kind: "tool-call", anchorSeq: n * 2 + 2, data: { root: { kind: "tool-result", callId: tKey, call: { name: "bash", argsRaw: '{"command":"echo"}' }, isError: false } } });
1191
+ longOrder.push(bKey, tKey);
1192
+ }
1193
+ const deepAnchor = foldedConversationTimeline({ chat: { order: longOrder, nodes: { get: (key) => longNodes.get(key) } } });
1194
+ assert.equal(deepAnchor[0]?.anchor, true, "×3 扩窗收集不足时经廉价前走命中窗口前的用户节点(R-01-012/AC-12)");
1195
+ assert.equal(deepAnchor[0]?.text, "深层指令", "前走命中的锚行内容正确(R-01-012/AC-12)");
1196
+ // steering 消息按用户输入行归一参与锚行
1197
+ const steeringAnchor = foldedConversationTimeline({
1198
+ chat: {
1199
+ order: ["s1", "a-b1", "a-t1", "a-b2", "a-t2", "a-b3"],
1200
+ nodes: { get: (key) => key === "s1"
1201
+ ? { key: "s1", kind: "steering", anchorSeq: -1, data: { content: [{ type: "text", text: "插话补充" }] } }
1202
+ : anchorNodes.get(key) },
1203
+ },
1204
+ });
1205
+ assert.equal(steeringAnchor[0]?.anchor, true, "steering 消息按用户输入行归一参与锚行停留(R-01-012/AC-13)");
1206
+ assert.equal(steeringAnchor[0]?.text, "插话补充", "steering 锚行内容正确(R-01-012/AC-13)");
1207
+ // hidden 用户节点不入锚
1208
+ const hiddenAnchor = foldedConversationTimeline({
1209
+ chat: {
1210
+ order: ["h1", "a-b1", "a-t1", "a-b2", "a-t2", "a-b3"],
1211
+ nodes: { get: (key) => key === "h1"
1212
+ ? { key: "h1", kind: "user", visibility: "hidden", anchorSeq: -1, data: { content: [{ type: "text", text: "隐藏消息" }] } }
1213
+ : anchorNodes.get(key) },
1214
+ },
1215
+ });
1216
+ assert.ok(hiddenAnchor.every((row) => row.anchor !== true), "hidden 用户节点不作为指令锚行(R-01-012/AC-12)");
1217
+ // AC-13 触顶停留:更近的用户输入行滚动触顶后停留为首行锚行,旧指令行不再出现
1218
+ const switchNodes = new Map();
1219
+ const switchOrder = ["w-u1"];
1220
+ switchNodes.set("w-u1", { key: "w-u1", kind: "user", anchorSeq: 1, data: { content: [{ type: "text", text: "第一轮指令" }] } });
1221
+ for (let n = 1; n <= 4; n += 1) switchNodes.set(`w-b${n}`, { key: `w-b${n}`, kind: "assistant-step", anchorSeq: n + 1, data: { status: "settled", turn: 1, step: n, blocks: [{ kind: "text", text: `正文${n}` }] } });
1222
+ switchOrder.push("w-b1", "w-b2", "w-b3", "w-b4");
1223
+ switchNodes.set("w-u2", { key: "w-u2", kind: "user", anchorSeq: 6, data: { content: [{ type: "text", text: "第二轮指令" }] } });
1224
+ switchOrder.push("w-u2");
1225
+ for (let n = 5; n <= 9; n += 1) switchNodes.set(`w-b${n}`, { key: `w-b${n}`, kind: "assistant-step", anchorSeq: n + 2, data: { status: "settled", turn: 1, step: n, blocks: [{ kind: "text", text: `正文${n}` }] } });
1226
+ switchOrder.push("w-b5", "w-b6", "w-b7", "w-b8", "w-b9");
1227
+ const switchedAnchor = foldedConversationTimeline({ chat: { order: switchOrder, nodes: { get: (key) => switchNodes.get(key) } } });
1228
+ assert.equal(switchedAnchor[0]?.anchor, true, "更近的用户输入行滚动触顶后停留为新锚行(R-01-012/AC-13)");
1229
+ assert.equal(switchedAnchor[0]?.text, "第二轮指令", "新锚行内容为更近的用户输入行(R-01-012/AC-13)");
1230
+ assert.ok(!switchedAnchor.some((row) => row.text === "第一轮指令"), "被取代的旧指令行不再出现(R-01-012/AC-13)");
1231
+ // 空文本用户输入行不作锚(前缀扫描与前走同口径)
1232
+ const emptyUserAnchor = foldedConversationTimeline({
1233
+ chat: {
1234
+ order: ["e1", "a-b1", "a-t1", "a-b2", "a-t2", "a-b3"],
1235
+ nodes: { get: (key) => key === "e1"
1236
+ ? { key: "e1", kind: "user", anchorSeq: -1, data: { content: [{ type: "text", text: " " }] } }
1237
+ : anchorNodes.get(key) },
1238
+ },
1239
+ });
1240
+ assert.ok(emptyUserAnchor.every((row) => row.anchor !== true), "空文本用户输入行不作为指令锚行(R-01-012/AC-15)");
1241
+ // 冷 history 路径同口径指令锚行(R-01-012/AC-12):页内全部事件折叠后套用同一窗口/锚行选择
1242
+ const hUser = (seq, text) => ({ event: { type: "user/message", seq, data: { source: { kind: "user" }, content: [{ type: "text", text }] } } });
1243
+ const hAgent = (seq, text) => ({ event: { type: "assistant/message", seq, data: { message: { content: [{ type: "text", text }] } } } });
1244
+ const hToolCall = (seq, callId, name = "bash", argsRaw = "{}") => ({ event: { type: "tool/call", seq, data: { turn: 1, step: 0, callId, name, arguments: argsRaw } } });
1245
+ // canonical tool/result 事件形状(dsh-tool-cordis SessionEvent 契约):data = { turn, step, message: ToolResultMessage, error? }。
1246
+ const hToolResult = (seq, callId, { isError = false, error = null, text = "ok" } = {}) => ({
1247
+ event: {
1248
+ type: "tool/result",
1249
+ seq,
1250
+ data: {
1251
+ turn: 1,
1252
+ step: 0,
1253
+ message: { source: { kind: "tool", callId }, content: [{ type: "tool-result", toolCallId: callId, content: [{ type: "text", text }], isError }] },
1254
+ ...(error === null ? {} : { error }),
1255
+ },
1256
+ },
1257
+ });
1258
+ const hToolDone = (seq) => hToolResult(seq, `hc${seq}`);
1259
+ const histAnchored = foldedHistoryTimeline([hUser(1, "冷指令"), hAgent(2, "回复一"), hToolDone(3), hToolDone(4), hAgent(5, "回复二"), hToolDone(6)]);
1260
+ assert.equal(histAnchored.length, 4, "冷 history 路径锚行计入总预算:含锚行合计不超过 4(R-01-012/AC-13)");
1261
+ assert.equal(histAnchored[0].anchor, true, "冷路径最近用户消息滚动触顶后停留为首行锚行(R-01-012/AC-13)");
1262
+ assert.equal(histAnchored[0].kind, "user", "冷路径锚行保留用户行语义(R-01-012/AC-12)");
1263
+ assert.equal(histAnchored[0].text, "冷指令", "冷路径锚行内容为最近用户消息(R-01-012/AC-13)");
1264
+ assert.ok(histAnchored.slice(1).every((row) => row.anchor !== true), "冷路径仅首行带锚标记(R-01-012/AC-13)");
1265
+ const histInWindow = foldedHistoryTimeline([hUser(1, "近指令"), hAgent(2, "回复")]);
1266
+ assert.equal(histInWindow.length, 2, "冷路径短时间线全量展示(R-01-012/AC-12)");
1267
+ assert.equal(histInWindow[0].anchor, true, "冷路径用户消息占据第一行即停留为锚行(R-01-012/AC-13)");
1268
+ assert.ok(foldedHistoryTimeline([hAgent(1, "仅回复"), hToolDone(2)]).every((row) => row.anchor !== true), "history 无用户消息时不造锚行");
1269
+ // ---- R-01-016/AC-01 回归:冷 history 路径 tool/result 按 canonical 形状落定同 callId 的 tool/call 项——
1270
+ // 已完成会话的等待卡时间线不残留 running 行(修复前 call 项永久 running,被活动保留逻辑钉为尾行蓝闪,
1271
+ // 违反 R-01-009/AC-09「仅执行中行闪烁」与 AC-10「非运行中不适用尾部提升」的呈现前提)----
1272
+ const coldPair = conversationTimelineFromHistory([hToolCall(1, "hc1"), hToolResult(2, "hc1")], 10);
1273
+ assert.equal(coldPair.length, 1, "history 中同 callId 的 call/result 配对为一项,不产生重复行");
1274
+ assert.equal(coldPair[0].status, "done", "结果到达后调用项落定 done,不残留 running");
1275
+ assert.equal(coldPair[0].label, "Bash", "配对项沿用 call 事件的工具名(result 事件不携带 name/arguments)");
1276
+ const coldDoneCard = foldedHistoryTimeline([hUser(1, "任务"), hToolCall(2, "hc2"), hToolResult(3, "hc2"), hAgent(4, "完成")]);
1277
+ assert.ok(coldDoneCard.length > 0 && coldDoneCard.every((row) => row.status !== "running"), "已完成会话冷时间线无 running 行:等待卡尾行不蓝闪(R-01-016/AC-01)");
1278
+ const coldError = conversationTimelineFromHistory([hToolCall(1, "hc3"), hToolResult(2, "hc3", { isError: true, text: "boom\nstack" })], 10);
1279
+ assert.equal(coldError[0].status, "error", "isError 结果落定 error");
1280
+ assert.equal(coldError[0].summary, "boom", "error 摘要取结果内容首行(原生 resultText 语义)");
1281
+ const coldInterrupted = conversationTimelineFromHistory([hToolCall(1, "hc4"), hToolResult(2, "hc4", { error: { name: "Error", code: "interrupted" }, text: "" })], 10);
1282
+ assert.equal(coldInterrupted[0].status, "stopped", "interrupted 结果落定 stopped 而非 error");
1283
+ const coldOrphan = conversationTimelineFromHistory([hToolResult(1, "hc9")], 10);
1284
+ assert.equal(coldOrphan.length, 1, "call 在窗口外的孤儿 result 仍成行(信息不丢失)");
1285
+ assert.equal(coldOrphan[0].status, "done", "孤儿 result 落定 done,不造 running 行");
1286
+ // historyInstructionAnchor:尾扫最近一条非空文本真实用户消息(R-01-012/AC-12 快照窗口外兜底)
1287
+ assert.equal(historyInstructionAnchor([hUser(1, "旧指令"), hAgent(2, "回复"), hUser(3, "新指令")])?.text, "新指令", "锚行取最近一条用户消息");
1288
+ assert.equal(historyInstructionAnchor([hUser(1, " "), hAgent(2, "回复")]), null, "空文本用户消息不作锚");
1289
+ assert.equal(historyInstructionAnchor([{ event: { type: "user/message", seq: 1, data: { source: { kind: "recall" }, content: [{ type: "text", text: "召回" }] } } }]), null, "非真实用户来源不作锚");
1290
+ assert.equal(historyInstructionAnchor([hAgent(1, "仅回复")]), null, "无用户消息返回 null");
1291
+ assert.equal(historyInstructionAnchor(null), null, "非数组输入归一 null");
1292
+ // history fallback anchor 作为 foldedConversationTimeline 的显式输入(R-01-012/AC-12、C-035、C-039)。
1293
+ const anchorRow = historyInstructionAnchor([hUser(9, "兜底指令")]);
1294
+ // C-039:快照窗口内有新指令但旧锚已滚出尾窗时,fallbackAnchor 充当停留锚行,新指令自末尾参与滚动
1295
+ const fallScrollNodes = new Map(anchorNodes);
1296
+ fallScrollNodes.set("a-u2", { key: "a-u2", kind: "user", anchorSeq: 7, data: { content: [{ type: "text", text: "追加指令" }] } });
1297
+ const fallScroll = foldedConversationTimeline(
1298
+ { chat: { order: ["a-b1", "a-t1", "a-b2", "a-u2"], nodes: { get: (key) => fallScrollNodes.get(key) } }, running: true, pending: [] },
1299
+ 4,
1300
+ "",
1301
+ false,
1302
+ false,
1303
+ anchorRow,
1304
+ );
1305
+ assert.deepEqual(fallScroll.map((row) => row.id), [anchorRow.id, "fold:work:a-t1", "a-b2", "a-u2"], "窗口外旧锚经 fallback 停留首行,新指令末尾进入不清空(R-01-012/AC-12、AC-14)");
1306
+ assert.equal(fallScroll.at(-1).anchor ?? false, false, "新指令未达第二行前不带锚标记(R-01-012/AC-15)");
1307
+ // fallback 与窗口内最近用户行同文本时判为同一消息:不双行、不顶替
1308
+ const sameTextAnchor = historyInstructionAnchor([hUser(9, "追加指令")]);
1309
+ const fallDedup = foldedConversationTimeline(
1310
+ { chat: { order: ["a-b1", "a-t1", "a-b2", "a-u2"], nodes: { get: (key) => fallScrollNodes.get(key) } }, running: true, pending: [] },
1311
+ 4,
1312
+ "",
1313
+ false,
1314
+ false,
1315
+ sameTextAnchor,
1316
+ );
1317
+ assert.deepEqual(fallDedup.map((row) => row.id), ["a-b1", "fold:work:a-t1", "a-b2", "a-u2"], "fallback 与窗口内用户行同消息时不产生双行(R-01-012/AC-15)");
1318
+ assert.ok(fallDedup.every((row) => row.anchor !== true), "同消息 fallback 不造锚行(R-01-012/AC-15)");
1319
+ // fallback 充当的停留锚行同样被滚动至第二行的新指令顶替(AC-15 统一口径)
1320
+ const fallReplaced = foldedConversationTimeline(
1321
+ { chat: { order: ["a-b1", "a-t1", "a-u2", "a-b2", "a-b3"], nodes: { get: (key) => fallScrollNodes.get(key) } }, running: true, pending: [] },
1322
+ 4,
1323
+ "",
1324
+ false,
1325
+ false,
1326
+ anchorRow,
1327
+ );
1328
+ assert.deepEqual(fallReplaced.map((row) => row.id), ["a-u2", "a-b2", "a-b3"], "fallback 停留锚行被滚动至第二行的新指令顶替、暂减一行(R-01-012/AC-15)");
1329
+ assert.equal(fallReplaced[0].anchor, true, "顶替 fallback 的新指令停留为第一行锚行(R-01-012/AC-15)");
1330
+ // R-01-009/AC-11 与自然窗口共存:首行用户行停留为锚行,真实 running 行保留末行
1331
+ const natLiveNodes = new Map(anchorNodes);
1332
+ natLiveNodes.set("a-live", { key: "a-live", kind: "assistant-step", anchorSeq: 7, data: { status: "running", turn: 2, step: 0, blocks: [{ kind: "reasoning", text: "正在执行的内容" }] } });
1333
+ const natLive = foldedConversationTimeline(
1334
+ { chat: { order: ["a-u1", "a-b1", "a-live"], nodes: { get: (key) => natLiveNodes.get(key) } }, running: true, pending: [] },
1335
+ );
1336
+ assert.equal(natLive[0]?.anchor, true, "自然窗口首行用户行停留为锚行(R-01-012/AC-13)");
1337
+ assert.equal(natLive.at(-1)?.status, "running", "真实 running 行保留末行(R-01-009/AC-11)");
1338
+ assert.equal(natLive.at(-1)?.summary, "正在执行的内容", "末行保留真实活动文字(R-01-009/AC-11)");
1339
+
1340
+ // R-01-009/AC-11:history 锚行参与核心单次选择,真实 running 行即使位于四工作行首位也必须保留为末行。
1341
+ const fallbackActivityNodes = new Map([
1342
+ ["live-first", { key: "live-first", kind: "assistant-step", data: { status: "running", turn: 2, step: 0, blocks: [{ kind: "reasoning", text: "真正当前正在执行的内容" }] } }],
1343
+ ["done-1", { key: "done-1", kind: "assistant-step", data: { status: "settled", turn: 2, step: 1, blocks: [{ kind: "text", text: "较新完成消息一" }] } }],
1344
+ ["done-2", { key: "done-2", kind: "assistant-step", data: { status: "settled", turn: 2, step: 2, blocks: [{ kind: "text", text: "较新完成消息二" }] } }],
1345
+ ["done-3", { key: "done-3", kind: "assistant-step", data: { status: "settled", turn: 2, step: 3, blocks: [{ kind: "text", text: "较新完成消息三" }] } }],
1346
+ ]);
1347
+ const fallbackActivity = foldedConversationTimeline(
1348
+ { chat: { order: [...fallbackActivityNodes.keys()], nodes: { get: (key) => fallbackActivityNodes.get(key) } }, running: true, pending: [] },
1349
+ 4,
1350
+ "",
1351
+ false,
1352
+ false,
1353
+ anchorRow,
1354
+ );
1355
+ assert.deepEqual(fallbackActivity.map((row) => row.id), [anchorRow.id, "done-2", "done-3", "fold:work:live-first"], "history 锚行 + 最近两条历史工作行 + 真实活动末行,总计 4 行(R-01-009/AC-11、R-01-012/AC-12)");
1356
+ assert.equal(fallbackActivity.at(-1)?.summary, "真正当前正在执行的内容", "末行保留真实活动文字,不由旧尾内容冒充(R-01-009/AC-11)");
1357
+ assert.equal(fallbackActivity.at(-1)?.status, "running", "真实当前活动末行保持 running 蓝闪状态(R-01-009/AC-11)");
1358
+ // 多个真实 live 显示行共存时,最新 live 分组占据末行,较早 live 行只参与剩余历史名额。
1359
+ const multiLiveNodes = new Map([
1360
+ ["multi-done-1", { key: "multi-done-1", kind: "assistant-step", data: { status: "settled", turn: 4, step: 1, blocks: [{ kind: "text", text: "多 live 前完成一" }] } }],
1361
+ ["multi-done-2", { key: "multi-done-2", kind: "assistant-step", data: { status: "settled", turn: 4, step: 2, blocks: [{ kind: "text", text: "多 live 前完成二" }] } }],
1362
+ ]);
1363
+ const multiLive = foldedConversationTimeline(
1364
+ {
1365
+ chat: { order: [...multiLiveNodes.keys()], nodes: { get: (key) => multiLiveNodes.get(key) } },
1366
+ partial: { turn: 4, step: 3, blocks: [{ kind: "text", text: "正在流式回复" }] },
1367
+ runningCalls: [{ callId: "live-call", name: "bash", argsRaw: '{"command":"pnpm check"}', turn: 4, step: 4 }],
1368
+ running: true,
1369
+ pending: [],
1370
+ },
1371
+ 4,
1372
+ "",
1373
+ false,
1374
+ false,
1375
+ anchorRow,
1376
+ );
1377
+ assert.equal(multiLive.length, 4, "多个 live 行与锚行仍遵守四行总预算(R-01-009/AC-11、R-01-012/AC-12)");
1378
+ assert.equal(multiLive.filter((row) => row.live === true && row.status === "running").length, 2, "partial 与 running call 的真实 live 身份穿透折叠层(R-01-009/AC-11)");
1379
+ assert.equal(multiLive.at(-1)?.id, "fold:work:live-call", "多个 live 行取最新 live 分组置于末行(R-01-009/AC-11)");
1380
+ assert.equal(multiLive.at(-1)?.summary, "pnpm check", "最新 live 分组末行保留当前调用内容(R-01-009/AC-11)");
1381
+ // 无真实 running 时仍保留 AC-10 尾部持续标志,但 history 锚行与工作预算由同一选择完成。
1382
+ const fallbackPromotedNodes = new Map(Array.from({ length: 4 }, (_, index) => {
1383
+ const n = index + 1;
1384
+ return [`fallback-done-${n}`, { key: `fallback-done-${n}`, kind: "assistant-step", data: { status: "settled", turn: 3, step: n, blocks: [{ kind: "text", text: `完成消息${n}` }] } }];
1385
+ }));
1386
+ const fallbackPromoted = foldedConversationTimeline(
1387
+ { chat: { order: [...fallbackPromotedNodes.keys()], nodes: { get: (key) => fallbackPromotedNodes.get(key) } }, running: true, pending: [] },
1388
+ 4,
1389
+ "",
1390
+ false,
1391
+ false,
1392
+ anchorRow,
1393
+ );
1394
+ assert.deepEqual(fallbackPromoted.map((row) => row.id), [anchorRow.id, "fallback-done-2", "fallback-done-3", "fallback-done-4"], "history 锚行占一格,最近三工作行回填(R-01-012/AC-12)");
1395
+ assert.equal(fallbackPromoted.at(-1)?.status, "running", "无真实活动行时仅提升所选末行作为持续标志(R-01-009/AC-10)");
1396
+
1397
+
1398
+ // ---- R-01-009/AC-04 工具动作摘要镜像主会话窗口 deriveSummary 语义(可含原始命令)----
1399
+ assert.equal(
1400
+ summarizeToolArguments("bash", '{"command":"rm -rf /","description":"清理目录"}'),
1401
+ "清理目录",
1402
+ "bash 摘要优先 description 参数键",
1403
+ );
1404
+ assert.equal(
1405
+ summarizeToolArguments("bash", '{"command":"top","cwd":"/srv"}'),
1406
+ "top",
1407
+ "bash 无 description 时展示原始命令首行(C-011)",
1408
+ );
1409
+ assert.equal(
1410
+ summarizeToolArguments("read", '{"path":"/srv/ops/a.log"}'),
1411
+ "/srv/ops/a.log",
1412
+ "read 摘要取 path 参数键",
1413
+ );
1414
+ assert.equal(
1415
+ summarizeToolArguments("web_fetch", '{"url":"https://example.com/x"}'),
1416
+ "https://example.com/x",
1417
+ "web_fetch 摘要取 url 参数键(read variant)",
1418
+ );
1419
+ assert.equal(
1420
+ summarizeToolArguments("read", '{"path":"/ws/src/a.ts"}', "/ws"),
1421
+ "src/a.ts",
1422
+ "工作区内绝对路径按 cwd 相对化(镜像 relativizeToCwd)",
1423
+ );
1424
+ assert.equal(
1425
+ summarizeToolArguments("read", '{"path":"/elsewhere/a.ts"}', "/ws"),
1426
+ "/elsewhere/a.ts",
1427
+ "工作区外路径保持原样",
1428
+ );
1429
+ assert.equal(
1430
+ summarizeToolArguments("read", '{"path":"/ws/src/a.ts"}'),
1431
+ "/ws/src/a.ts",
1432
+ "无 cwd 时路径原样保留",
1433
+ );
1434
+ assert.equal(
1435
+ summarizeToolArguments("custom_tool", '{"note":"hi"}'),
1436
+ "hi",
1437
+ "无参数键命中时取首个字符串参数值",
1438
+ );
1439
+ assert.equal(summarizeToolArguments("bash", "not-json{{"), "not-json{{", "不可解析参数取 argsRaw 首行(镜像原生)");
1440
+ assert.equal(summarizeToolArguments("bash", 123), null, "非字符串参数返回 null");
1441
+ assert.equal(summarizeToolArguments("bash", ""), null, "空参数返回 null(callId 由 timelineToolItem 补)");
1442
+ assert.equal(cleanPreview(" a b "), "a b", "摘要文本折叠空白");
1443
+ assert.equal(cleanPreview("", 10), null, "空文本返回 null");
1444
+ assert.equal(cleanPreview("x".repeat(100), 88)?.length, 88, "超长摘要在 88 字符内截断");
1445
+
1446
+ // ---- R-01-009/AC-05 输出 token 计数与速率 ----
1447
+ assert.equal(fmtTokens(847), "847", "千以下原样计数");
1448
+ assert.equal(fmtTokens(1200), "1.2K", "千级一位小数(大写,镜像原生)");
1449
+ assert.equal(fmtTokens(51_700), "51.7K", "万级缩写");
1450
+ assert.equal(fmtTokens(517_000), "517K", "缩写值百位以上取整");
1451
+ assert.equal(fmtTokens(2_800_000), "2.8M", "百万级转 M(对齐主窗口统计行)");
1452
+ assert.equal(fmtTokens(4_260_000), "4.3M", "M 级四舍五入");
1453
+ assert.equal(fmtTokens(-1), null, "负数不展示");
1454
+ assert.equal(fmtTokens(NaN), null, "非有限数不展示");
1455
+ assert.deepEqual(
1456
+ runtimeStats({ outputTokens: 0, rateTokS: 0, elapsedMs: 47_000 }),
1457
+ { elapsedMs: 47_000, outputTokens: 0, rateTokS: null },
1458
+ "零速率归一为空,token 统计仍可放在进度条下方",
1459
+ );
1460
+ assert.deepEqual(
1461
+ usageSummary({ uncachedInputTokens: 100, cacheReadTokens: 700, cacheWriteTokens: 200 }),
1462
+ { inputTokens: 1_000, cacheHitPct: 70 },
1463
+ "计费输入=未缓存+读+写,命中率=读÷计费输入四舍五入(R-01-009/AC-05)",
1464
+ );
1465
+ assert.deepEqual(
1466
+ usageSummary({ uncachedInputTokens: 50 }),
1467
+ { inputTokens: 50, cacheHitPct: null },
1468
+ "无缓存读桶时命中率未知不展示",
1469
+ );
1470
+ assert.deepEqual(
1471
+ usageSummary({ uncachedInputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }),
1472
+ { inputTokens: 10, cacheHitPct: 0 },
1473
+ "零命中显示 0% 而非隐藏",
1474
+ );
1475
+ assert.deepEqual(usageSummary({}), { inputTokens: null, cacheHitPct: null }, "空 usage 归一为空");
1476
+ assert.deepEqual(
1477
+ usageSummary({ uncachedInputTokens: -1, cacheReadTokens: Number.NaN }),
1478
+ { inputTokens: null, cacheHitPct: null },
1479
+ "非法桶不计入,全非法归一为空",
1480
+ );
1481
+
1482
+ // ---- R-01-009/AC-06 回合进度:y = t/(t+k),半衰期每帧按最新实测速率校准、允许回退(C-014、C-025、C-044)----
1483
+ assert.equal(progressOf({ elapsedMs: 0 }), 0, "回合起点过原点 0%");
1484
+ assert.equal(progressOf({ elapsedMs: 120_000 }), 18.2, "无速率保守默认半衰期 540s(20 tok/s 起步基准)2 分钟显示 18.2%");
1485
+ assert.equal(progressOf({ elapsedMs: 360_000 }), 40, "保守默认下 6 分钟显示 40%");
1486
+ assert.ok(progressOf({ elapsedMs: 86_400_000 }) < 100, "超长回合渐近 100% 永不到达");
1487
+ const pEarly = progressOf({ elapsedMs: 30_000 });
1488
+ const pLate = progressOf({ elapsedMs: 300_000 });
1489
+ assert.ok(pLate > pEarly && pEarly > 0, "固定半衰期下随已耗时单调递增且先快后慢");
1490
+ assert.ok(progressOf({ elapsedMs: Number.NaN }) === 0 && progressOf({ elapsedMs: -1 }) === 0, "非法已耗时归一为 0");
1491
+ assert.ok(progressOf({}) === 0, "缺省入参归一为 0");
1492
+ assert.equal(progressOf({ elapsedMs: 240_000, halfLifeSec: 240 }), 50, "校准半衰期 240s 时 4 分钟显示 50%");
1493
+ assert.equal(progressOf({ elapsedMs: 120_000, halfLifeSec: 240 }), 33.3, "校准半衰期下 2 分钟显示 33.3%");
1494
+ // 允许回退(C-044):同一已耗时下 k 变化直接反映为进度变化——速率回落(k 回升)进度随之回退
1495
+ assert.equal(progressOf({ elapsedMs: 300_000, halfLifeSec: 120 }), 71.4, "快速率(k=120)5 分钟显示 71.4%");
1496
+ assert.equal(progressOf({ elapsedMs: 300_000, halfLifeSec: 540 }), 35.7, "速率回落 20 tok/s(k=540)同一时刻显示 35.7%(允许回退)");
1497
+ assert.ok(
1498
+ progressOf({ elapsedMs: 300_000, halfLifeSec: 540 }) < progressOf({ elapsedMs: 300_000, halfLifeSec: 120 }),
1499
+ "k 回升时进度随之下调(实时估计语义,单调承诺撤销)",
1500
+ );
1501
+ assert.equal(progressOf({ elapsedMs: 120_000, halfLifeSec: Number.NaN }), 18.2, "非法半衰期回退保守默认 540s");
1502
+ assert.equal(progressOf({ elapsedMs: 120_000, halfLifeSec: 0 }), 18.2, "非正半衰期回退保守默认 540s");
1503
+ // 半衰期速率校准(progressHalfLifeSec):k = clamp(120×90÷r, 60, 600),r 为全会话累计输出速率 tok/s
1504
+ assert.equal(progressHalfLifeSec({ rateTokS: 90 }), 120, "基准速率 90 tok/s 半衰期 120s(行为与校准前一致)");
1505
+ assert.equal(progressHalfLifeSec({ rateTokS: 45 }), 240, "45 tok/s 半衰期按比例拉长为 240s");
1506
+ assert.equal(progressHalfLifeSec({ rateTokS: 20 }), 540, "20 tok/s 半衰期 540s(与保守起步基点同值,起步值可被实测无缝接续)");
1507
+ assert.equal(progressHalfLifeSec({ rateTokS: 180 }), 60, "180 tok/s 夹取下界 60s");
1508
+ assert.equal(progressHalfLifeSec({ rateTokS: 300 }), 60, "超高速率仍夹取下界 60s");
1509
+ assert.equal(progressHalfLifeSec({ rateTokS: 10 }), 600, "10 tok/s 夹取上界 600s");
1510
+ assert.equal(progressHalfLifeSec({}), 540, "无可用速率取保守默认 540s(20 tok/s 起步基准)");
1511
+ assert.ok(
1512
+ progressHalfLifeSec({ rateTokS: Number.NaN }) === 540 &&
1513
+ progressHalfLifeSec({ rateTokS: 0 }) === 540 &&
1514
+ progressHalfLifeSec({ rateTokS: -5 }) === 540,
1515
+ "非法/非正速率回退保守默认 540s",
1516
+ );
1517
+ // 注:R-01-009/AC-06 的"回合切换归零重计"由渲染层 turnTimings 新回合起点保证,属 GUI 验收项(scripts/acceptance.mjs)。
1518
+ // ---- R-01-009/AC-06 委托周期进度锚点:周期内连续、周期外回合切换归零 ----
1519
+ const anchorIdle = progressAnchor(null, { descendantActive: false, hostStartTime: null, now: 1000 });
1520
+ assert.deepEqual(anchorIdle, { mode: "idle", anchor: null, turnStart: null, drainedAt: null }, "无后代无回合为 idle");
1521
+ const anchorTurnA = progressAnchor(anchorIdle, { descendantActive: false, hostStartTime: 1000, now: 1000 });
1522
+ // deepEqual 全形状比较同时验证状态不承载半衰期(C-044:冻结/继承/重捕获语义已废弃)
1523
+ assert.deepEqual(anchorTurnA, { mode: "turn", anchor: 1000, turnStart: 1000, drainedAt: null }, "回合起点即锚点(且不承载 halfLifeSec)");
1524
+ assert.equal(progressAnchor(anchorTurnA, { descendantActive: false, hostStartTime: 1000, now: 5000 }).anchor, 1000, "同回合锚点不变");
1525
+ assert.equal(progressAnchor(anchorTurnA, { descendantActive: false, hostStartTime: 9000, now: 9000 }).anchor, 9000, "无活动后代时回合切换归零重计");
1526
+ const anchorDeleg = progressAnchor(anchorTurnA, { descendantActive: true, hostStartTime: 1000, now: 2000 });
1527
+ assert.deepEqual(anchorDeleg, { mode: "delegating", anchor: 1000, turnStart: 1000, drainedAt: null }, "进入委托周期锚点保持");
1528
+ const anchorDelegIdle = progressAnchor(anchorDeleg, { descendantActive: true, hostStartTime: null, now: 8000 });
1529
+ assert.equal(anchorDelegIdle.anchor, 1000, "自身回合结束后委托周期锚点连续(不归零、不打满)");
1530
+ const anchorDelegNewTurn = progressAnchor(anchorDelegIdle, { descendantActive: true, hostStartTime: 9000, now: 9000 });
1531
+ assert.equal(anchorDelegNewTurn.anchor, 1000, "settle 触发的新回合委托周期内不归零");
1532
+ const anchorDrained = progressAnchor(anchorDelegNewTurn, { descendantActive: false, hostStartTime: 9000, now: 12000 });
1533
+ assert.equal(anchorDrained.anchor, 1000, "后代全部结束、处理回合在飞时锚点仍连续");
1534
+ assert.deepEqual(
1535
+ progressAnchor(anchorDrained, { descendantActive: false, hostStartTime: null, now: 20000 }),
1536
+ { mode: "idle", anchor: null, turnStart: null, drainedAt: null },
1537
+ "处理回合完成即委托周期结束",
1538
+ );
1539
+ assert.equal(
1540
+ progressAnchor(anchorIdle, { descendantActive: false, hostStartTime: 30000, now: 30000 }).anchor,
1541
+ 30000,
1542
+ "委托周期结束后新回合归零重计",
1543
+ );
1544
+ assert.deepEqual(
1545
+ progressAnchor(null, { descendantActive: true, hostStartTime: null, now: 42000 }),
1546
+ { mode: "delegating", anchor: 42000, turnStart: null, drainedAt: null },
1547
+ "无已知起点时以进入委托周期时刻为起点(冷启动)",
1548
+ );
1549
+ // 后代耗尽后宽限内开始的新回合视为 settle 处理回合(锚点连续),超时视为全新回合(归零)。
1550
+ const anchorDrainWait = progressAnchor(anchorDelegNewTurn, { descendantActive: false, hostStartTime: null, now: 30000 });
1551
+ assert.equal(anchorDrainWait.mode, "delegating", "后代耗尽、无开放回合时委托周期不立即退出");
1552
+ assert.equal(anchorDrainWait.drainedAt, 30000, "耗尽时刻记账供 settle 回合归属判定");
1553
+ assert.equal(
1554
+ progressAnchor(anchorDrainWait, { descendantActive: false, hostStartTime: 31000, now: 31000 }).anchor,
1555
+ 1000,
1556
+ "耗尽后宽限内开始的 settle 处理回合锚点连续(不归零)",
1557
+ );
1558
+ assert.equal(
1559
+ progressAnchor(anchorDrainWait, { descendantActive: false, hostStartTime: 91001, now: 91001 }).anchor,
1560
+ 91001,
1561
+ "耗尽宽限超时后开始的新回合归零重计",
1562
+ );
1563
+ // 冷窗口回合起点兜底(R-01-009/AC-06):history 事件尾扫——尾部最近边界为 turn/start 即开放回合起点
1564
+ const turnStartEv = (seq, turn, time) => ({ event: { type: "turn/start", seq, time, data: { turn } } });
1565
+ const turnEndEv = (seq, turn, time) => ({ event: { type: "turn/end", seq, time, data: { turn } } });
1566
+ assert.equal(
1567
+ openTurnStartFromEvents([turnStartEv(1, 1, 1000), turnEndEv(2, 1, 2000), turnStartEv(3, 2, 5000)]),
1568
+ 5000,
1569
+ "尾部边界为 turn/start 时返回其时刻(快照窗口外开放回合起点兜底)",
1570
+ );
1571
+ assert.equal(
1572
+ openTurnStartFromEvents([turnStartEv(1, 1, 1000), turnEndEv(2, 1, 2000)]),
1573
+ null,
1574
+ "尾部边界为 turn/end 时无开放回合",
1575
+ );
1576
+ assert.equal(
1577
+ openTurnStartFromEvents([turnStartEv(1, 1, 1000), turnEndEv(2, 1, 2000), turnStartEv(3, 2, 5000)], 3),
1578
+ null,
1579
+ "history 开放回合落后于快照已知回合(minTurn)时判为陈旧不采用",
1580
+ );
1581
+ assert.equal(openTurnStartFromEvents([turnStartEv(1, 1, Number.NaN)]), null, "turn/start 时刻非法时无可用起点");
1582
+ assert.equal(openTurnStartFromEvents([]), null, "空事件无开放回合起点");
1583
+ assert.equal(openTurnStartFromEvents(null), null, "非数组输入归一 null");
1584
+ // 补读触发口径(R-01-009/AC-06):仅「运行中 + 轮内订阅已建立 + 快照无开放回合起点」算缺口
1585
+ assert.equal(
1586
+ openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: true, liveStartTime: null }),
1587
+ true,
1588
+ "运行中且快照无开放回合起点判定为缺口(超长回合冷窗口)",
1589
+ );
1590
+ assert.equal(
1591
+ openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: true, liveStartTime: 1000 }),
1592
+ false,
1593
+ "窗口内含开放回合起点时不是缺口",
1594
+ );
1595
+ assert.equal(
1596
+ openTurnStartMissing({ snapshotReady: true, running: false, hasLiveness: false, liveStartTime: null }),
1597
+ false,
1598
+ "等待/空闲会话(非运行、无 liveness 记录)不算缺口、不触发补读",
1599
+ );
1600
+ assert.equal(
1601
+ openTurnStartMissing({ snapshotReady: true, running: true, hasLiveness: false, liveStartTime: null }),
1602
+ false,
1603
+ "轮内订阅尚未建立时不算缺口(下一帧建立后再判定)",
1604
+ );
1605
+ assert.equal(
1606
+ openTurnStartMissing({ snapshotReady: false, running: true, hasLiveness: true, liveStartTime: null }),
1607
+ false,
1608
+ "快照未就绪走 historyNeeded 原路径,不算窗口缺口",
1609
+ );
1610
+
1611
+ // ---- R-01-009/AC-07 工作项时间线的状态与主会话窗口语义摘要(无行级耗时,C-012)----
1612
+ const statusTimeline = conversationWorkItems({
1613
+ chat: {
1614
+ order: ["t1", "t2"],
1615
+ nodes: {
1616
+ get: (key) =>
1617
+ ({
1618
+ t1: { key: "t1", kind: "tool-call", data: { root: { kind: "tool-result", callId: "c1", call: { name: "bash", argsRaw: '{"command":"ls","path":"/tmp"}' }, callTime: 1000, time: 3000, isError: false } } },
1619
+ t2: { key: "t2", kind: "tool-call", data: { root: { kind: "tool-result", callId: "c2", call: { name: "read", argsRaw: '{"file_path":"/a/b.txt"}' }, callTime: 4000, time: 6000, isError: true } } },
1620
+ })[key],
1621
+ },
1622
+ },
1623
+ });
1624
+ assert.equal(statusTimeline[0].status, "done", "成功工作项状态为 done");
1625
+ assert.ok(!("durationMs" in statusTimeline[0]) && !("durationMs" in statusTimeline[1]), "工作项不携带行级耗时,对齐主会话窗口(C-012)");
1626
+ assert.equal(statusTimeline[0].detail, "ls", "bash 工作项详情展示原始命令首行(C-011)");
1627
+ assert.equal(statusTimeline[1].status, "error", "出错工作项状态为 error");
1628
+ assert.equal(statusTimeline[1].detail, "/a/b.txt", "read 工作项详情取 file_path 参数键");
1629
+ const runningTimeline = conversationWorkItems({
1630
+ chat: { order: [], nodes: { get: () => undefined } },
1631
+ runningCalls: [{ callId: "rc1", name: "web_search", argsRaw: '{"query":"dsh","url":"https://x"}', turn: 1, step: 0, time: 100 }],
1632
+ });
1633
+ assert.equal(runningTimeline[0].status, "running", "进行中工具调用状态为 running");
1634
+ assert.equal(runningTimeline[0].detail, "dsh", "进行中工具参数摘要按 search variant 参数键取 query");
1635
+ // R-01-009/AC-10 重构等价性钉住(评审):live 项存在时不提升尾部 done 项——
1636
+ // 旧守卫 liveItems.length===0 与现守卫 !some(running) 在可达语义上等价
1637
+ // (live 项恒为 running:partial 硬编码 running,runningCalls 无 result kind)。
1638
+ const livePlusTail = conversationWorkItems({
1639
+ chat: { order: ["tail"], nodes: { get: () => ({ key: "tail", kind: "tool-call", data: { root: { kind: "tool-result", callId: "tail", call: { name: "bash", argsRaw: "{}" } } } }) } },
1640
+ runningCalls: [{ callId: "rc2", name: "grep", argsRaw: "{}" }],
1641
+ });
1642
+ assert.equal(livePlusTail.length, 2, "live 项并入窗口尾部");
1643
+ assert.equal(livePlusTail[1].id, "rc2", "尾项为 live 工具项");
1644
+ assert.equal(livePlusTail[1].status, "running", "live 存在时不克隆提升尾部已定案项(等价性回归)");
1645
+
1646
+ // ---- R-01-009/AC-10 运行中无 live 项时尾部非用户已定案项提升为 running(agent 工作标志)----
1647
+ const idleGapSnapshot = {
1648
+ chat: {
1649
+ order: ["t1"],
1650
+ nodes: { get: () => ({ key: "t1", kind: "tool-call", data: { root: { kind: "tool-result", callId: "c1", call: { name: "read", argsRaw: '{"path":"/tmp/a"}' }, callTime: 10, time: 35, isError: false } } }) },
1651
+ },
1652
+ running: true,
1653
+ pending: [],
1654
+ };
1655
+ const idleGapTimeline = conversationWorkItems(idleGapSnapshot);
1656
+ assert.equal(idleGapTimeline[0].status, "running", "运行中无 live 项时尾部已定案工具项提升为 running");
1657
+ const idleGapSettled = conversationWorkItems({ ...idleGapSnapshot, running: false });
1658
+ assert.equal(idleGapSettled[0].status, "done", "非运行中尾部已定案项保持 done");
1659
+ assert.notEqual(idleGapTimeline[0], idleGapSettled[0], "提升产出克隆而非复用原引用");
1660
+ const pendingIdle = conversationWorkItems({ ...idleGapSnapshot, pending: [{ kind: "approval" }] });
1661
+ assert.equal(pendingIdle[0].status, "done", "等待用户行动时尾部不提升");
1662
+ // pending 期间残留 running 行全部落定:等待卡时间线不再闪烁(R-01-016)。
1663
+ const pendingRunning = foldedConversationTimeline({
1664
+ chat: { order: ["p1"], nodes: { get: () => ({ kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "被打断的思考" }] } }) } },
1665
+ running: false,
1666
+ pending: [{ kind: "approval" }],
1667
+ }, 4, "");
1668
+ assert.ok(pendingRunning.length > 0 && pendingRunning.every((row) => row.status !== "running"), "pending 期间无执行中显示行(不闪烁)");
1669
+ assert.equal(pendingRunning.at(-1)?.status, "done", "pending 残留 running 行落定为 done");
1670
+ // 正文流出 ⇒ 推理落定:live 正文行保持 running,拆入组的思考成员落定(R-01-017/AC-02)。
1671
+ const liveTextFold = foldedConversationTimeline({
1672
+ chat: { order: [], nodes: { get: () => null } },
1673
+ running: true,
1674
+ partial: { turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "先想" }, { kind: "text", text: "正文输出中" }] },
1675
+ }, 4, "");
1676
+ const liveTextBody = liveTextFold.at(-1);
1677
+ const liveTextGroup = liveTextFold.find((row) => row.fold === true);
1678
+ assert.equal(liveTextBody?.status, "running", "流式正文行保持 running(真实在飞项)");
1679
+ assert.ok(liveTextGroup !== undefined && liveTextGroup.status === "done", "正文流出后思考组落定,不与正文行同闪");
1680
+ // 渲染层 idle 判定路径:等待卡使用冻结快照(running=true、无 pending 字段),idle=true 时残留 running 行同样落定(R-01-016)。
1681
+ const frozenSnap = {
1682
+ chat: { order: ["f1"], nodes: { get: () => ({ kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "冻结时的思考" }] } }) } },
1683
+ running: true,
1684
+ };
1685
+ const frozenIdle = foldedConversationTimeline(frozenSnap, 4, "", false, true);
1686
+ assert.ok(frozenIdle.length > 0 && frozenIdle.every((row) => row.status !== "running"), "idle=true 时冻结快照残留 running 行全部落定");
1687
+ const frozenLive = foldedConversationTimeline(frozenSnap, 4, "", false, false);
1688
+ assert.ok(frozenLive.some((row) => row.status === "running"), "idle=false 时冻结快照保持原状态(运行卡实时路径不受影响)");
1689
+ // 委托周期(存在活动后代)视同运行中:尾部提升继续(R-01-009/AC-10)。
1690
+ const delegatingFold = foldedConversationTimeline({ ...idleGapSnapshot, running: false }, 4, "", true);
1691
+ assert.equal(delegatingFold[0].status, "running", "委托周期中尾部已定案项同样提升为 running");
1692
+ // pending + 活动后代:快照级 idle 落定让位于委托语义(R-01-016 例外、R-01-009/AC-10)。
1693
+ const pendingDescendant = foldedConversationTimeline({ ...frozenSnap, pending: [{ kind: "approval" }] }, 4, "", true);
1694
+ assert.ok(pendingDescendant.some((row) => row.status === "running"), "pending 且后代活跃时快照 idle 落定不生效(保留在飞呈现)");
1695
+ // 落定在分组之前:组标题由已定案成员派生,不出现 done 圆点配「正在思考」(R-01-017/AC-03)。
1696
+ assert.equal(frozenIdle.at(-1)?.label, "已思考", "idle 落定后组标题随成员落定(不再显示「正在思考」)");
1697
+ const nonDelegatingFold = foldedConversationTimeline({ ...idleGapSnapshot, running: false }, 4);
1698
+ assert.equal(nonDelegatingFold[0].status, "done", "非运行且非委托周期尾部不提升");
1699
+ const errorTail = conversationWorkItems({
1700
+ chat: { order: ["t"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "c2", call: { name: "bash", argsRaw: '{"command":"bad"}' }, isError: true } } }) } },
1701
+ running: true,
1702
+ });
1703
+ assert.equal(errorTail[0].status, "error", "尾部 error 项不提升,错误标识优先");
1704
+ const stoppedTail = conversationWorkItems({
1705
+ chat: { order: ["t"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "c3", call: { name: "bash", argsRaw: '{"command":"sleep 9"}' }, isError: true, error: { code: "interrupted" } } } }) } },
1706
+ running: true,
1707
+ });
1708
+ assert.equal(stoppedTail[0].status, "stopped", "尾部 stopped 项不提升");
1709
+ const userTail = conversationWorkItems({
1710
+ chat: { order: ["u"], nodes: { get: () => ({ kind: "user", data: { content: [{ type: "text", text: "任务" }] } }) } },
1711
+ running: true,
1712
+ });
1713
+ assert.equal(userTail[0].status, "done", "尾部用户输入项保持 done(提升不适用)");
1714
+ const liveTailUnchanged = conversationWorkItems({ ...idleGapSnapshot, runningCalls: [{ callId: "rc9", name: "grep", argsRaw: '{"pattern":"x"}', turn: 1, step: 0 }] });
1715
+ assert.equal(liveTailUnchanged.map((item) => item.status).join(","), "done,running", "live 项存在时不额外提升已定案项");
1716
+ const midRunningTimeline = conversationWorkItems({
1717
+ chat: {
1718
+ order: ["a", "t"],
1719
+ nodes: {
1720
+ get: (key) =>
1721
+ key === "a"
1722
+ ? { key: "a", kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "text", text: "输出中" }] } }
1723
+ : { key: "t", kind: "tool-call", data: { root: { kind: "tool-result", callId: "c4", call: { name: "read", argsRaw: '{"path":"/tmp/b"}' }, isError: false } } },
1724
+ },
1725
+ },
1726
+ running: true,
1727
+ });
1728
+ assert.deepEqual(midRunningTimeline.map((item) => item.status), ["running", "done"], "时间线已存在执行中项时尾部不再提升");
1729
+
1730
+ // ---- R-01-012/AC-03 fallback 文字镜像原生 keyed/通用行,选中/非选中态不漂移 ----
1731
+ const todoItem = conversationWorkItems({
1732
+ chat: { order: ["td"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "td1", call: { name: "todo_write", argsRaw: '{"todos":[{"content":"写代码","status":"completed"},{"content":"写测试","status":"in_progress"},{"content":"部署","status":"pending"}]}' }, isError: false } } }) } },
1733
+ })[0];
1734
+ assert.equal(todoItem.label, "更新任务清单", "todo_write 标题镜像原生 keyed 行");
1735
+ assert.equal(todoItem.detail, "1/3 已完成 · 写测试", "todo_write 摘要复刻原生进度文案");
1736
+ const askRunning = conversationWorkItems({
1737
+ chat: { order: [], nodes: { get: () => undefined } },
1738
+ runningCalls: [{ callId: "q1", name: "ask_user_question", argsRaw: '{"questions":[]}', turn: 1, step: 0 }],
1739
+ })[0];
1740
+ assert.equal(askRunning.label, "提问", "ask_user_question 标题镜像原生 keyed 行");
1741
+ assert.equal(askRunning.detail, "等待回答", "ask 进行中摘要镜像原生等待文案");
1742
+ const askAnswered = conversationWorkItems({
1743
+ chat: { order: ["q"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "q2", call: { name: "ask_user_question", argsRaw: "{}" }, isError: false, content: [{ type: "text", text: '{"answers":[{"selected":["a"]},{"selected":[],"custom":""}]}' }] } } }) } },
1744
+ })[0];
1745
+ assert.equal(askAnswered.detail, "1/2 已回答", "ask 定案摘要复刻原生已答计数");
1746
+ const askAnsweredMultiBlock = conversationWorkItems({
1747
+ chat: { order: ["q"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "q2m", call: { name: "ask_user_question", argsRaw: "{}" }, isError: false, content: [{ type: "text", text: '{"answers":[{"selected":["a"]},' }, { type: "text", text: '{"selected":[]}]}' }] } } }) } },
1748
+ })[0];
1749
+ assert.equal(askAnsweredMultiBlock.detail, "1/2 已回答", "ask 多块结果文本以空串拼接解析(镜像原生 join 语义)");
1750
+ const askCancelled = conversationWorkItems({
1751
+ chat: { order: ["q"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "q3", call: { name: "ask_user_question", argsRaw: "{}" }, isError: true, error: { name: "AskError", code: "ASK_CANCELLED" } } } }) } },
1752
+ })[0];
1753
+ assert.equal(askCancelled.detail, "已取消", "ask 取消摘要镜像原生");
1754
+ assert.equal(askCancelled.status, "error", "ask 取消保持 error 状态");
1755
+ const askAborted = conversationWorkItems({
1756
+ chat: { order: ["q"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "q4", call: { name: "ask_user_question", argsRaw: "{}" }, isError: true, error: { name: "AskError", code: "ASK_ABORTED" } } } }) } },
1757
+ })[0];
1758
+ assert.equal(askAborted.detail, "已中断", "ask 中断摘要镜像原生");
1759
+ assert.equal(askAborted.status, "stopped", "ask 中断状态归 stopped(镜像原生)");
1760
+ const unknownTool = conversationWorkItems({
1761
+ chat: { order: ["x"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "x1", call: { name: "my_mcp_tool", argsRaw: '{"note":"hi"}' } } } }) } },
1762
+ })[0];
1763
+ assert.equal(unknownTool.label, "Tool call", "未知工具标题镜像原生 others variant");
1764
+ assert.equal(unknownTool.detail, "my_mcp_tool · hi", "未知工具摘要带 `工具名 · ` 前缀(镜像原生)");
1765
+ const cordisDefine = conversationWorkItems({
1766
+ chat: { order: ["cd"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-call", callId: "cd1", call: { name: "cordis_define", argsRaw: '{"name":"my-plugin"}' } } } }) } },
1767
+ })[0];
1768
+ assert.equal(cordisDefine.label, "注册 Cordis 插件", "cordis_define 标题镜像原生 keyed 行");
1769
+ assert.equal(cordisDefine.detail, "my-plugin", "cordis_define 摘要取插件名参数(keyed 行无前缀)");
1770
+ const failedBash = conversationWorkItems({
1771
+ chat: { order: ["f"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "f1", call: { name: "bash", argsRaw: '{"command":"bad","description":"跑坏命令"}' }, isError: true, content: [{ type: "text", text: "boom happened\nstack line" }] } } }) } },
1772
+ })[0];
1773
+ assert.equal(failedBash.status, "error", "失败 bash 状态为 error");
1774
+ assert.equal(failedBash.detail, "boom happened", "错误态摘要取结果输出首行(镜像原生 errorSummary)");
1775
+ const interruptedBash = conversationWorkItems({
1776
+ chat: { order: ["i"], nodes: { get: () => ({ kind: "tool-call", data: { root: { kind: "tool-result", callId: "i1", call: { name: "bash", argsRaw: '{"command":"sleep 9"}' }, isError: true, error: { name: "Error", code: "interrupted" } } } }) } },
1777
+ })[0];
1778
+ assert.equal(interruptedBash.status, "stopped", "interrupted 归 stopped(镜像原生)");
1779
+ assert.equal(interruptedBash.detail, "sleep 9", "stopped 不套用错误首行,保持参数摘要");
1780
+ const thinkSettled = conversationWorkItems({
1781
+ chat: { order: ["th"], nodes: { get: () => ({ kind: "assistant-step", data: { status: "settled", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "第一段\n第二段" }] } }) } },
1782
+ })[0];
1783
+ assert.equal(thinkSettled.summary, "第一段", "Think 摘要镜像原生 firstLine");
1784
+ const thinkStreaming = conversationWorkItems({
1785
+ chat: { order: ["th"], nodes: { get: () => ({ kind: "assistant-step", data: { status: "running", turn: 1, step: 0, blocks: [{ kind: "reasoning", text: "第一段\n进行中段" }] } }) } },
1786
+ })[0];
1787
+ assert.equal(thinkStreaming.summary, "进行中段", "流式 Think 摘要镜像原生 latestLine");
1788
+ const contextItem = conversationWorkItems({
1789
+ chat: { order: ["cx"], nodes: { get: () => ({ kind: "context", data: { content: [{ type: "text", text: "<system_prompt>…</system_prompt>" }], source: { kind: "agent-instructions", changes: [{ path: "AGENTS.md" }] }, provenance: { role: "inject", label: "AGENTS.md" } } }) } },
1790
+ })[0];
1791
+ assert.equal(contextItem.label, "上下文注入", "context 工作项标题镜像原生 ContextInjectionRow(注入)");
1792
+ assert.equal(contextItem.summary, "AGENTS.md", "context 工作项摘要为来源标识而非注入内容原文");
1793
+ const contextRecall = conversationWorkItems({
1794
+ chat: { order: ["cx"], nodes: { get: () => ({ kind: "context", data: { content: [{ type: "text", text: "召回内容" }], provenance: { role: "recall", label: "旧会话" } } }) } },
1795
+ })[0];
1796
+ assert.equal(contextRecall.label, "跨会话召回", "context 工作项标题镜像原生召回文案");
1797
+ const contextNoProvenance = conversationWorkItems({
1798
+ chat: { order: ["cx"], nodes: { get: () => ({ kind: "context", data: { content: [{ type: "text", text: "<system_prompt>…</system_prompt>" }] } }) } },
1799
+ })[0];
1800
+ assert.equal(contextNoProvenance.label, "上下文注入", "provenance 缺失时回退注入标题(镜像原生 unreadable 兜底)");
1801
+ assert.equal(contextNoProvenance.summary, "", "无来源标识时摘要为空,注入内容原文不上卡");
1802
+ // R-01-012/AC-02
1803
+ const unlocatedPartial = conversationWorkItems({
1804
+ chat: {
1805
+ order: ["a", "u"],
1806
+ nodes: {
1807
+ get: (key) =>
1808
+ key === "a"
1809
+ ? { key: "a", kind: "assistant-step", data: { blocks: [{ kind: "text", text: "旧回复" }] } }
1810
+ : { key: "u", kind: "user", data: { content: [{ type: "text", text: "问题" }] } },
1811
+ },
1812
+ },
1813
+ partial: { blocks: [{ kind: "text", text: "流式中" }] },
1814
+ });
1815
+ assert.deepEqual(
1816
+ unlocatedPartial.map((item) => item.text),
1817
+ ["旧回复", "问题", "流式中"],
1818
+ "partial 定位缺省(无 turn/step)时不误摘除无定位 assistant 节点",
1819
+ );
1820
+ // R-01-012/AC-02
1821
+ assert.deepEqual(conversationWorkItems(chatSnapshot, 0), [], "limit=0 返回空时间线");
1822
+ assert.deepEqual(
1823
+ conversationWorkItems({ chat: { order: [], nodes: { get: () => undefined } } }),
1824
+ [],
1825
+ "空 order 返回空时间线",
1826
+ );
1827
+ // R-01-013/AC-03、R-01-013/AC-04
1828
+ assert.deepEqual(
1829
+ messagePreviews({
1830
+ snapshot: {
1831
+ chat: {
1832
+ order: ["s", "a"],
1833
+ nodes: {
1834
+ get: (key) =>
1835
+ key === "s"
1836
+ ? { key, kind: "steering", data: { content: [{ type: "text", text: "补充指令" }] } }
1837
+ : { key, kind: "assistant-step", data: { blocks: [{ kind: "text", text: "已定案回复" }] } },
1838
+ },
1839
+ },
1840
+ },
1841
+ }),
1842
+ { userPreview: "补充指令", agentPreview: "已定案回复" },
1843
+ "steering 消息按用户语义取物理首行",
1844
+ );
1845
+ assert.deepEqual(
1846
+ messagePreviews({
1847
+ snapshot: {
1848
+ chat: {
1849
+ order: ["u", "a"],
1850
+ nodes: {
1851
+ get: (key) =>
1852
+ key === "u"
1853
+ ? { key, kind: "user", data: { content: [{ type: "text", text: "" }] } }
1854
+ : { key, kind: "assistant-step", data: { blocks: [] } },
1855
+ },
1856
+ },
1857
+ },
1858
+ }),
1859
+ { userPreview: "", agentPreview: "" },
1860
+ "空文本项不产生预览",
1861
+ );
1862
+ assert.equal(
1863
+ messagePreviews({
1864
+ snapshot: {
1865
+ chat: {
1866
+ order: ["a"],
1867
+ nodes: { get: () => ({ kind: "assistant-step", data: { blocks: [{ kind: "text", text: "已定案回复" }] } }) },
1868
+ },
1869
+ partial: { turn: 1, step: 0, blocks: [] },
1870
+ },
1871
+ }).agentPreview,
1872
+ "已定案回复",
1873
+ "partial 为空块时回退已定案回复,不被空 live 项遮蔽",
1874
+ );
1875
+ // R-01-013/AC-03、R-01-013/AC-04
1876
+ assert.deepEqual(
1877
+ messagePreviews({
1878
+ history: [
1879
+ { event: { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "更早的用户消息" }] } } },
1880
+ { event: { type: "assistant/message", data: { message: { content: [{ type: "text", text: "更早的回复" }] } } } },
1881
+ { event: { type: "tool/call", data: { callId: "c1", name: "bash", arguments: "{}" } } },
1882
+ ],
1883
+ }),
1884
+ { userPreview: "更早的用户消息", agentPreview: "更早的回复" },
1885
+ "深翻累计事件:尾页无消息时预览取自更早页",
1886
+ );
1887
+ assert.deepEqual(
1888
+ messagePreviews({
1889
+ history: [
1890
+ { event: { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "更早的用户消息" }] } } },
1891
+ { event: { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "最新的用户消息" }] } } },
1892
+ { event: { type: "assistant/message", data: { message: { content: [{ type: "text", text: "最新的回复" }] } } } },
1893
+ ],
1894
+ }),
1895
+ { userPreview: "最新的用户消息", agentPreview: "最新的回复" },
1896
+ "深翻累计事件:新页消息不被旧页遮蔽",
1897
+ );
1898
+
1899
+ // ---- R-01-014/AC-05、R-01-013/AC-03、AC-04 回溯翻页序列:一直向前翻到命中最近用户消息或翻尽 ----
1900
+ const pageOf = (events, hasMore) => ({ events, hasMore });
1901
+ const userEvent = (seq, text) => ({ event: { type: "user/message", seq, data: { source: { kind: "user" }, content: [{ type: "text", text }] } } });
1902
+ const agentEvent = (seq, text) => ({ event: { type: "assistant/message", seq, data: { message: { content: [{ type: "text", text }] } } } });
1903
+ const toolEvent = (seq) => ({ event: { type: "tool/call", seq, data: { callId: `c${seq}`, name: "bash", arguments: "{}" } } });
1904
+ {
1905
+ const calls = [];
1906
+ const fetchPage = async (beforeSeq) => {
1907
+ calls.push(beforeSeq);
1908
+ return pageOf([userEvent(1, "用户"), agentEvent(2, "回复")], true);
1909
+ };
1910
+ const result = await pagedHistoryEvents({ fetchPage });
1911
+ assert.equal(calls.length, 1, "尾页含用户消息时一页即止,不回溯");
1912
+ assert.equal(result.error, null, "成功路径无 error");
1913
+ }
1914
+ {
1915
+ const calls = [];
1916
+ const fetchPage = async (beforeSeq) => {
1917
+ calls.push(beforeSeq);
1918
+ if (calls.length === 1) return pageOf([toolEvent(10)], true);
1919
+ return pageOf([userEvent(1, "更早用户"), agentEvent(2, "更早回复")], false);
1920
+ };
1921
+ const result = await pagedHistoryEvents({ fetchPage });
1922
+ assert.equal(calls.length, 2, "尾页无用户消息时向前回溯");
1923
+ assert.deepEqual(calls[1], 10, "beforeSeq 取上页页首事件 seq");
1924
+ assert.deepEqual(
1925
+ messagePreviews({ history: result.events }),
1926
+ { userPreview: "更早用户", agentPreview: "更早回复" },
1927
+ "回溯后预览取自更早页",
1928
+ );
1929
+ }
1930
+ // R-01-013/AC-03 回溯承诺:一直向前翻,直到命中最近一条用户消息——
1931
+ // 实证约 28% 会话的最后用户消息距尾部 >150 事件(旧 3 页上限外)。
1932
+ {
1933
+ const calls = [];
1934
+ const fetchPage = async (beforeSeq) => {
1935
+ calls.push(beforeSeq);
1936
+ if (calls.length === 4) return pageOf([toolEvent(1), userEvent(2, "深处用户")], false);
1937
+ return pageOf([toolEvent(100 - calls.length)], true);
1938
+ };
1939
+ const result = await pagedHistoryEvents({ fetchPage });
1940
+ assert.equal(calls.length, 4, "用户消息在第 4 页(远超旧 3 页上限)时仍持续回溯直至命中");
1941
+ assert.deepEqual(
1942
+ messagePreviews({ history: result.events }),
1943
+ { userPreview: "深处用户", agentPreview: "" },
1944
+ "回溯命中远处用户消息即止;agent 预览缺失不影响停止条件",
1945
+ );
1946
+ }
1947
+ {
1948
+ let calls = 0;
1949
+ const fetchPage = async () => {
1950
+ calls += 1;
1951
+ return pageOf([toolEvent(100 - calls)], calls < 5);
1952
+ };
1953
+ const result = await pagedHistoryEvents({ fetchPage });
1954
+ assert.equal(calls, 5, "全程无用户消息时回溯直至翻尽(hasMore=false),不以固定页数截断");
1955
+ assert.equal(result.events.length, 5, "已翻事件全部保留");
1956
+ }
1957
+ {
1958
+ let calls = 0;
1959
+ const fetchPage = async () => {
1960
+ calls += 1;
1961
+ return pageOf([toolEvent(calls)], false);
1962
+ };
1963
+ const result = await pagedHistoryEvents({ fetchPage });
1964
+ assert.equal(calls, 1, "hasMore=false 即止");
1965
+ }
1966
+ {
1967
+ // 显式 maxPages 仍作护栏(防畸形数据的显式界;默认 Infinity 即无界)。
1968
+ let calls = 0;
1969
+ const fetchPage = async () => {
1970
+ calls += 1;
1971
+ return pageOf([toolEvent(calls)], true);
1972
+ };
1973
+ const result = await pagedHistoryEvents({ fetchPage, maxPages: 3 });
1974
+ assert.equal(calls, 3, "显式 maxPages 护栏仍生效(默认无界)");
1975
+ assert.equal(result.events.length, 3, "护栏内已翻事件全部保留");
1976
+ }
1977
+ {
1978
+ let calls = 0;
1979
+ const fetchPage = async () => {
1980
+ calls += 1;
1981
+ if (calls === 2) throw new Error("network");
1982
+ return pageOf([toolEvent(calls)], true);
1983
+ };
1984
+ const result = await pagedHistoryEvents({ fetchPage });
1985
+ assert.equal(result.events.length, 1, "回溯中途失败保留已得事件");
1986
+ assert.ok(result.error instanceof Error, "失败以 error 返回供降级展示");
1987
+ }
1988
+ {
1989
+ let calls = 0;
1990
+ const fetchPage = async () => {
1991
+ calls += 1;
1992
+ return calls === 1 ? pageOf([toolEvent(1)], true) : null;
1993
+ };
1994
+ const result = await pagedHistoryEvents({ fetchPage });
1995
+ assert.equal(calls, 2, "业务错误(null)即停止");
1996
+ assert.equal(result.events.length, 1, "业务错误前已得事件保留");
1997
+ }
1998
+ // requireOpenTurnStart(R-01-009/AC-06 冷窗口兜底):用户消息命中但开放回合起点未命中时继续回溯
1999
+ {
2000
+ const calls = [];
2001
+ const fetchPage = async (beforeSeq) => {
2002
+ calls.push(beforeSeq);
2003
+ if (calls.length === 1) return pageOf([userEvent(50, "用户"), agentEvent(51, "回复")], true);
2004
+ return pageOf([{ event: { type: "turn/start", seq: 1, time: 1000, data: { turn: 1 } } }, toolEvent(2)], false);
2005
+ };
2006
+ const result = await pagedHistoryEvents({ fetchPage, requireOpenTurnStart: true });
2007
+ assert.equal(calls.length, 2, "用户消息命中但无开放回合起点时继续回溯(R-01-009/AC-06)");
2008
+ assert.equal(openTurnStartFromEvents(result.events), 1000, "回溯命中开放回合起点时刻");
2009
+ }
2010
+ {
2011
+ let calls = 0;
2012
+ const fetchPage = async () => {
2013
+ calls += 1;
2014
+ return pageOf([userEvent(calls * 2, "u"), agentEvent(calls * 2 + 1, "a")], calls < 2);
2015
+ };
2016
+ await pagedHistoryEvents({ fetchPage, requireOpenTurnStart: true });
2017
+ assert.equal(calls, 2, "无开放回合起点且翻尽(hasMore=false)即止,不以固定页数截断");
2018
+ }
2019
+ // R-01-013/AC-03、AC-04 多页取序:回溯组合的多页事件按旧→新排列,预览必须取最近命中而非最早
2020
+ {
2021
+ assert.deepEqual(
2022
+ messagePreviews({
2023
+ history: [toolEvent(1), userEvent(2, "最早用户"), agentEvent(3, "最早回复"), toolEvent(4), userEvent(5, "最近用户"), agentEvent(6, "最近回复")],
2024
+ }),
2025
+ { userPreview: "最近用户", agentPreview: "最近回复" },
2026
+ "多页回溯后预览取最近用户/agent 消息首行而非最早页",
2027
+ );
2028
+ assert.deepEqual(
2029
+ messagePreviews({
2030
+ history: [userEvent(1, "唯一用户"), agentEvent(2, "回复"), userEvent(3, "最新用户")],
2031
+ }),
2032
+ { userPreview: "最新用户", agentPreview: "回复" },
2033
+ "最近用户消息在尾部的场景取尾部而非最早",
2034
+ );
2035
+ }
2036
+
2037
+ // ---- R-02-003/AC-01 富卡字段并入签名后,进度/轨迹变化必触重重绘 ----
2038
+ assert.notEqual(
2039
+ cardSignature([...entries, { ...entries[0], progress: 42 }]),
2040
+ cardSignature(entries),
2041
+ "progress 变化签必变",
2042
+ );
2043
+ assert.notEqual(
2044
+ cardSignature([...entries, { ...entries[0], timeline: [{ id: "x", label: "Read" }] }]),
2045
+ cardSignature(entries),
2046
+ "工作项时间线变化签名必变",
2047
+ );
2048
+ assert.notEqual(
2049
+ cardSignature([...entries, { ...entries[0], outputTokens: 42 }]),
2050
+ cardSignature(entries),
2051
+ "token 统计变化签名必变",
2052
+ );
2053
+ assert.notEqual(
2054
+ cardSignature([{ ...entries[0], cacheHitPct: 88 }, ...entries.slice(1)]),
2055
+ cardSignature(entries),
2056
+ "缓存命中率变化签名必变",
2057
+ );
2058
+ // ---- R-01-010/AC-01 非活动且 24h 内→最近历史区 ----
2059
+ const NOW = 2_000_000_000_000; // 固定时钟便于确定性断言
2060
+ // 注意:completed:true 的主会话是"待打开"的活动卡(在活动区),不属历史区;
2061
+ // 真实"最近历史"是已处理(running:false 且无未确认完成)的非活动会话;
2062
+ // 「待打开E」为宿主 completed 边沿标志但无完成登记——按 C-030 口径不受活动判定。
2063
+ const recentSnap = {
2064
+ ids: ["sA", "sB", "sOld", "sBlank", "sAwait"],
2065
+ byId: {
2066
+ sA: { id: "sA", displayTitle: "运行A", running: true, completed: false, updatedAt: NOW },
2067
+ sB: { id: "sB", displayTitle: "旧B", running: false, completed: false, updatedAt: NOW - 3_600_000 },
2068
+ sOld: { id: "sOld", displayTitle: "太旧C", running: false, completed: false, updatedAt: NOW - 26 * 60 * 60 * 1000 },
2069
+ sBlank: { id: "sBlank", displayTitle: "空白D", blank: true, running: false, updatedAt: NOW - 1_000 },
2070
+ sAwait: { id: "sAwait", displayTitle: "待开E", running: false, completed: true, updatedAt: NOW - 2_000 },
2071
+ },
2072
+ current: null,
2073
+ };
2074
+ // R-01-001/AC-01、R-01-002/AC-03(C-030):宿主 completed 边沿标志本身不再驱动显示——
2075
+ // 无完成登记(completions 无记录)时 completed 行视为不活动,落入历史区。
2076
+ const recentUncompleted = buildRecent(recentSnap, [], NOW);
2077
+ assert.deepEqual(
2078
+ recentUncompleted.map((e) => e.id),
2079
+ ["sAwait", "sB"],
2080
+ "completed 边沿标志不参与活动/历史判定;无完成登记时按历史窗口入历史区(C-030)",
2081
+ );
2082
+ // 完成登记存在时(未确认完成):待开 E 显示为完成提醒并排除出历史区(AC-03、R-01-010/AC-06)。
2083
+ const recentAcks = new Map([
2084
+ ["sAwait", { lastTurnEnd: NOW - 1_000, ackedAt: null }],
2085
+ ["sAwait2", { lastTurnEnd: NOW - 900, ackedAt: NOW - 2_000 }],
2086
+ ]);
2087
+ const recent = buildRecent(recentSnap, [], NOW, undefined, {}, [], recentAcks);
2088
+ assert.deepEqual(
2089
+ recent.map((e) => e.id),
2090
+ ["sB"],
2091
+ "完成登记表中未确认完成的会话留在活动区、排除出历史区;已确认的不再排除(仍按窗口入区)",
2092
+ );
2093
+ assert.deepEqual(
2094
+ [recent[0].model, recent[0].reasoning, recent[0].userPreview, recent[0].agentPreview],
2095
+ ["", "", "", ""],
2096
+ "模型/history API 缺失时历史卡数据字段为空",
2097
+ );
2098
+ assert.deepEqual(modelMetadata({}), { model: "", reasoning: "" }, "models 失败或空 payload 时模型区域为空");
2099
+ assert.deepEqual(messagePreviews({ history: [] }), { userPreview: "", agentPreview: "" }, "history 失败或空 payload 时消息预览为空");
2100
+ // R-01-013/AC-01
2101
+ // R-01-013/AC-02
2102
+ // R-01-013/AC-03
2103
+ // R-01-013/AC-04
2104
+ // R-01-013/AC-05
2105
+ // R-01-013/AC-06
2106
+ const recentWithPreviews = buildRecent(recentSnap, [], NOW, undefined, {
2107
+ sB: {
2108
+ model: { model: "Model M", reasoning: "High" },
2109
+ previews: { userPreview: "用户首行", agentPreview: "回复首行" },
2110
+ },
2111
+ }, [], recentAcks);
2112
+ assert.deepEqual(
2113
+ recentWithPreviews[0],
2114
+ {
2115
+ id: "sB",
2116
+ kind: "recent",
2117
+ depth: 0,
2118
+ title: "旧B",
2119
+ workspaceTitle: "",
2120
+ workspaceKey: "",
2121
+ model: "Model M",
2122
+ reasoning: "High",
2123
+ userPreview: "用户首行",
2124
+ agentPreview: "回复首行",
2125
+ isCurrent: false,
2126
+ activityAt: NOW - 3_600_000,
2127
+ },
2128
+ "历史卡五行数据缺失时仍保留空字段并复用模型/预览",
2129
+ );
2130
+ assert.deepEqual(
2131
+ messagePreviews({
2132
+ history: [
2133
+ { event: { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "\n用户首行\n第二行" }] } } },
2134
+ { event: { type: "assistant/message", data: { message: { content: [{ type: "text", text: "\n回复首行\n详情" }] } } } },
2135
+ ],
2136
+ }),
2137
+ { userPreview: "用户首行", agentPreview: "回复首行" },
2138
+ "历史卡用户与 agent 预览取首个非空物理行",
2139
+ );
2140
+
2141
+ // ---- R-01-010/AC-02 活动→非活动 移入历史区 ----
2142
+ // 同一会话:运行态时出现在活动区、不在历史区;转为非活动后从活动区消失并进入历史区。
2143
+ const activeDuringRun = buildRecent(
2144
+ {
2145
+ ids: ["sB"],
2146
+ byId: { sB: { id: "sB", displayTitle: "旧B", running: true, updatedAt: NOW - 60_000 } },
2147
+ current: null,
2148
+ },
2149
+ [],
2150
+ NOW,
2151
+ );
2152
+ assert.equal(activeDuringRun.length, 0, "运行中会话不在历史区");
2153
+ const viaRunToIdle = buildEntries(
2154
+ { ids: ["sB"], byId: { sB: { id: "sB", displayTitle: "旧B", running: true } }, current: null },
2155
+ [],
2156
+ );
2157
+ assert.deepEqual(viaRunToIdle.map((e) => e.id), ["sB"], "运行中会话在活动区");
2158
+ const recentAfterIdle = buildRecent(recentSnap, [], NOW, undefined, {}, [], recentAcks);
2159
+ assert.ok(recentAfterIdle.some((e) => e.id === "sB"), "转为非活动后进入历史区");
2160
+
2161
+ // ---- R-01-010/AC-03 历史区按最后活动时间从新到旧 ----
2162
+ const multiRecent = buildRecent(
2163
+ {
2164
+ ids: ["r1", "r2", "r3"],
2165
+ byId: {
2166
+ r1: { id: "r1", displayTitle: "R1", running: false, updatedAt: NOW - 2_000 },
2167
+ r2: { id: "r2", displayTitle: "R2", running: false, updatedAt: NOW - 5_000 },
2168
+ r3: { id: "r3", displayTitle: "R3", running: false, updatedAt: NOW - 1_000 },
2169
+ },
2170
+ current: null,
2171
+ },
2172
+ [],
2173
+ NOW,
2174
+ );
2175
+ assert.deepEqual(
2176
+ multiRecent.map((e) => e.id),
2177
+ ["r3", "r1", "r2"],
2178
+ "历史区按最后活动时间倒序",
2179
+ );
2180
+
2181
+ // ---- R-01-010/AC-08 最后活动时间:turn/end 提取与 max 归一 ----
2182
+ assert.equal(lastTurnEndFromEvents([]), null, "空 history 无回合结束时刻");
2183
+ assert.equal(
2184
+ lastTurnEndFromEvents([{ event: { type: "user/message", time: 100 } }]),
2185
+ null,
2186
+ "无 turn/end 时回退 null(中断会话不抛错)",
2187
+ );
2188
+ assert.equal(
2189
+ lastTurnEndFromEvents([
2190
+ { event: { type: "turn/end", time: 1000, data: { turn: 1 } } },
2191
+ { event: { type: "user/message", time: 2000 } },
2192
+ { event: { type: "turn/end", time: 3000, data: { turn: 2 } } },
2193
+ ]),
2194
+ 3000,
2195
+ "history 取最后一条 turn/end 的时刻",
2196
+ );
2197
+ assert.equal(
2198
+ lastTurnEndFromEvents([
2199
+ { event: { type: "turn/end", time: 1000, data: { turn: 1 } } },
2200
+ { event: { type: "turn/end", data: { turn: 2 } } },
2201
+ ]),
2202
+ 1000,
2203
+ "最后 turn/end 缺有效 time 时继续向前取更早有效回合",
2204
+ );
2205
+ assert.equal(lastTurnEndFromTimings(new Map()), null, "无回合计时回退 null");
2206
+ assert.equal(
2207
+ lastTurnEndFromTimings(new Map([[1, { startTime: 100 }]])),
2208
+ null,
2209
+ "回合未结束(无 endTime)不计",
2210
+ );
2211
+ assert.equal(
2212
+ lastTurnEndFromTimings(new Map([
2213
+ [1, { startTime: 100, endTime: 900 }],
2214
+ [2, { startTime: 1000 }],
2215
+ [3, { startTime: 2000, endTime: 2500 }],
2216
+ ])),
2217
+ 2500,
2218
+ "turnTimings 取最大 endTime,忽略未结束回合",
2219
+ );
2220
+ const refineSnap = {
2221
+ ids: ["sTurn", "sPrompt", "sNone"],
2222
+ byId: {
2223
+ sTurn: { id: "sTurn", displayTitle: "回合", running: false, updatedAt: NOW - 10_000 },
2224
+ sPrompt: { id: "sPrompt", displayTitle: "消息", running: false, updatedAt: NOW - 1_000 },
2225
+ sNone: { id: "sNone", displayTitle: "无回合", running: false, updatedAt: NOW - 5_000 },
2226
+ },
2227
+ current: null,
2228
+ };
2229
+ const refined = buildRecent(refineSnap, [], NOW, undefined, {}, [], null, null, {
2230
+ sTurn: NOW - 2_000, // 回合结束晚于宿主时间 → 精化为回合结束时刻
2231
+ sPrompt: NOW - 3_000, // 回合结束早于宿主时间(消息未处理)→ 取较新者
2232
+ });
2233
+ assert.deepEqual(
2234
+ refined.map((e) => [e.id, e.activityAt]),
2235
+ [["sPrompt", NOW - 1_000], ["sTurn", NOW - 2_000], ["sNone", NOW - 5_000]],
2236
+ "activityAt 取宿主列表时间与回合结束时刻的较新者并据此排序(R-01-010/AC-08)",
2237
+ );
2238
+
2239
+ // ---- R-01-010/AC-09 数据在途先按宿主列表时间,到达后精化 ----
2240
+ const unrefined = buildRecent(refineSnap, [], NOW);
2241
+ assert.deepEqual(
2242
+ unrefined.map((e) => [e.id, e.activityAt]),
2243
+ [["sPrompt", NOW - 1_000], ["sNone", NOW - 5_000], ["sTurn", NOW - 10_000]],
2244
+ "回合结束时刻在途时先按宿主列表时间判定、排序与显示(R-01-010/AC-09)",
2245
+ );
2246
+ // 窗口下界语义:宿主时间超窗的会话不读取历史,即使回合在窗内结束也不入区(C-020 明示缺口)。
2247
+ const crossWindow = buildRecent(
2248
+ { ids: ["sLong"], byId: { sLong: { id: "sLong", displayTitle: "长回合", running: false, updatedAt: NOW - 25 * 3_600_000 } }, current: null },
2249
+ [],
2250
+ NOW,
2251
+ undefined,
2252
+ {},
2253
+ [],
2254
+ null,
2255
+ null,
2256
+ { sLong: NOW - 1_000 },
2257
+ );
2258
+ assert.equal(crossWindow.length, 0, "宿主时间超窗的会话不入历史区(跨窗长回合缺口,C-020)");
2259
+
2260
+ // ---- R-01-002/AC-03、AC-05、AC-10~AC-12、R-01-010/AC-06 完成确认:未确认完成提醒保留活动卡;
2261
+ // 显式确认(按钮)或新回合隐式更替后解除;打开/切走/刷新不解除(C-030)----
2262
+ const holdBase = { id: "sB", displayTitle: "旧B", running: false, updatedAt: NOW - 1_000 };
2263
+ const acks = (lastTurnEnd, ackedAt = null) => new Map([["sB", { lastTurnEnd, ackedAt }]]);
2264
+ // completionReminder 成立判定:仅主会话、lastTurnEnd > ackedAt(无 ackedAt 视同未确认)。
2265
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: null }, false), true, "有未确认完成即成立");
2266
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: 999 }, false), true, "ackedAt 早于 lastTurnEnd 仍成立");
2267
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: 1000 }, false), false, "ackedAt 齐平 lastTurnEnd 不成立");
2268
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 1000, ackedAt: 2000 }, false), false, "ackedAt 晚于 lastTurnEnd 不成立");
2269
+ assert.equal(completionReminder(holdBase, null, false), false, "无完成登记不成立(升级不回溯补发提醒)");
2270
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 0, ackedAt: null }, false), false, "lastTurnEnd 非法不作数");
2271
+ assert.equal(completionReminder({ id: "m-c1", parentId: "m", displayTitle: "子S" }, { lastTurnEnd: 1000, ackedAt: null }, true), false, "子代理不产生完成提醒");
2272
+ // 打开/切换当前会话不解除(AC-05):完成提醒成立与 current 无关。
2273
+ // R-01-002/AC-05 打开或切换当前会话不解除完成提醒:判定与 current 无关(C-030)。
2274
+ const holdSnap = { ids: ["sB"], byId: { sB: holdBase }, current: "sA" };
2275
+ const confirmEntries = buildEntries(holdSnap, [], {}, acks(1000));
2276
+ assert.deepEqual(
2277
+ confirmEntries.map((e) => [e.id, e.kind, e.pendingText ?? null, e.waitClass, e.noteText, e.isCurrent]),
2278
+ [["sB", "awaiting", null, "done", "继续对话,或移入历史", false]],
2279
+ "未确认完成提醒以 awaiting 完成提醒条目(胶囊「已完成」+固定正文)留在活动区,是否当前会话无关",
2280
+ );
2281
+ assert.deepEqual(
2282
+ awaitBadgeStats(confirmEntries),
2283
+ { waiting: 1, blocked: 0, total: 1 },
2284
+ "完成提醒计入徽标等待分子但不计阻塞(R-01-002/AC-06)",
2285
+ );
2286
+ assert.deepEqual(
2287
+ buildRecent(holdSnap, [], NOW, undefined, {}, [], acks(1000)).map((e) => e.id),
2288
+ [],
2289
+ "未确认完成提醒不入历史区(分区不变量)",
2290
+ );
2291
+ // running 抑制:完成提醒在运行期间按运行卡呈现(C-030 呈现层抑制条件不变)。
2292
+ const confirmRunning = buildEntries({ ids: ["sB"], byId: { sB: { ...holdBase, running: true } }, current: null }, [], {}, acks(1000));
2293
+ assert.equal(confirmRunning[0].kind, "running", "完成提醒在运行期间被呈现抑制");
2294
+ // 显式确认(AC-10):ackedAt 前移即解除,会话退出活动区、转入历史区。
2295
+ const ackedMap = acks(1000, 1500);
2296
+ assert.deepEqual(
2297
+ buildEntries(holdSnap, [], {}, ackedMap).map((e) => [e.id, e.kind]),
2298
+ [],
2299
+ "确认后完成提醒解除、退出活动区",
2300
+ );
2301
+ assert.deepEqual(
2302
+ buildRecent(holdSnap, [], NOW, undefined, {}, [], ackedMap).map((e) => e.id),
2303
+ ["sB"],
2304
+ "确认后会话进入历史区",
2305
+ );
2306
+ // 新回合隐式更替:lastTurnEnd 前移后旧确认游标不再覆盖新回合(仍未确认则对新回合成立)。
2307
+ assert.equal(completionReminder(holdBase, { lastTurnEnd: 2000, ackedAt: 1500 }, false), true, "新回合完成后提醒针对新回合重新成立");
2308
+ // 委托周期抑制:后代活动期间完成提醒不生效(呈现不依赖宿主 completed)。
2309
+ const delegDoneMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", running: true } }, current: null };
2310
+ assert.deepEqual(
2311
+ buildEntries(delegDoneMix, [], {}, acks(1000), new Set(["root"])).map((e) => [e.id, e.pendingText ?? null]),
2312
+ [["root", null], ["root-c1", null]],
2313
+ "委托周期中完成提醒不生效",
2314
+ );
2315
+ // 阻塞等待优先:pendingInteraction 时按对应文案呈现而非完成提醒。
2316
+ const pendingMixSnap = { ids: ["sB"], byId: { sB: { ...holdBase, pendingInteraction: "approval" } }, current: null };
2317
+ assert.deepEqual(
2318
+ buildEntries(pendingMixSnap, [], {}, acks(1000)).map((e) => [e.id, e.kind, e.pendingText, e.waitClass]),
2319
+ [["sB", "awaiting", "待确认", "blocked"]],
2320
+ "阻塞等待优先于完成提醒呈现",
2321
+ );
2322
+
2323
+ // ---- R-01-002/AC-13 错误提醒:最近回合以错误结束 → 红色等待卡;随新回合覆盖解除;
2324
+ // 无确认按钮(不消费 ack 游标);刷新恢复;后代/运行抑制;错误信息正文 ----
2325
+ const errAcks = (lastTurnEndKind, error, ackedAt = null) =>
2326
+ new Map([["sB", { lastTurnEnd: 1000, lastTurnEndKind, lastTurnEndError: error, ackedAt }]]);
2327
+ assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind: "error" }, false), true, "error 回合结束即成立");
2328
+ assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind: "completed" }, false), false, "正常回合不成立");
2329
+ assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, { lastTurnEndKind: "error", ackedAt: 99999 }, false), true, "错误提醒不消费 ack 游标(ackedAt 不影响成立)");
2330
+ assert.equal(errorReminder({ id: "sB", displayTitle: "旧B" }, null, false), false, "无登记不成立(升级不回溯补发错误提醒)");
2331
+ assert.equal(errorReminder({ id: "m-c1", parentId: "m", displayTitle: "子S" }, { lastTurnEndKind: "error" }, true), false, "子代理不产生错误提醒");
2332
+ const errEntries = buildEntries(holdSnap, [], {}, errAcks("error", "The engine is currently overloaded, please try again later"));
2333
+ assert.deepEqual(
2334
+ errEntries.map((e) => [e.id, e.kind, e.pendingText ?? null, e.waitClass, e.noteText]),
2335
+ [["sB", "awaiting", null, "error", "The engine is currently overloaded, please try again later"]],
2336
+ "error 回合以 awaiting 错误提醒条目留在活动区(红色卡面、正文为错误信息)",
2337
+ );
2338
+ assert.deepEqual(
2339
+ buildEntries(holdSnap, [], {}, errAcks("error", "")).map((e) => [e.id, e.waitClass, e.noteText]),
2340
+ [["sB", "error", ERROR_NOTE_FALLBACK]],
2341
+ "error 回合无错误信息时回落固定文案(R-01-002/AC-09)",
2342
+ );
2343
+ assert.ok(ERROR_NOTE_MAX > 0, "错误信息截断上限为正数");
2344
+ assert.equal(ERROR_NOTE_FALLBACK, "回合以错误结束,请检查会话");
2345
+ assert.equal(truncateErrorNote("短错误"), "短错误", "不超限原样返回");
2346
+ assert.equal(truncateErrorNote("x".repeat(ERROR_NOTE_MAX)), "x".repeat(ERROR_NOTE_MAX), "恰在上限时原样返回、不加省略号");
2347
+ assert.equal(
2348
+ truncateErrorNote("x".repeat(ERROR_NOTE_MAX + 1)),
2349
+ `${"x".repeat(ERROR_NOTE_MAX)}…`,
2350
+ "超限截断至上限字符并以省略号收尾(省略号不计入上限)",
2351
+ );
2352
+ assert.equal(truncateErrorNote("😀".repeat(ERROR_NOTE_MAX + 1)), `${"😀".repeat(ERROR_NOTE_MAX)}…`, "按 Unicode 码点截断,代理对字符不被劈开");
2353
+ assert.equal(truncateErrorNote(null), "", "非字符串入参返回空串(防御性边界)");
2354
+ assert.deepEqual(
2355
+ awaitBadgeStats(errEntries),
2356
+ { waiting: 1, blocked: 0, total: 1 },
2357
+ "错误提醒计入徽标等待分子但不计阻塞(R-01-002/AC-06)",
2358
+ );
2359
+ assert.deepEqual(
2360
+ buildRecent(holdSnap, [], NOW, undefined, {}, [], errAcks("error", "boom")).map((e) => e.id),
2361
+ [],
2362
+ "错误提醒中会话不入历史区(分区不变量,R-01-010/AC-06)",
2363
+ );
2364
+ // 优先级:同一登记上 error 优先于 done(lastTurnEndKind 为 error 时兼有未确认完成)。
2365
+ assert.deepEqual(
2366
+ buildEntries(holdSnap, [], {}, errAcks("error", "boom", 1000)).map((e) => [e.id, e.waitClass]),
2367
+ [["sB", "error"]],
2368
+ "错误提醒优先于完成提醒呈现(C-043)",
2369
+ );
2370
+ // 抑制与覆盖:运行期间按运行卡呈现;新回合(kind 覆盖为正常原因)即解除错误提醒——
2371
+ // 未确认时落入完成提醒(turn/end 照常登记 lastTurnEnd),已确认后完全退出活动区。
2372
+ const errRunning = buildEntries({ ids: ["sB"], byId: { sB: { ...holdBase, running: true } }, current: null }, [], {}, errAcks("error", "boom"));
2373
+ assert.equal(errRunning[0].kind, "running", "错误提醒在运行期间被呈现抑制");
2374
+ const clearedErr = buildEntries(holdSnap, [], {}, errAcks("completed", null));
2375
+ assert.deepEqual(
2376
+ clearedErr.map((e) => [e.id, e.kind, e.waitClass]),
2377
+ [["sB", "awaiting", "done"]],
2378
+ "新回合正常结束后错误提醒覆盖解除:未确认时转为完成提醒(绿卡)",
2379
+ );
2380
+ const clearedErrAcked = buildEntries(holdSnap, [], {}, errAcks("completed", null, 2000));
2381
+ assert.deepEqual(clearedErrAcked.map((e) => [e.id, e.kind]), [], "新回合正常结束且已确认后完全退出活动区(错误提醒无确认按钮语义)");
2382
+ // 委托周期抑制:后代活动期间错误提醒不生效。
2383
+ const delegErrMix = { ids: ["root", "root-c1"], byId: { root: holdBase, "root-c1": { id: "root-c1", displayTitle: "子S", parentId: "root", running: true } }, current: null };
2384
+ const delegErrAcks = new Map([["root", { lastTurnEnd: 1000, lastTurnEndKind: "error", lastTurnEndError: "boom", ackedAt: null }]]);
2385
+ assert.deepEqual(
2386
+ buildEntries(delegErrMix, [], {}, delegErrAcks, new Set(["root"])).map((e) => e.id),
2387
+ ["root", "root-c1"],
2388
+ "委托周期中错误提醒不生效(保持运行呈现)",
2389
+ );
2390
+
2391
+ // ---- R-01-016/AC-01 等待卡条目承载会话最后已知工作项时间线(数据路径)----
2392
+ const settledTrace = [{ id: "w1", kind: "tool", label: "Bash", summary: "pnpm check", status: "done" }];
2393
+ const awaitingTraceEntries = buildEntries(holdSnap, [], { sB: { timeline: settledTrace } }, acks(1000));
2394
+ assert.equal(awaitingTraceEntries[0].kind, "awaiting", "完成提醒中会话以 awaiting 卡呈现");
2395
+ assert.deepEqual(awaitingTraceEntries[0].timeline, settledTrace, "awaiting 条目承载会话最近工作项时间线(R-01-016/AC-01)");
2396
+ const pendingTraceEntries = buildEntries(pendingSnap, [], { sP: { timeline: settledTrace } });
2397
+ assert.equal(pendingTraceEntries[0].kind, "awaiting", "待确认会话以 awaiting 卡呈现");
2398
+ assert.deepEqual(pendingTraceEntries[0].timeline, settledTrace, "待确认 awaiting 条目同样承载时间线(R-01-016/AC-01)");
2399
+
2400
+ // ---- R-01-010/AC-07 活动区→历史区迁移判定 ----
2401
+ assert.deepEqual(
2402
+ movedToRecentIds(new Set(["sA", "sB"]), [{ id: "sA" }], [{ id: "sB" }]),
2403
+ ["sB"],
2404
+ "上一帧活动区 id 离开活动区且出现于历史区判定为迁移",
2405
+ );
2406
+ assert.deepEqual(movedToRecentIds(new Set(["sB"]), [], []), [], "彻底消失(归档/滑出历史窗口)不判定为迁移");
2407
+ assert.deepEqual(movedToRecentIds(new Set(), [{ id: "sB" }], []), [], "上一帧不在活动区不判定为迁移");
2408
+ assert.deepEqual(movedToRecentIds(new Set(["sB"]), [{ id: "sB" }], []), [], "仍在活动区不判定为迁移");
2409
+
2410
+ // ---- R-01-010/AC-07 历史区→活动区迁移判定(反向,与 movedToRecentIds 镜像)----
2411
+ assert.deepEqual(
2412
+ movedToActiveIds(new Set(["rA", "rB"]), [{ id: "rB" }], [{ id: "rA" }]),
2413
+ ["rB"],
2414
+ "上一帧历史区 id 离开历史区且出现于活动区判定为反向迁移",
2415
+ );
2416
+ assert.deepEqual(movedToActiveIds(new Set(["rB"]), [], []), [], "彻底消失(归档/滑出历史窗口)不判定为反向迁移");
2417
+ assert.deepEqual(movedToActiveIds(new Set(), [{ id: "rB" }], []), [], "上一帧不在历史区不判定为反向迁移");
2418
+ assert.deepEqual(movedToActiveIds(new Set(["rB"]), [], [{ id: "rB" }]), [], "仍在历史区不判定为反向迁移");
2419
+
2420
+ // ---- R-01-003/AC-02、R-01-010/AC-01 已结束子代理不入最近历史 ----
2421
+ const recentSubSnap = {
2422
+ ids: ["m", "m-c1"],
2423
+ byId: {
2424
+ m: { id: "m", displayTitle: "主M", running: false, completed: false, updatedAt: NOW - 1_000 },
2425
+ "m-c1": { id: "m-c1", displayTitle: "子S", running: false, completed: false, parentId: "m", updatedAt: NOW - 500 },
2426
+ },
2427
+ current: null,
2428
+ };
2429
+ const recentSub = buildRecent(recentSubSnap, [], NOW);
2430
+ assert.ok(
2431
+ recentSub.some((e) => e.id === "m") && !recentSub.some((e) => e.id === "m-c1"),
2432
+ "最近历史仅主会话,已结束子代理不入历史区",
2433
+ );
2434
+
2435
+ // ---- R-01-010/AC-01 归档会话不入最近历史(不可选中,列出即成死卡) ----
2436
+ const recentArchived = buildRecent(
2437
+ {
2438
+ ids: ["sKeep", "sGone"],
2439
+ byId: {
2440
+ sKeep: { id: "sKeep", displayTitle: "保留K", running: false, completed: false, updatedAt: NOW - 1_000 },
2441
+ sGone: { id: "sGone", displayTitle: "归档G", running: false, completed: false, updatedAt: NOW - 500 },
2442
+ },
2443
+ current: null,
2444
+ },
2445
+ [],
2446
+ NOW,
2447
+ undefined,
2448
+ {},
2449
+ ["sGone"],
2450
+ );
2451
+ assert.deepEqual(
2452
+ recentArchived.map((e) => e.id),
2453
+ ["sKeep"],
2454
+ "归档会话即使在 24h 窗口内也不入最近历史",
2455
+ );
2456
+ assert.deepEqual(
2457
+ buildRecent(
2458
+ {
2459
+ ids: ["sKeep", "sGone"],
2460
+ byId: {
2461
+ sKeep: { id: "sKeep", displayTitle: "保留K", running: false, completed: false, updatedAt: NOW - 1_000 },
2462
+ sGone: { id: "sGone", displayTitle: "归档G", running: false, completed: false, updatedAt: NOW - 500 },
2463
+ },
2464
+ current: null,
2465
+ },
2466
+ [],
2467
+ NOW,
2468
+ undefined,
2469
+ {},
2470
+ new Set(["sGone"]),
2471
+ ).map((e) => e.id),
2472
+ ["sKeep"],
2473
+ "归档集同样接受 Set 形态",
2474
+ );
2475
+
2476
+ // ---- R-01-014/AC-01 列表在途显示加载指示而非空态 ----
2477
+ assert.equal(listLoadState(null), "loading", "快照缺失视为列表在途");
2478
+ assert.equal(listLoadState({ phase: "pending" }), "loading", "phase 为 pending 视为列表在途");
2479
+ assert.equal(listLoadState({ phase: "ready" }), "ready", "phase 为 ready 才允许空态");
2480
+ assert.equal(listLoadState({ phase: "ready", state: "error" }), "error", "列表错误轴归一为 error");
2481
+ assert.equal(listLoadState({ phase: "ready", error: { code: "x" } }), "error", "携带 error 字段归一为 error");
2482
+
2483
+ // ---- R-01-014/AC-06 数量标识在途显示加载指示而非冒充计数 | R-01-002/AC-06 脉冲门控 ----
2484
+ assert.deepEqual(
2485
+ countBadgeState("loading", 0, 0),
2486
+ { mode: "loading", text: "", ariaText: "活动会话计数加载中", awaiting: false },
2487
+ "列表在途归一为加载指示,不冒充 0/0",
2488
+ );
2489
+ assert.equal(countBadgeState("loading", 1, 3).awaiting, false, "在途期即便有等待计数也不触发脉冲");
2490
+ assert.equal(countBadgeState("error", 0, 0).mode, "count", "错误轴不归一为加载指示");
2491
+ assert.deepEqual(
2492
+ countBadgeState("ready", 0, 0),
2493
+ { mode: "count", text: "0/0", ariaText: "0 个活动会话", awaiting: false },
2494
+ "就绪空态仍显示 0/0(R-01-001/AC-06)",
2495
+ );
2496
+ assert.deepEqual(
2497
+ countBadgeState("ready", 1, 3, 1),
2498
+ { mode: "count", text: "1/3", ariaText: "3 个活动会话,1 个等待你答复", awaiting: true },
2499
+ "存在阻塞等待:脉冲开启,aria 表达等你答复(R-01-002/AC-06)",
2500
+ );
2501
+ assert.deepEqual(
2502
+ countBadgeState("ready", 2, 3, 1),
2503
+ { mode: "count", text: "2/3", ariaText: "3 个活动会话,1 个等待你答复,1 个已完成", awaiting: true },
2504
+ "混合态 aria 同时携带阻塞与完成计数",
2505
+ );
2506
+ assert.deepEqual(
2507
+ countBadgeState("ready", 2, 3, 0),
2508
+ { mode: "count", text: "2/3", ariaText: "3 个活动会话,2 个已完成", awaiting: true },
2509
+ "仅完成提醒同样开启脉冲:两类等待行为一致(R-01-002/AC-06,C-037 翻案 C-028)",
2510
+ );
2511
+
2512
+ // ---- R-01-014/AC-05 补充数据失败降级为空字段并可重试 ----
2513
+ // (行为链详见 R-01-012/AC-01 的 detailLoadPlan 锚点:失败置空 → 可见期内不热重试 →
2514
+ // 离开可见清理 → 重回可见允许重试。)
2515
+ assert.equal(detailLoadPlan({ detail: { model: { model: "", reasoning: "" } } }).model, false, "失败置空即降级为空字段");
2516
+
2517
+ // ---- R-01-015/AC-02 拖拽宽度夹取 | R-01-015/AC-04 持久化恢复归一 ----
2518
+ assert.equal(clampPaneWidth(280), 280, "范围内宽度原样保留");
2519
+ assert.equal(clampPaneWidth(199.6), 200, "拖拽越过下界夹取到最小 200px");
2520
+ assert.equal(clampPaneWidth(480.4), 480, "拖拽越过上界夹取到最大 480px");
2521
+ assert.equal(clampPaneWidth("360"), 360, "localStorage 字符串宽度解析恢复");
2522
+ assert.equal(clampPaneWidth(null), 280, "无持久化记录回退默认 280px");
2523
+ assert.equal(clampPaneWidth(""), 280, "空串回退默认 280px");
2524
+ assert.equal(clampPaneWidth("abc"), 280, "非法持久化值回退默认 280px");
2525
+ assert.equal(clampPaneWidth(999), 480, "越界持久化值夹取进允许范围");
2526
+ assert.equal(clampPaneWidth(-5), 200, "负值持久化值夹取到最小 200px");
2527
+
2528
+ // ---- 重建 client bundle 并校验产物契约 ----
2529
+ await mkdir(join(root, ".dsh-plugin"), { recursive: true });
2530
+ execFileSync(process.execPath, [join(root, "scripts/build-client.mjs")], {
2531
+ cwd: root,
2532
+ stdio: "pipe",
2533
+ });
2534
+ const bundle = await readFile(join(root, ".dsh-plugin/client.js"), "utf8");
2535
+ const clientSource = await readFile(join(root, "src/client.mjs"), "utf8");
2536
+ assert.ok(
2537
+ clientSource.includes('lastTurnEndKind: typeof record?.lastTurnEndKind === "string"') &&
2538
+ clientSource.includes('lastTurnEndError: typeof record?.lastTurnEndError === "string"'),
2539
+ "SSE ack 快照完整保留错误提醒的 kind 与正文(R-01-002/AC-13,T-088)",
2540
+ );
2541
+ // bundle 必须是可解析的合法 JS(new Function 只编译不执行)——防止 CSS 模板内
2542
+ // 误插反引号这类"字符串检查能过、但 loader 导入即失败"的损坏。
2543
+ assert.doesNotThrow(
2544
+ () => new Function(bundle),
2545
+ "bundle 必须是合法 JS(可被 loader 导入注册)",
2546
+ );
2547
+ assert.ok(!bundle.includes("sessionsListHas"), "点击不得以第二份 list 快照提前拦截");
2548
+ // 折叠分组为时间线唯一来源;指令槽位派生与渲染不残留(R-01-018 已删除,C-019)
2549
+ assert.ok(
2550
+ bundle.includes("foldedConversationTimeline") && bundle.includes("foldWorkGroups"),
2551
+ "折叠分组派生函数进入 bundle(R-01-017)",
2552
+ );
2553
+ assert.ok(
2554
+ bundle.includes("openTurnStartFromEvents") && bundle.includes("requireOpenTurnStart"),
2555
+ "开放回合起点 history 兜底进入 bundle(R-01-009/AC-06 冷窗口兜底)",
2556
+ );
2557
+ assert.ok(
2558
+ bundle.includes("historyInstructionAnchor") && bundle.includes("memoTimelineAnchor") && bundle.includes("foldedHistoryTimeline") && !bundle.includes("withInstructionAnchor"),
2559
+ "history 锚行作为核心时间线输入且 client 不再二次裁剪(R-01-009/AC-11、R-01-012/AC-12、C-035)",
2560
+ );
2561
+ assert.ok(!bundle.includes("renderSlot") && !bundle.includes("dap-slot"), "指令槽位渲染无残留(C-019)");
2562
+ assert.ok(!bundle.includes("rememberLastUser") && !bundle.includes("lastUserFromEvents") && !bundle.includes("foldedTimelineWithSlot") && !bundle.includes("foldWorkGroupsWithSlot"), "槽位派生家族无残留(C-019)");
2563
+ assert.ok(!bundle.includes('document.addEventListener("click"'), "不得在 document 上拦截点击");
2564
+ // R-01-008/AC-02 移动端抽屉经标题行整体激活收起(与桌面同一控件,无独立 × 按钮)
2565
+ assert.ok(!bundle.includes("dap-close") && !bundle.includes("onCloseClick"), "不再保留独立关闭按钮:移动端与桌面同为标题行整体控件(R-01-008/AC-02)");
2566
+ assert.ok(
2567
+ clientSource.includes("const onHeaderActivate = () => {\n\t\t\tif (window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`).matches) {\n\t\t\t\ttogglePane(false);"),
2568
+ "移动端断点标题行激活即收起抽屉,而非折叠窄条(R-01-008/AC-02、R-01-011/AC-06)",
2569
+ );
2570
+ assert.ok(!/\.dap-collapse-hint \{\s*display: none/.test(bundle), "方向符号 « 两端断点一致呈现(R-01-008/AC-02)");
2571
+ // R-01-011/AC-03 标题行整体作为桌面收起控件(无独立按钮)
2572
+ assert.ok(bundle.includes('class="dap-header" role="button"'), "标题行整体作为可激活控件");
2573
+ assert.ok(bundle.includes('header?.addEventListener("click", onHeaderActivate)'), "标题行绑定 click 收起");
2574
+ assert.ok(bundle.includes('header?.addEventListener("keydown", onHeaderKeydown)'), "标题行支持 Enter/Space 键盘激活");
2575
+ assert.ok(!bundle.includes("onCollapseClick") && !bundle.includes('class="dap-collapse"'), "不再保留独立收起按钮:标题行整体承担折叠");
2576
+ // R-01-011/AC-04 折叠窄条竖排标题 + 计数、整体可点
2577
+ assert.ok(bundle.includes('class="dap-rail-title"'), "折叠窄条显示竖排标题");
2578
+ assert.ok(bundle.includes("writing-mode: vertical-rl"), "窄条标题竖排呈现");
2579
+ assert.ok(/\.dap-rail \{[^]*?flex: 1;/.test(bundle), "窄条撑满窗格高度,整面均为展开命中区");
2580
+ assert.ok(bundle.includes('[data-collapsed="true"] .dap-rail:hover,'), "折叠窄条悬停/聚焦高亮,与展开态标题行反馈对等");
2581
+ // R-01-011/AC-06 移动端标题行激活解释为收起抽屉
2582
+ assert.ok(bundle.includes("matchMedia(`(max-width: ${MOBILE_BREAKPOINT})`)"), "标题行收起经移动断点门控");
2583
+ assert.ok(bundle.includes('rail?.addEventListener("click", onRailClick)'), "折叠窄条绑定自身 click");
2584
+ assert.ok(bundle.includes('class="dap-rail" type="button"'), "折叠窄条使用原生 button 语义");
2585
+ // R-01-015/AC-01 拖拽手柄实时调宽、主会话弹性让位
2586
+ assert.ok(bundle.includes('class="dap-resize" aria-hidden="true"'), "窗格右缘提供拖拽调宽手柄");
2587
+ assert.ok(bundle.includes('resize?.addEventListener("pointerdown", onResizeDown)'), "拖拽手柄绑定 pointerdown");
2588
+ assert.ok(bundle.includes('resize.addEventListener("pointermove", onResizeMove)'), "拖拽经 pointermove 实时调宽");
2589
+ assert.ok(bundle.includes('pane.style.setProperty("--dap-width", `${paneWidth}px`)'), "拖拽实时写入 --dap-width 令主会话弹性让位");
2590
+ assert.ok(bundle.includes("resize.setPointerCapture(event.pointerId)"), "拖拽经 pointer capture 跟踪指针");
2591
+ assert.ok(bundle.includes("resizeNotifyHandle = requestAnimationFrame("), "拖拽期间经 rAF 合帧派发 resize 通知(overlay 实时跟随)");
2592
+ // R-01-015/AC-02 拖拽目标宽度经夹取
2593
+ assert.ok(bundle.includes("clampPaneWidth(startWidth + move.clientX - startX)"), "拖拽目标宽度经 clampPaneWidth 夹取 200–480px");
2594
+ // R-01-015/AC-03 折叠窄条与移动端抽屉不提供拖拽
2595
+ assert.ok(bundle.includes('[data-collapsed="true"] .dap-resize { display: none; }'), "折叠窄条不提供拖拽调宽");
2596
+ assert.ok(bundle.includes("[data-dsh-activity-pane] .dap-resize { display: none; }"), "移动端抽屉不提供拖拽调宽");
2597
+ // R-01-015/AC-04 调宽持久化与启动恢复
2598
+ assert.ok(bundle.includes("localStorage.getItem(WIDTH_STORAGE_KEY)"), "启动读取持久化宽度恢复");
2599
+ assert.ok(bundle.includes("writeStoredPaneWidth(paneWidth)"), "拖拽结束写入持久化宽度");
2600
+ assert.ok(bundle.includes('resize?.removeEventListener("pointerdown", onResizeDown)'), "unbind 移除拖拽监听(R-02-003/AC-02)");
2601
+ assert.ok(bundle.includes("unbindPaneControls"), "窗格控制监听可清理");
2602
+ assert.ok(bundle.includes("notifyLayoutChange"), "布局变化通知 sibling overlay 重测");
2603
+ assert.ok(bundle.includes('window.dispatchEvent(new Event("resize"))'), "布局变化派发标准 resize 通知");
2604
+ assert.ok(bundle.includes("pane !== renderedPane"), "新窗格实例必须重置渲染签名");
2605
+ assert.ok(
2606
+ clientSource.includes("const sig = JSON.stringify([listState, cardSignature(visibleEntries), pulseSurface]);"),
2607
+ "列表 phase 转换必须参与结构化渲染签名,空列表不得冻结在加载/失败状态(T-087)",
2608
+ );
2609
+ // R-01-013/AC-02 回归:卡片标题必须随快照更新——单卡渲染异常不得冻结其余卡片
2610
+ // (此前渲染签名先于卡片循环提交且无异常隔离,故障卡及其后全部卡片永久滞留旧标题,
2611
+ // 历史卡因此停在首条消息形态的 fallback 标题,与左侧栏脱节)。
2612
+ assert.ok(
2613
+ clientSource.includes("if (renderOk) {\n\t\t\tlastSig = sig;") && !/if \(sig === lastSig\) return;\s*lastSig = sig;/.test(clientSource),
2614
+ "渲染签名仅在整轮卡片渲染成功后提交,不得在卡片循环前预先提交",
2615
+ );
2616
+ assert.equal(
2617
+ (clientSource.match(/logCardRenderError\(entry\.id, error\)/g) ?? []).length,
2618
+ 2,
2619
+ "活动区与历史区卡片渲染均须逐卡 try/catch 异常隔离并上报",
2620
+ );
2621
+ assert.ok(bundle.includes("openRetryStates"), "跳转重试链必须可合并并清理");
2622
+ assert.ok(bundle.includes("shouldCancelOpenRetry"), "打开重试链经统一取消判定防止过期链条拽回会话");
2623
+ assert.ok(bundle.includes("escapeCssString"), "卡片定位选择器经统一转义");
2624
+ assert.ok(!clientSource.includes("value.partial"), "history 响应不读取不存在的 partial 字段(宿主契约为 events/hasMore/projections)");
2625
+ assert.ok(!clientSource.includes("timelineUserMessages"), "不重新引入 C-007/C-008 否决的 timelineUserMessages 投影");
2626
+ assert.ok(bundle.includes("isSubagentRow(byId[id], byId)"), "子代理跳过必被 agent-busy 拒绝的 models 读取");
2627
+ assert.ok(
2628
+ bundle.includes("let rec = reuseMap.get(entry.id);"),
2629
+ "renderCardIntoList 必须先取复用记录再判空:丢失该行会让 rec 未声明抛 ReferenceError,逐卡 catch 吞掉后整区空白",
2630
+ );
2631
+ assert.ok(
2632
+ !bundle.includes("[data-dsh-activity-pane] .dap-rail {\n position: absolute;"),
2633
+ "折叠态展开按钮 .dap-rail 不得被绝对定位:撞名规则会把按钮压成 1px 竖线,折叠态窗格整体空白",
2634
+ );
2635
+ assert.ok(
2636
+ !bundle.includes("list.appendChild(rec.el)"),
2637
+ "渲染不得无条件 appendChild 移动卡片:卡片瞬时脱离文档会让浏览器取消按下/抬起之间的 click、让焦点卡失焦、丢失悬停态(会话活跃期高频渲染时窗格整体不响应)",
2638
+ );
2639
+ assert.ok(
2640
+ bundle.includes("list.insertBefore(rec.el, ref)"),
2641
+ "卡片仅在顺序/归属变化时移动 DOM(insertBefore 位置守卫)",
2642
+ );
2643
+ // ---- R-01-009/AC-02、R-01-009/AC-05、R-01-012/AC-01..04、R-01-013/AC-01..06 ----
2644
+ assert.ok(bundle.includes("foldedConversationTimeline"), "活动卡时间线由折叠分组唯一来源派生(R-01-012、R-01-017)");
2645
+ assert.ok(!bundle.includes("dap-trace-time"), "工作项时间线不渲染行级耗时元素,对齐主会话窗口(R-01-009/AC-07、C-012)");
2646
+ assert.ok(!bundle.includes("PROGRESS_THINK_BASE") && !bundle.includes("progressFloor"), "回合进度纯时间驱动,无思考基线/单调下限残留(R-01-009/AC-06、C-014)");
2647
+ assert.ok(bundle.includes("progressHalfLifeSec"), "bundle 含半衰期速率校准函数(R-01-009/AC-06、C-025、C-044)");
2648
+ assert.ok(
2649
+ bundle.includes("halfLifeSec: progressHalfLifeSec({ rateTokS })"),
2650
+ "进度赋值每帧按最新实测速率现算半衰期(R-01-009/AC-06、C-044)",
2651
+ );
2652
+ assert.ok(
2653
+ !bundle.includes("halfLifeSec: anchor.halfLifeSec"),
2654
+ "锚点状态不再承载半衰期(C-044:k 不随锚点捕获冻结)",
2655
+ );
2656
+ // R-01-009/AC-10
2657
+ // R-01-017 无条件折叠(C-017):检测探测与原生行呈现机器不得残留
2658
+ assert.ok(!bundle.includes("dshcf") && !bundle.includes("autoCollapseActive"), "无 dsh-auto-collapse 探测残留(R-01-017、C-017)");
2659
+ assert.ok(!bundle.includes("nativeWorkItemRow") && !bundle.includes("cloneNativeIcon") && !bundle.includes("nativeIconsByTraceKey"), "原生行匹配/图标克隆机器无残留(C-017)");
2660
+ assert.ok(!bundle.includes("mergeTraceStatus") && !bundle.includes("allowNativePresentation"), "行状态直接采用核心派生值,无合并/切换层(C-017)");
2661
+ assert.ok(bundle.includes("api.history"), "冷会话使用 native history 一次性补齐");
2662
+ assert.ok(bundle.includes("api.models"), "模型/reasoning 使用 native models 数据");
2663
+ assert.ok(bundle.includes("dap-token-stats"), "token 统计 DOM 位于进度条之后");
2664
+ assert.ok(
2665
+ bundle.includes('makeEl("span", "dap-token-main")') && bundle.includes('makeEl("span", "dap-token-time")'),
2666
+ "统计行双段结构:左列文本 + 右置时长(R-01-009/AC-05)",
2667
+ );
2668
+ assert.ok(bundle.includes("`输入 ${fmtTokens("), "统计行含输入/输出中文短标签(R-01-009/AC-05)");
2669
+ assert.ok(
2670
+ bundle.indexOf("parts.push(`${Math.round(entry.rateTokS)} tok/s`") <
2671
+ bundle.indexOf("parts.push(`缓存 ${entry.cacheHitPct}%`)") &&
2672
+ bundle.indexOf("parts.push(`缓存 ${entry.cacheHitPct}%`)") <
2673
+ bundle.indexOf("parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`)") &&
2674
+ bundle.indexOf("parts.push(`输入 ${fmtTokens(entry.inputTokens) ?? entry.inputTokens}`)") <
2675
+ bundle.indexOf("parts.push(`输出 ${fmtTokens(entry.outputTokens) ?? entry.outputTokens}`)"),
2676
+ "左列顺序对齐主窗口:tok/s、缓存命中、输入、输出(R-01-009/AC-05)",
2677
+ );
2678
+ assert.ok(!bundle.includes("≈"), "速率不再携带约等于符号(R-01-009/AC-05)");
2679
+ assert.ok(bundle.includes("dap-history-line"), "历史卡包含用户/agent 两条消息预览行");
2680
+ // R-01-003/AC-04
2681
+ assert.ok(bundle.includes("parentId: m.isSub ? String(parentId) : null"), "活动卡条目保留直属母会话 id");
2682
+ assert.ok(bundle.includes("function trackRuns(entries)"), "母会话轨道拓扑由纯函数 trackRuns 一次求出");
2683
+ assert.ok(bundle.includes('[data-dsh-activity-pane] .dap-tracks {'), "列表内置绝对定位轨道层");
2684
+ assert.ok(bundle.includes('[data-dsh-activity-pane] .dap-conn-track {'), "每个母会话一条连续轨道元素");
2685
+ assert.ok(bundle.includes('trackEl.className = "dap-conn-track"'), "轨道元素使用独立类名");
2686
+ assert.ok(bundle.includes('class="dap-tracks" aria-hidden="true"'), "轨道层为纯装饰、不进可访问性树");
2687
+ assert.ok(
2688
+ bundle.includes("syncTracks(activeList, active, cardsById)"),
2689
+ "全部卡片写入后统一测量绘制轨道(读写分离,避免布局抖动)",
2690
+ );
2691
+ assert.ok(
2692
+ bundle.includes("function trackBoxes(run, rectOf, indentPx)") && bundle.includes("trackBoxes(run, rectOf, INDENT_PX)"),
2693
+ "竖轨与横线几何(母会话底缘 → 末级子卡中心、逐子卡横线、统一取整)在纯函数 trackBoxes 中推导并被可执行断言钉住,渲染层只做测量与写入",
2694
+ );
2695
+ assert.ok(
2696
+ bundle.includes("rec.el.getBoundingClientRect()") && !bundle.includes("rec.el.offsetTop"),
2697
+ "轨道测量必须用浮点矩形:offsetTop/offsetHeight 是整数舍入值,与 CSS 全精度定位的横线会随机差 1~2px",
2698
+ );
2699
+ assert.ok(bundle.includes("new ResizeObserver("), "卡片高度随流式内容变化时由 ResizeObserver 重算轨道");
2700
+ assert.ok(
2701
+ bundle.includes("window.devicePixelRatio") && bundle.includes("Math.round(baseLeft * dpr) / dpr") && bundle.includes("queueTrackSync"),
2702
+ "轨道层必须整体对齐设备像素网格(层原点的小数相位会让 1px 线段粗细不稳),滚动后经 rAF 重对齐",
2703
+ );
2704
+ assert.ok(
2705
+ bundle.includes("cancelAnimationFrame(trackSyncHandle)"),
2706
+ "卸载时必须取消未执行的滚动重对齐 rAF 并清空轨道上下文,避免回调落到已移除列表",
2707
+ );
2708
+ assert.ok(bundle.includes('[data-dsh-activity-pane] .dap-conn-stub {'), "接入横线由轨道层元素绘制");
2709
+ assert.ok(bundle.includes('el.className = "dap-conn-stub"'), "横线元素使用独立类名");
2710
+ assert.ok(
2711
+ bundle.includes("Math.round(parent.left + indentPx / 2 + 1)") && bundle.includes("Math.round(rect.top + rect.height / 2)"),
2712
+ "全部线段坐标统一取整到 CSS 像素:小数坐标定位的 1px 线段被抗锯齿随机摊薄(粗细不一、端点错位)",
2713
+ );
2714
+ assert.ok(
2715
+ !bundle.includes('"data-connector"') && !bundle.includes(".dap-card[data-connector]::after"),
2716
+ "横线不得回退到卡片伪元素:CSS 按小数 50% 定位的横线相位随机,粗细不稳定",
2717
+ );
2718
+ assert.ok(
2719
+ !bundle.includes('.dap-card[data-connector]::before'),
2720
+ "竖向轨道不得再由卡片伪元素分段拼接:接缝端点落在随机亚像素相位上,断口与重叠并存不可控(T-033)",
2721
+ );
2722
+ assert.ok(
2723
+ !bundle.includes("data-last-child"),
2724
+ "末级收口由测量给出精确值,不得回退到 data-last-child + calc(50%+6px) 的 CSS 凑数",
2725
+ );
2726
+ assert.ok(
2727
+ !bundle.includes('el.className = "dap-rail"'),
2728
+ "轨道元素不得复用 dap-rail:该类已被折叠态展开按钮占用,撞名会使按钮被绝对定位成 1px 竖线(折叠态窗格整体空白)并被 querySelector 误取",
2729
+ );
2730
+ assert.ok(
2731
+ !bundle.includes("[data-dsh-activity-pane] .dap-rail {\n position: absolute;"),
2732
+ "折叠态展开按钮 .dap-rail 不得被绝对定位:撞名规则会把按钮压成 1px 竖线,折叠态窗格整体空白",
2733
+ );
2734
+ assert.ok(
2735
+ clientSource.includes("const INDENT_PX = 16;") &&
2736
+ bundle.includes("Math.round(parent.left + indentPx / 2 + 1)") &&
2737
+ bundle.includes("renderCardIntoList(activeList, entry, cardsById, index, 1, hueByWorkspace)"),
2738
+ "几何耦合钉住:INDENT_PX=16、轨道 left 由母会话卡片左缘测量推导(+半槽+1px border,取整后与横线起笔相接)与活动区卡片 offset=1(轨道层为首子节点),改任一必须同步",
2739
+ );
2740
+ // R-01-003/AC-05
2741
+ assert.ok(bundle.includes("function activeSessionIds(byId = {})"), "活动子代理沿 parentId 链补齐活动祖先");
2742
+ // ---- R-01-016/AC-01 等待卡保留最近工作项时间线 ----
2743
+ assert.ok(
2744
+ bundle.includes('return [head, row, makeEl("div", "dap-trace"), foot];'),
2745
+ "awaiting 骨架在标题行与末行两段(胶囊+正文)之间含时间线容器(R-01-016/AC-01,C-043)",
2746
+ );
2747
+ // ---- R-01-002/AC-10 完成提醒卡「移入历史」按钮 ----
2748
+ assert.ok(
2749
+ bundle.includes('noteRow.append(makeEl("div", "dap-note"), makeConfirmButton());'),
2750
+ "awaiting 正文行为「正文+按钮」行容器,胶囊已移至首行;按钮仅完成提醒卡显示(R-01-002/AC-08、AC-10,C-043)",
2751
+ );
2752
+ assert.ok(bundle.includes('button.className = "dap-confirm"') && bundle.includes('button.textContent = "移入历史"'), "完成提醒卡按钮以「移入历史」文案呈现(R-01-002/AC-10,C-040)");
2753
+ assert.ok(
2754
+ bundle.includes('confirm.addEventListener("click"') && bundle.includes("event.stopPropagation()") && bundle.includes("confirm.addEventListener(\"keydown\", (event) => event.stopPropagation())") && bundle.includes('ackCompletion(id)'),
2755
+ "按钮点击/键盘激活写回 ack 且阻断卡片跳转(R-01-002/AC-10)",
2756
+ );
2757
+ assert.ok(bundle.includes('confirm.hidden = entry.waitClass !== "done"'), "仅完成提醒卡显示「移入历史」按钮,阻塞等待卡不显示(R-01-002/AC-10)");
2758
+ assert.ok(bundle.includes("new window.EventSource(`${ACK_API_BASE}/acks/stream`)"), "完成确认状态经 SSE 通道订阅(R-01-002/AC-11、AC-12)");
2759
+ // R-01-002/AC-12 缺陷回归:移动 PWA 后台恢复后 ack 通道必须自愈(EventSource CLOSED/半开
2760
+ // 不再自动重连),否则完成等待中的会话被误判入历史区直至整页重载。
2761
+ assert.ok(
2762
+ bundle.includes('document.addEventListener("visibilitychange", onVisibilityResume)') && bundle.includes('window.addEventListener("pageshow", onPageShow)'),
2763
+ "回到前台/bfcache 还原触发 ack 通道自愈(R-01-002/AC-12)",
2764
+ );
2765
+ assert.ok(
2766
+ bundle.includes("function resumeAcksChannel()") && bundle.includes("function connectAcksStream()") && bundle.includes("acksSource?.close()"),
2767
+ "ack 通道自愈经无条件重建 SSE 连接收敛(连接即收全量快照,R-01-002/AC-12)",
2768
+ );
2769
+ assert.ok(
2770
+ bundle.includes('document.removeEventListener("visibilitychange", onVisibilityResume)') && bundle.includes('window.removeEventListener("pageshow", onPageShow)'),
2771
+ "卸载时移除 ack 通道自愈监听(R-01-002/AC-12)",
2772
+ );
2773
+ assert.ok(bundle.includes("fetch(`${ACK_API_BASE}/ack`"), "确认写回经宿主侧 ack 路由(R-01-002/AC-10、AC-11)");
2774
+ assert.ok(
2775
+ !bundle.includes("updateCompletedHolds") && !bundle.includes("heldCompletedIds") && !bundle.includes("prevActiveMainIds"),
2776
+ "响应保持易失记账全套移除(C-030)",
2777
+ );
2778
+ // ---- R-01-003/AC-05 委托周期保持运行呈现:parent 卡形态已废除 ----
2779
+ assert.ok(
2780
+ !bundle.includes('entry.kind === "parent"') && !bundle.includes('[data-kind="parent"]'),
2781
+ "parent 分支与样式全部移除,委托母会话保持运行卡呈现(R-01-003/AC-05)",
2782
+ );
2783
+ assert.equal(
2784
+ bundle.split('makeEl("span", "dap-pct")').length - 1,
2785
+ 1,
2786
+ "百分比文本元素全 bundle 仅运行卡骨架一处创建(R-01-009/AC-06)",
2787
+ );
2788
+ assert.equal(
2789
+ bundle.split('querySelector(".dap-pct")').length - 1,
2790
+ 1,
2791
+ "百分比文本写入全 bundle 仅运行卡渲染分支一处(R-01-009/AC-06)",
2792
+ );
2793
+ // ---- R-01-016/AC-04 时间线数据在途时显示加载指示、返回就地填充 ----
2794
+ assert.ok(
2795
+ bundle.split("renderTimelineArea(traceContainer, entry").length - 1 === 3 && !bundle.includes("nativePresentationSessionId"),
2796
+ "运行/subagent/等待卡统一复用 renderTimelineArea:在途显示加载行、返回就地填充(R-01-016/AC-04)",
2797
+ );
2798
+ // R-01-013/AC-07、R-01-013/AC-08
2799
+ assert.ok(bundle.includes('dataset.role = "user"'), "用户消息行骨架静态标识 user 角色");
2800
+ assert.ok(bundle.includes('dataset.role = "agent"'), "agent 回复行骨架静态标识 agent 角色");
2801
+ assert.ok(bundle.includes("dap-history-icon"), "历史卡预览行带常驻角色图标段");
2802
+ assert.ok(bundle.includes("dap-history-text"), "历史卡预览文本写入图标后的独立文本段");
2803
+ // R-01-013/AC-07、AC-08 最近卡预览行对齐时间线形式:角色标签 + 圆点分隔符 + 12px 图标盒
2804
+ assert.ok(bundle.includes("dap-history-label"), "历史卡预览行带角色标签段(R-01-013/AC-07、AC-08)");
2805
+ assert.ok(bundle.includes('userLabel.textContent = "用户"'), "用户消息行带「用户」标签(R-01-013/AC-07)");
2806
+ assert.ok(bundle.includes('agentLabel.textContent = "助手"'), "agent 回复行带「助手」标签(R-01-013/AC-08)");
2807
+ assert.ok(bundle.includes("dap-history-separator"), "历史卡预览行标签与文本之间带圆点分隔符(R-01-013/AC-07、AC-08)");
2808
+ assert.ok(
2809
+ bundle.includes(".dap-history-icon svg { display: block; width: 12px; height: 12px; }") &&
2810
+ !bundle.includes(".dap-history-icon svg { display: block; width: 10px; height: 10px; }"),
2811
+ "历史卡角色图标盒 12px,与时间线图标一致(R-01-013/AC-07、AC-08)",
2812
+ );
2813
+ // R-01-013/AC-08
2814
+ assert.ok(bundle.includes("agentIcon.append(createRobotIcon())"), "agent 回复行使用机器人图标");
2815
+ // R-01-012/AC-09、AC-11 回归:机器人图标为 Lucide bot 改造的小电视几何——去双耳、双 45° 外撇短斜天线
2816
+ //(ISC 许可,来源声明见 LICENSE/README);几何与清晰化选型见 C-021
2817
+ assert.ok(bundle.includes("M10 8L7 5M14 8L17 5"), "机器人图标为双斜短天线小电视几何(R-01-012/AC-09)");
2818
+ assert.ok(!bundle.includes("M2 14h2") && !bundle.includes("M20 14h2") && !bundle.includes("M12 8V4H8"), "机器人图标无双耳与旧单折线天线残留(C-021)");
2819
+ assert.ok(
2820
+ bundle.includes('"stroke-width": "2.2"') && !bundle.includes('"stroke-width": "1.3"'),
2821
+ "机器人描边 2.2(12/22 缩放渲染 1.2px),旧 1.3px 细描边不残留(R-01-012/AC-11)",
2822
+ );
2823
+ assert.ok(
2824
+ !bundle.includes('data-icon="robot"] .dap-trace-icon svg'),
2825
+ "机器人字形与其他时间线图标同用 12px 盒,无 13px 半像素偏移覆盖(R-01-012/AC-11)",
2826
+ );
2827
+ // R-01-012/AC-11 机器人图标 viewBox 保框(1 3 22 18)保持显示尺度,与同盒 canonical 图标一致
2828
+ assert.ok(bundle.includes('viewBox: "1 3 22 18"'), "机器人图标 viewBox 保框保持显示尺度,不因留白显小(R-01-012/AC-11)");
2829
+ assert.ok(!bundle.includes('viewBox: "0 0 24 24"'), "机器人图标不使用留白 24 框(R-01-012/AC-11)");
2830
+ // R-01-012/AC-03(T-021 副作用守卫:历史卡换图标不影响时间线兜底)
2831
+ // R-01-012/AC-09 时间线 assistant 行图标:正文行机器人图标(与最近卡 agent 角色标识同源)、思考行思考图标,按 detail 有无分流而非比较 label 文案
2832
+ assert.ok(bundle.includes('? createThinkIcon() : createRobotIcon()'), "时间线 assistant 正文行使用机器人图标、思考行使用思考图标(R-01-012/AC-09)");
2833
+ assert.ok(!bundle.includes('label === "Think"') && !bundle.includes('label === "Assistant"'), "图标分流不比较 label 显示文案(R-01-012/AC-09)");
2834
+ // R-01-012/AC-10 时间线数据层无英文 Think/Assistant 标签残留
2835
+ assert.ok(!bundle.includes('"Think"') && !bundle.includes('"Assistant"'), "bundle 无英文 Think/Assistant 标签残留(R-01-012/AC-10)");
2836
+ assert.ok(bundle.includes('"思考"') && bundle.includes('"助手"'), "bundle 含中文「思考」「助手」标签(R-01-012/AC-09、AC-10)");
2837
+ assert.ok(bundle.includes("session.subscribe"), "运行卡通过 native session subscribe 接收实时推送");
2838
+ assert.ok(
2839
+ clientSource.includes('const inject = ["connection", "sessions", "workspaces"];'),
2840
+ "sessions/workspaces 通过 client inject 注入,不依赖服务发现定时器",
2841
+ );
2842
+ assert.ok(!bundle.includes("serviceTimer"), "服务发现不得保留后台定时器");
2843
+ assert.ok(!bundle.includes("frameProbeTimer"), "宿主 frame 发现不得保留后台定时器");
2844
+ assert.ok(bundle.includes("conversationObserver"), "流式 DOM 观察绑定到 conversation seat");
2845
+ assert.ok(bundle.includes("centerObserver"), "宿主结构变化通过 center 直接子节点通知处理");
2846
+ assert.ok(bundle.includes("setInterval(() => queueSync(), CLOCK_MS)"), "仅保留运行时长显示所需的单一 1 秒时钟");
2847
+ // R-01-014/AC-01
2848
+ // R-01-014/AC-02
2849
+ // R-01-014/AC-03
2850
+ // R-01-014/AC-04
2851
+ // ---- R-01-014 加载过程可见与渐进呈现 ----
2852
+ assert.ok(bundle.includes("listLoadState"), "列表加载态经 listLoadState 归一");
2853
+ assert.ok(bundle.includes('listState === "loading" ? "加载中…"'), "列表在途时活动区显示加载指示而非空态");
2854
+ assert.ok(bundle.includes('"列表加载失败"'), "列表错误时显示失败文案而非空态");
2855
+ assert.ok(bundle.includes("node.dataset.mode"), "加载指示与空态分模式渲染");
2856
+ assert.ok(bundle.includes("dap-spinner"), "加载指示使用活动图标");
2857
+ // R-01-014/AC-06 数量标识在途显示加载指示而非冒充计数
2858
+ assert.ok(bundle.includes("countBadgeState"), "数量标识在途态经 countBadgeState 归一");
2859
+ assert.ok(bundle.includes("setCountBadgeContent"), "三处数量标识在途接入加载指示");
2860
+ assert.ok(bundle.includes("活动会话计数加载中"), "数量标识加载态 aria 文案不冒充计数");
2861
+ assert.ok(
2862
+ bundle.includes('.dap-count .dap-spinner') && bundle.includes('.dap-rail-count .dap-spinner') && bundle.includes('.dap-toggle-count .dap-spinner'),
2863
+ "三处数量标识均有加载指示样式",
2864
+ );
2865
+ assert.ok(bundle.includes("loadingModel"), "模型字段级加载指示并入签名");
2866
+ assert.ok(bundle.includes("loadingTimeline"), "时间线字段级加载指示并入签名");
2867
+ assert.ok(bundle.includes("loadingPreviews"), "预览字段级加载指示并入签名");
2868
+ assert.ok(bundle.includes("renderTraceLoading"), "时间线区数据在途时显示加载行");
2869
+ assert.ok(clientSource.includes('e2eParams.get("dap-e2e-model-delay")'), "detail 渐进 E2E 接缝由显式 URL fragment 启用");
2870
+ assert.ok(clientSource.includes("Math.min(requestedModelDelay, 1_000)"), "detail 渐进 E2E 延迟上限为 1 秒");
2871
+ assert.ok(clientSource.includes("if (!subagent && e2eModelDelayMs === 0) subscribeModelDirectory(id, detail);"), "fixture 模式仅绕开 model directory 抢先初值");
2872
+ assert.ok(clientSource.includes("delayedModelCall(() => api.models({ sessionId: id }))"), "detail fixture 延迟正式 models RPC,不伪造 model response");
2873
+ assert.ok(clientSource.includes("Promise.resolve().then(call)"), "detail fixture 保留 models RPC 同步异常的 Promise catch 降级语义");
2874
+ assert.ok(clientSource.includes("e2eModelDelayWaiters.clear()"), "卸载时取消并结清 detail fixture 延迟,不残留 timer/promise");
2875
+ assert.ok(bundle.includes("promise.then(queueSync, queueSync)"), "补充数据逐个完成即重绘(先就绪先显示)");
2876
+ assert.ok(bundle.includes("LOAD_CONCURRENCY"), "冷数据读取经并发池限制慢网挤占");
2877
+ assert.ok(bundle.includes("session.open"), "运行卡通过 native session open hydrate 非当前会话");
2878
+ assert.ok(bundle.includes("sessionOpenLoads"), "session.open 请求与 cold history fallback 不重复");
2879
+ // R-01-012/AC-16 模型目录订阅:store 推送更新、只订阅不 load、随可见性/卸载清理
2880
+ assert.ok(bundle.includes('ctx.get("modelDirectories")'), "模型实时选择来自原生 modelDirectories 服务(可选软依赖)");
2881
+ assert.ok(bundle.includes("directory.store.subscribe"), "订阅目录 store 推送模型选择变更");
2882
+ assert.ok(!bundle.includes("directory.load("), "不调用目录 load(),不与 select() 竞争 generation(C-024)");
2883
+ assert.ok(bundle.includes("pruneSubscriptions(modelDirectorySubs, visibleIds)"), "模型目录订阅随可见性先 unsubscribe 再除名");
2884
+ assert.ok(bundle.includes("pruneSubscriptions(modelDirectorySubs, new Set())"), "卸载时模型目录订阅整体退订归零");
2885
+ assert.ok(bundle.includes("detail.modelLive"), "目录订阅已产值时晚到的一次性 RPC 不回写旧值");
2886
+ assert.ok(!bundle.includes("events.mux"), "不常驻全局 mux,当前会话使用原生 session subscribe");
2887
+ assert.ok(
2888
+ bundle.indexOf('makeEl("div", "dap-track")') < bundle.indexOf('makeEl("div", "dap-token-stats")'),
2889
+ "token 统计骨架位于进度条骨架之后",
2890
+ );
2891
+ assert.ok(
2892
+ bundle.indexOf('statsRow.append(makeEl("span", "dap-token-main"), makeEl("span", "dap-token-time"))') > -1,
2893
+ "统计行先左列后时长,时长段恒在行尾(R-01-009/AC-05)",
2894
+ );
2895
+ assert.ok(!bundle.includes("思考中"), "活动卡 bundle 不再渲染独立思考中动作行");
2896
+ assert.ok(!bundle.includes("dap-status"), "活动卡 bundle 不再保留独立状态行骨架");
2897
+ assert.ok(!bundle.includes("statusLine"), "活动卡 bundle 不再依赖 statusLine 状态文案");
2898
+
2899
+ // R-02-001/AC-01
2900
+ // R-02-001/AC-02
2901
+ // R-02-004/AC-01
2902
+ // R-02-004/AC-02
2903
+ // ---- 无第三方状态路由,轮内状态不引入新的 HTTP 轮询 ----
2904
+ // 校验的是运行时引用:不得注入第三方插件服务、不得请求其状态路由、不得发起状态轮询
2905
+ // (文档注释中的上位名提及属来源声明,不构成依赖)。
2906
+ assert.ok(bundle.includes('id: "dsh-activity-pane"'), "bundle 含插件 id");
2907
+ assert.ok(bundle.includes("ctx.get(\"sessions\")"), "数据来自 DSH 原生 sessions 服务");
2908
+ assert.ok(bundle.includes("ctx.get(\"workspaces\")"), "数据来自 DSH 原生 workspaces 服务");
2909
+ assert.ok(
2910
+ !bundle.includes("ctx.get(\"dsh-answer-pet\")"),
2911
+ "不得以服务方式依赖第三方宠物插件",
2912
+ );
2913
+ // R-02-004/AC-02(演进,C-030):完成确认写回是唯一 HTTP 请求——自家宿主侧路由、
2914
+ // 用户操作触发的一次性 POST,非状态轮询;轮内状态仍只来自原生订阅推送与 SSE 推送。
2915
+ // fetch 唯一性由下条断言钉住:轮询需要重复请求,唯一 fetch 即排除轮询形态。
2916
+ assert.ok(
2917
+ (bundle.match(/fetch\(/g) ?? []).length === 1 && bundle.includes("fetch(`${ACK_API_BASE}/ack`"),
2918
+ "唯一的 fetch 调用是完成确认写回,指向宿主侧自家路由(R-01-002/AC-10、C-030)",
2919
+ );
2920
+
2921
+ // ---- R-02-003/AC-02 卸载时清理注入元素、样式与监听 ----
2922
+ assert.ok(bundle.includes("style.remove()"), "卸载移除注入样式");
2923
+ assert.ok(bundle.includes("bodyObserver?.disconnect()"), "卸载断开 body 观察者");
2924
+ assert.ok(bundle.includes("disconnectAncestorObservers"), "卸载断开祖先链观察者");
2925
+ assert.ok(bundle.includes("centerObserver?.disconnect()"), "卸载断开 center 结构观察者");
2926
+ assert.ok(bundle.includes("conversationObserver?.disconnect()"), "卸载断开 conversation 观察者");
2927
+ assert.ok(bundle.includes("removeEventListener"), "卸载移除事件监听");
2928
+ const loadMaps = [new Map([["a", 1], ["b", 2]]), new Map([["b", 3], ["c", 4]])];
2929
+ pruneInvisibleEntries(loadMaps, new Set(["b"]));
2930
+ assert.deepEqual([...loadMaps[0].keys()], ["b"], "可见性清理保留可见记账");
2931
+ assert.deepEqual([...loadMaps[1].keys()], ["b"], "loads 记账与详情同生命周期清理");
2932
+ assert.ok(bundle.includes("pruneInvisibleEntries"), "可见性清理统一经 pruneInvisibleEntries");
2933
+
2934
+ // R-01-012/AC-05
2935
+ // R-01-012/AC-06
2936
+ // R-01-012/AC-07
2937
+ // R-01-012/AC-08
2938
+ // ---- 回归锚点:动作图标、错误呈现与标题摘要分隔符 ----
2939
+ assert.ok(bundle.includes("createUserIcon"), "用户工作项使用人物 SVG 图标");
2940
+ assert.ok(bundle.includes('item.kind === "user"'), "用户图标按工作项语义固定选择");
2941
+ assert.ok(bundle.includes("createBashIcon"), "Bash 使用稳定的 canonical 图标");
2942
+ assert.ok(bundle.includes("item.fold === true"), "折叠组行图标按组类别固定选择,不随成员状态或展开态漂移(R-01-012/AC-03、AC-08)");
2943
+ assert.ok(bundle.includes('M11.4818 5.57813'), "Bash fallback 使用 DSH IconApiOutline14 路径");
2944
+ // R-01-012/AC-03
2945
+ // ---- 回归锚点:非当前会话 fallback 与主会话网页同一 canonical 图标表,选中/非选中态不漂移 ----
2946
+ assert.ok(bundle.includes("fallbackTraceIcon"), "fallback 图标统一经 canonical 图标工厂");
2947
+ assert.ok(bundle.includes("TOOL_ICON_FACTORIES"), "fallback 图标按 toolName 镜像原生 classifyTool 分类");
2948
+ assert.ok(!bundle.includes('assistant: "✦"'), "fallback 不再使用字符画图标");
2949
+ assert.ok(bundle.includes("createSearchIcon") && bundle.includes("M11.894845 6.647401"), "grep/glob fallback 使用 DSH IconSearchOutline16 路径");
2950
+ assert.ok(bundle.includes("createGlobeIcon") && bundle.includes("M7.00018 0.353516"), "web_search fallback 使用 DSH IconGlobeOutline14 路径");
2951
+ assert.ok(bundle.includes("createBrowseIcon") && bundle.includes("M11.2426 4.80473"), "read/web_fetch fallback 使用 DSH IconBrowseOutline16 路径");
2952
+ assert.ok(bundle.includes("createEditIcon") && bundle.includes("M9.94076 1.34942"), "write/edit fallback 使用 DSH IconEditOutline16 路径");
2953
+ assert.ok(bundle.includes("createThinkIcon"), "Think fallback 使用 DSH IconThinkOutline14 图标");
2954
+ assert.ok(bundle.includes('item.kind === "context" ? "" : item.text'), "context 注入内容原文不作为摘要兜底上卡(R-01-012/AC-03)");
2955
+ // 原生行匹配/图标克隆/图标缓存机器已随逐项镜像移除(C-017),由上方负向守卫覆盖。
2956
+ assert.ok(!bundle.includes('disclosure?.querySelector("svg")'), "不得直接复制 disclosure 内第一个 SVG");
2957
+ assert.ok(
2958
+ bundle.includes('[data-dsh-activity-pane] .dap-trace-item[data-status="error"] .dap-trace-icon') &&
2959
+ bundle.includes('data-status="error"] .dap-trace-label'),
2960
+ "错误分组行经 CSS 整体染色:图标、组标题与摘要跟随错误色(R-01-012/AC-06)",
2961
+ );
2962
+ assert.ok(bundle.includes("dap-trace-separator"), "标题与摘要之间有圆点分隔符");
2963
+ assert.ok(bundle.includes('main.append(makeEl("span", "dap-trace-separator"))'), "仅在标题和摘要同时存在时插入分隔符");
2964
+ assert.ok(bundle.includes('[data-status="error"] .dap-trace-icon'), "错误时动作图标染红");
2965
+ assert.ok(bundle.includes('[data-status="error"] .dap-trace-label'), "错误时动作标题染红");
2966
+ assert.ok(bundle.includes('[data-status="error"] .dap-trace-summary'), "错误时动作摘要染红");
2967
+ assert.ok(bundle.includes('summary.dataset.follow !== follow'), "流式摘要记录跟随态以区分钉行尾与回行首(R-01-012/AC-03)");
2968
+ assert.ok(bundle.includes('summary.scrollLeft = follow === "end" ? summary.scrollWidth : 0'), "running 摘要钉行尾跟随流式输出、结束后回行首(R-01-012/AC-03)");
2969
+ assert.ok(bundle.includes('.dap-trace-summary[data-follow="end"] { text-overflow: clip; }'), "钉行尾跟随时摘要不渲染省略号(镜像原生 ReasoningRow follow-end)");
2970
+
2971
+ // R-01-009/AC-09
2972
+ // ---- 回归锚点:时间线几何/状态动画(R-01-009/AC-08、AC-09 呈现细节)----
2973
+ // 轨道列从卡片内容左边起步:时间线节点与 7px 标题点使用同尺寸承载盒(left:0、圆心 x=3.5),
2974
+ // 与 left 3px 的 1px 竖线保持同一光栅相位;承载盒的 1px border 透明,7px border-box 内仅由
2975
+ // padding-box 背景显示 5px 实心核,半透明 drop-shadow 从小圆核的 alpha 轮廓生成,不显露承载盒;
2976
+ // 竖线贯穿首项圆点并向上引出;
2977
+ // 竖线为容器 ::before 单元素整条绘制(零拼接,对齐层级连接线 .dap-conn-track 原则)——
2978
+ // 逐项分段曾在接缝处双线叠加、半透明相加成亮带(T-069);
2979
+ // reduced-motion 只关闭宽度 transition,不关闭 answer-pet 同款状态脉冲/进度条纹。
2980
+ assert.ok(bundle.includes(".dap-trace::before,\n[data-dsh-activity-pane] .dap-subtrace::before"), "时间线竖线为容器级单元素整条绘制(零拼接接缝,T-069)");
2981
+ assert.ok(!bundle.includes(".dap-trace-item::after"), "时间线不再逐项分段自绘竖线(接缝叠加成亮带,T-069)");
2982
+ assert.ok(!bundle.includes("bottom: -8px"), "逐项竖线下探 8px 的拼接几何已移除(T-069)");
2983
+ assert.ok(bundle.includes(".dap-trace:has(> :only-child)::before"), "单项时间线(含加载行)不画竖线(沿用原末项不画线语义)");
2984
+ assert.ok(bundle.includes("margin: 1px 0 2px;"), "时间线整体与卡片内容左边界对齐");
2985
+ assert.ok(bundle.includes("left: 3px; top: 0; bottom: 7px"), "1px 竖线(整数位)与 7px 时间线承载盒、7px 标题点严格同圆心 x=3.5,终点没入最末圆点");
2986
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace::before"), "浅色主题时间线竖线色覆盖迁移到容器级规则(T-069)");
2987
+ assert.ok(bundle.includes("color: #c7ced9; font-size: 10px; line-height: 14px;"), "工作项文字恢复原有 10px/14px 尺度");
2988
+ assert.ok(bundle.includes("width: 14px; height: 14px;") && !bundle.includes("width: 14px; height: 14px; padding: 1px;"), "工作项图标容器为真实 14px 盒、无占位的 padding 环(R-01-012、C-019)");
2989
+ assert.ok(bundle.includes("width: fit-content;\n max-width: 100%;") && bundle.includes("linear-gradient(rgba(139, 152, 165, .55), rgba(139, 152, 165, .55))") && !bundle.includes("repeating-linear-gradient(90deg, rgba(139, 152, 165, .55)") && !bundle.includes("rgba(88, 201, 143, .1)") && !bundle.includes("border-bottom: 1px dashed"), "时间线用户消息行下划线改实线且宽度仅为图标+文字内容宽(背景渐变绘制、不占 14px 行高),无整行虚线与浅绿平底残留(R-01-012/AC-05、C-022)");
2990
+ assert.ok(bundle.includes("linear-gradient(var(--dsw-alias-label-tertiary, rgb(129, 133, 140)), var(--dsw-alias-label-tertiary, rgb(129, 133, 140)))"), "浅色主题用户行下划线同改实线(alias 变量色)(R-01-012/AC-05)");
2991
+ assert.ok(bundle.includes("anchor: true") && bundle.includes("isUserChatNode"), "指令锚行由核心派生并前置返回,找锚只做廉价 kind 检查(R-01-012/AC-12)");
2992
+ assert.ok(bundle.includes("display: block; width: 12px; height: 12px;"), "工作项 SVG 保持 12px");
2993
+ assert.ok(bundle.includes('svg.setAttribute("width", String(width))'), "canonical 图标经 createInlineIcon 统一写入尺寸(默认 12px)");
2994
+ assert.ok(bundle.includes("left: 0; top: 3px;\n width: 7px; height: 7px;"), "时间线节点恢复 7px 同心承载盒,圆心保持 x=3.5/y=6.5(R-01-009/AC-09、C-062)");
2995
+ assert.ok(bundle.includes("box-sizing: border-box; border: 1px solid transparent; border-radius: 50%;"), "7px 承载盒边界透明,仅为 5px 圆核提供同相位定位(R-01-009/AC-09、C-063)");
2996
+ assert.ok(bundle.includes("background-color: #778394; background-clip: padding-box;"), "时间线圆点使用实体背景与 padding-box 在透明承载盒内裁出 5px 圆核(R-01-009/AC-09)");
2997
+ assert.ok(bundle.includes("background-color: #65a0ff;") && bundle.includes("background-color: #58c98f;") && bundle.includes("background-color: #f06a72;") && bundle.includes("background-color: #f5a524;"), "全部状态仅覆盖 background-color,不以 background shorthand 重置 padding-box 裁剪(R-01-009/AC-09、C-063)");
2998
+ assert.ok(!bundle.includes("radial-gradient(circle,"), "时间线圆点不再使用硬停色 radial-gradient(R-01-009/AC-09、C-036、C-061、C-062、C-063)");
2999
+ assert.ok(!bundle.includes("box-shadow: 0 0 0 1px rgba(119, 131, 148, .14);"), "普通节点不再按 7px 承载盒绘制可见 box-shadow(R-01-009/AC-09、C-063)");
3000
+ assert.ok(!bundle.includes("box-shadow: 0 0 0 1px rgba(101,160,255,.16), 0 0 6px rgba(101,160,255,.65);"), "running 节点不再按 7px 承载盒绘制外围与光晕(R-01-009/AC-09、C-063)");
3001
+ assert.ok(bundle.includes("filter: drop-shadow(0 0 1px rgba(119,131,148,.32));"), "普通节点光晕从 5px 圆核 alpha 轮廓生成(R-01-009/AC-09、C-063)");
3002
+ assert.ok(bundle.includes("filter: drop-shadow(0 0 1px rgba(101,160,255,.32)) drop-shadow(0 0 3px rgba(101,160,255,.65));"), "running 节点保留基于 5px 圆核的半透明外围与状态光晕(R-01-009/AC-09、C-063)");
3003
+ assert.ok(bundle.includes(".dap-dot {\n width: 7px; height: 7px;"), "标题点保持可见 7px,时间线仅显示 5px 圆核形成尺寸层级");
3004
+ assert.ok(bundle.includes("padding-left: 14px"), "时间线文字轨道保持 14px 内缩");
3005
+ assert.ok(bundle.includes(".dap-subtrace {\n position: relative; /* 容器级整条竖线的定位基准 */\n min-width: 0;"), "子代理容器不再 padding/border/overflow 包裹(不裁切圆点),并为容器级整条竖线提供定位基准(T-069)");
3006
+ assert.ok(bundle.includes(".dap-fill { transition: none; }"), "降低动效设置不关闭状态动画(对齐 answer-pet)");
3007
+ // R-01-009/AC-08:进度条仅存于运行卡骨架,条纹挂在 .dap-fill 基础规则上——
3008
+ // 会话运行全程(含工具/思考阶段与委托周期母会话)持续向右滚动,不再经流式阶段门控。
3009
+ assert.ok(bundle.includes(".dap-fill {\n position: absolute; inset: 0 auto 0 0; width: 0%;\n border-radius: 6px;\n background: repeating-linear-gradient(90deg, #58c98f 0 10px, #3fbf86 10px 20px);\n background-size: 200% 100%;"), "进度条基础规则携带条纹渐变,运行全程呈现(R-01-009/AC-08)");
3010
+ assert.ok(bundle.includes("animation: dap-stripes 0.8s linear infinite;"), "进度条条纹持续向右滚动动画(R-01-009/AC-08)");
3011
+ assert.ok(!bundle.includes("data-streaming"), "条纹不再经 data-streaming 流式门控(R-01-009/AC-08)");
3012
+ assert.ok(!bundle.includes("entry.streaming"), "streaming 派生字段随条纹门控移除(R-01-009/AC-08)");
3013
+ assert.ok(bundle.indexOf('const track = makeEl("div", "dap-track");') > bundle.indexOf('return [head, row, makeEl("div", "dap-trace"), noteRow];'), "进度条骨架仅属运行卡,非运行卡不呈现条纹(R-01-009/AC-08)");
3014
+ assert.ok(bundle.includes("animation: dap-pulse 1.15s ease-in-out infinite"), "运行中蓝色节点保留脉冲动画");
3015
+ assert.ok(bundle.includes("dataset.traceKey"), "同一流程节点复用 DOM,脉冲动画不因时钟刷新重置");
3016
+ assert.ok(!bundle.includes(".dap-trace-item[data-status=\"running\"]::before {\n animation: none !important;"), "降低动效设置不关闭运行点脉冲");
3017
+
3018
+ // R-01-013/AC-09
3019
+ // 最近历史卡标题降为常规字重(不加粗),活动卡标题保持加粗。
3020
+ assert.ok(bundle.includes('[data-kind="recent"] .dap-title {\n font-weight: 400;'), "最近历史卡标题使用常规字重(不加粗)");
3021
+ assert.ok(bundle.includes("white-space: nowrap; font-size: 12px; line-height: 16px; font-weight: 700;"), "活动卡标题保持加粗 700");
3022
+
3023
+ // R-01-013/AC-10
3024
+ // 最近历史卡整体不透明度低于活动卡,弱化历史区视觉强调。
3025
+ assert.ok(bundle.includes("opacity: 0.8;"), "最近历史卡整体不透明度降为 0.8");
3026
+
3027
+ // R-01-013/AC-11
3028
+ // 最近卡底色与描边保持与窗格底色可分辨、且暗于活动卡:深色中间档底色 + 弱描边;浅色压暗底色 + 弱描边。
3029
+ assert.ok(bundle.includes("background: rgba(26, 28, 34, 0.92);\n border-color: rgba(255, 255, 255, 0.08);"), "深色最近卡底色为暗于活动卡的中间档并带弱描边(R-01-013/AC-11)");
3030
+ assert.ok(
3031
+ bundle.includes('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="recent"] {') && bundle.includes("background: rgb(243, 244, 246);\n border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));"),
3032
+ "浅色最近卡底色暗于活动卡纯白并带弱描边(R-01-013/AC-11)",
3033
+ );
3034
+
3035
+ // R-01-010/AC-08、AC-09(bundle 契约)
3036
+ // 历史区时间精化链路进入 bundle:turn/end 提取、turnEnds 注入 buildRecent、渲染读 activityAt。
3037
+ assert.ok(bundle.includes("lastTurnEndFromEvents") && bundle.includes("lastTurnEndFromTimings"), "回合结束时刻提取进入 bundle(R-01-010/AC-08)");
3038
+ assert.ok(bundle.includes("delegatingIds, turnEnds)"), "turnEnds 注入 buildRecent(R-01-010/AC-09)");
3039
+ assert.ok(bundle.includes("fmtRecentTime(entry.activityAt)"), "最近卡渲染读取精化后的 activityAt(R-01-010/AC-08)");
3040
+
3041
+ assert.ok(
3042
+ bundle.indexOf('[data-kind="recent"] {') < bundle.indexOf(".dap-card[data-opening]"),
3043
+ "recent 淡化规则位于 opening 脉冲规则之前,等待态由脉冲接管(R-01-013/AC-10 边界)",
3044
+ );
3045
+
3046
+ // R-01-008/AC-04
3047
+ // 移动端浮动开关固定在会话头部左上角、左边栏切换按钮(28px @ left:8px; top:12px)右侧,文案「活动」。
3048
+ assert.ok(bundle.includes("position: fixed; top: 12px; left: 44px;"), "浮动开关位于左上角左边栏切换按钮右侧(left:44px)");
3049
+ assert.ok(!bundle.includes(".dap-toggle {\n position: fixed; top: 12px; right: 12px;"), "浮动开关不再位于右上角");
3050
+ assert.ok(bundle.includes('"<span>活动</span><span class=\\"dap-toggle-count\\"></span>"'), "浮动开关文案为「活动」并保留计数徽标");
3051
+
3052
+ // R-01-008/AC-05
3053
+ // 抽屉打开时浮动开关隐藏,关闭后恢复;显隐随 togglePane 单点同步。
3054
+ assert.ok(bundle.includes(".dap-toggle[data-drawer-open] { display: none; }"), "抽屉打开时浮动开关隐藏");
3055
+ assert.ok(bundle.includes('toggle.toggleAttribute("data-drawer-open", open)'), "开关显隐由 togglePane 单点同步");
3056
+
3057
+ // R-01-002/AC-01、AC-02、AC-09、AC-13 等待三类胶囊(C-043):末行首行为「圆底类型图标 + 类型
3058
+ // 文字」胶囊(阻塞金/完成绿/错误红),胶囊与正文同频同相脉冲;「移入历史」按钮不闪,标题圆点静止。
3059
+ assert.ok(
3060
+ bundle.includes('capsule.append(makeEl("span", "dap-capsule-icon"), makeEl("span", "dap-capsule-text"))'),
3061
+ "等待胶囊为图标+文本双段结构(R-01-002/AC-01、AC-02、AC-09、AC-13)",
3062
+ );
3063
+ assert.ok(bundle.includes("function createCapsuleIcon(kind)"), "胶囊类型图标工厂存在(对勾/文档/问号气泡/已完成对勾/错误感叹号)");
3064
+ assert.ok(
3065
+ bundle.includes("CAPSULE_ICON_KINDS.has(entry.pendingKind)"),
3066
+ "胶囊图标归属由 waitClass/pendingKind 结构化字段驱动,未知种类不给图标(R-01-002/AC-01、AC-02)",
3067
+ );
3068
+ assert.ok(
3069
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"] .dap-foot :is(.dap-capsule, .dap-note)'),
3070
+ "三类等待卡的胶囊与正文脉冲由同一 data-wait 作用域规则驱动(R-01-002/AC-08,C-043)",
3071
+ );
3072
+ // 语义化提问列表(R-01-002/AC-09,C-064):单问 ul、多问 ol,动态文本不解析 HTML。
3073
+ assert.ok(
3074
+ bundle.includes('const list = makeEl(ordered ? "ol" : "ul", "dap-question-list")') &&
3075
+ bundle.includes("itemEl.value = item.index") &&
3076
+ bundle.includes("itemEl.textContent = item.text"),
3077
+ "待回复正文以 ul/ol/li 语义元素渲染,编号保留原始位置且动态文字只写 textContent(R-01-002/AC-09,C-064)",
3078
+ );
3079
+ assert.ok(
3080
+ bundle.includes(".dap-question-list") && bundle.includes(".dap-question-ellipsis { list-style: none; }"),
3081
+ "提问列表使用卡片内缩进并隐藏省略项 marker(R-01-002/AC-09,C-064)",
3082
+ );
3083
+ assert.ok(!bundle.includes("dap-badge-flash") && !bundle.includes("awaitBadgeFlash"), "标题区徽标闪烁机制整体移除:闪烁不再出现在卡片标题行(R-01-002/AC-08,C-040)");
3084
+ assert.ok(!bundle.includes("prevWait !== entry.waitClass"), "跨类转换已收敛到等待队列全局同步,不再单卡重复重启(R-01-002/AC-07、AC-08,C-065)");
3085
+ assert.ok(!bundle.includes('dot.style.animation = "none"'), "相位重启目标随标题圆点静止而移除,重启只作用于末行元素(R-01-002/AC-08,C-040)");
3086
+ assert.ok(
3087
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"] .dap-dot {\n animation: none;\n background: var(--dap-wait-color, #58c98f);'),
3088
+ "等待卡标题状态点静止不闪、色相随 --dap-wait-color 类别变量(R-01-002/AC-08,C-043)",
3089
+ );
3090
+ assert.ok(
3091
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="blocked"] {\n --dap-wait-color: #f5c542;') &&
3092
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="error"] {\n --dap-wait-color: #f06a72;'),
3093
+ "阻塞等待金 / 错误提醒红类别色相经 --dap-wait-color 单点定义(R-01-002/AC-04、AC-13,C-043)",
3094
+ );
3095
+ assert.equal(awaitBadgeTone([{ kind: "awaiting", waitClass: "blocked" }]), "blocked", "tone 判定收敛到核心纯函数单点(R-01-002/AC-06)");
3096
+ assert.ok(bundle.includes('rec.el.setAttribute("data-wait", entry.waitClass)'), "等待类别经 data-wait 属性承载(R-01-002/AC-08)");
3097
+ assert.ok(bundle.includes('entry.noteText ?? ""'), "非提问末行正文与提问回落文案仍由核心单点派生(R-01-002/AC-09)");
3098
+ // 缺陷回归(C-040):快照路径的时间线在 buildEntries 后才完成 memo,待回复卡静止后
3099
+ // 常无下一帧;时间线就绪后必须在同一帧补全结构化 questionPreview。
3100
+ assert.ok(
3101
+ bundle.includes('timelineQuestionPreview(entry.timeline)') &&
3102
+ bundle.includes("entry.questionPreview = question"),
3103
+ "待回复卡在时间线就绪后补全结构化提问预览,不依赖下一帧重绘(R-01-002/AC-09 时序缺陷回归)",
3104
+ );
3105
+ // R-01-002/AC-03、AC-04 完成提醒卡绿色成功卡面(C-040):深色静态暗绿底+绿描边光晕,浅色取 success 别名。
3106
+ assert.ok(
3107
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="done"] {\n --dap-wait-color: #58c98f;\n border-color: color-mix(in srgb, #58c98f 55%, transparent);') &&
3108
+ bundle.includes("background: rgba(32, 41, 35, 0.97);"),
3109
+ "完成提醒卡为暗绿底色与绿描边光晕,强度与其它等待卡一致(R-01-002/AC-03、AC-04)",
3110
+ );
3111
+ assert.ok(
3112
+ bundle.includes('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="done"] {\n background: var(--dsw-alias-state-success-tertiary, rgb(230, 250, 237));\n}'),
3113
+ "浅色主题完成提醒卡取宿主 success 三级背景别名(R-01-002/AC-04)",
3114
+ );
3115
+ assert.ok(
3116
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="error"] {\n --dap-wait-color: #f06a72;'),
3117
+ "错误提醒卡为红色调卡面(与时间线错误红同源)(R-01-002/AC-13,C-043)",
3118
+ );
3119
+ assert.ok(
3120
+ bundle.includes('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-wait="error"] {\n background: rgb(252, 233, 234);\n}'),
3121
+ "浅色主题错误提醒卡取淡红错误底(R-01-002/AC-13,C-043)",
3122
+ );
3123
+ assert.ok(
3124
+ !bundle.includes('.dap-card[data-kind="awaiting"][data-wait="done"] .dap-badge') &&
3125
+ bundle.includes('[data-dsh-activity-pane] .dap-capsule {\n flex: none; display: inline-flex; align-items: center; gap: 4px;') &&
3126
+ bundle.includes('[data-dsh-activity-pane] .dap-capsule-icon {'),
3127
+ "末行徽标结构整体迁移为胶囊:无行尾徽标隐藏规则,胶囊双段规则在位(R-01-002/AC-08,C-043)",
3128
+ );
3129
+ // R-01-002/AC-06 计数徽标底色跟随等待构成(C-040、C-043):三处镜像面 tone=done 取绿、tone=error 取红。
3130
+ assert.ok(
3131
+ bundle.includes("awaitBadgeTone(active)") &&
3132
+ bundle.includes("\n.dap-toggle[data-awaiting][data-tone=\"done\"] .dap-toggle-count {") &&
3133
+ bundle.includes("\n.dap-toggle[data-awaiting][data-tone=\"error\"] .dap-toggle-count {") &&
3134
+ !bundle.includes("[data-dsh-activity-pane] .dap-toggle[data-awaiting][data-tone="),
3135
+ "三处数量徽标按等待构成写入 tone 属性,浮动移动开关以自身作用域接入 done 绿/error 红变体(R-01-002/AC-06)",
3136
+ );
3137
+ assert.ok(
3138
+ bundle.includes('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-count[data-awaiting][data-tone="done"],') &&
3139
+ bundle.includes("var(--dsw-alias-state-success-tertiary, rgb(230, 250, 237))"),
3140
+ "浅色主题徽标 done 色调取 success 别名(R-01-002/AC-06)",
3141
+ );
3142
+ assert.ok(
3143
+ bundle.includes('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-count[data-awaiting][data-tone="error"],') &&
3144
+ bundle.includes("rgb(252, 233, 234)"),
3145
+ "浅色主题徽标 error 色调取淡红错误底(R-01-002/AC-06,C-043)",
3146
+ );
3147
+ // R-01-001/AC-04、AC-05、AC-06 徽标 n/m 计数;R-01-002/AC-06、AC-07 固定同步脉冲
3148
+ assert.ok(bundle.includes("text: `${waiting}/${total}`,"), "数量徽标以 n/m 分数形式呈现");
3149
+ assert.ok(bundle.includes("awaitBadgeStats(active)"), "数量统计由核心纯函数单点派生");
3150
+ assert.ok(!bundle.includes("awaitPulsePeriod") && !bundle.includes("--dap-await-period"), "数量徽标不再按等待占比派生或写入脉冲周期(R-01-002/AC-07)");
3151
+ assert.ok(
3152
+ bundle.includes("[data-dsh-activity-pane] .dap-count[data-awaiting] {\n /* 底色/透明度与等待卡完全一致、无描边与外环") &&
3153
+ bundle.includes("[data-dsh-activity-pane] .dap-rail-count[data-awaiting] {\n background: rgba(46, 42, 26, 0.97);") &&
3154
+ bundle.includes(".dap-toggle[data-awaiting] .dap-toggle-count {\n background: rgba(46, 42, 26, 0.97);"),
3155
+ "列头、窄条与移动开关的阻塞等待徽标底色/透明度均与等待卡一致、无描边与外环(R-01-002/AC-06)",
3156
+ );
3157
+ assert.equal(
3158
+ (bundle.match(/data-blocked/g) ?? []).length,
3159
+ 0,
3160
+ "脉冲门控不再区分阻塞等待:data-blocked 属性与选择器整体移除,任一等待行动即脉冲(R-01-002/AC-06,C-037)",
3161
+ );
3162
+ assert.ok(
3163
+ !bundle.includes("box-shadow: 0 0 0 1px color-mix(in srgb, #e8a33d 35%, transparent);\n animation: dap-await-pulse"),
3164
+ "三处镜像面等待态均无 1px 外环(R-01-002/AC-06)",
3165
+ );
3166
+ assert.ok(
3167
+ bundle.includes("@keyframes dap-await-pulse { 0%,100% { filter: brightness(1); } 50% { filter: brightness(1.3); } }"),
3168
+ "脉冲为亮度呼吸而非整体不透明度:底色全程可见不透底(R-01-002/AC-06 东家视觉反馈)",
3169
+ );
3170
+ assert.ok(!bundle.includes("linear-gradient(180deg, #ffb4b4, #f06a72)") && !bundle.includes("#2a1012"), "数量徽标不再使用红色渐变旧配色(时间线错误态红色不受影响)");
3171
+ assert.equal(
3172
+ (bundle.match(/animation: dap-await-pulse 1\.2s ease-in-out infinite/g) ?? []).length,
3173
+ 3,
3174
+ "列头/窄条/移动开关三处数量胶囊统一使用固定 1.2s 脉冲(R-01-002/AC-07)",
3175
+ );
3176
+ assert.ok(
3177
+ bundle.includes("function syncAwaitPulse(nodes)") && bundle.includes("pulseSignature !== nextPulseSignature") &&
3178
+ bundle.includes('pulseSurface = desktopQuery.matches') && bundle.includes("pulseSignature = nextPulseSignature;"),
3179
+ "等待集合、类别或可见表面变化时统一重启,并在渲染成功后提交签名(R-01-002/AC-07、AC-08)",
3180
+ );
3181
+ assert.ok(
3182
+ bundle.includes(
3183
+ "body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-rail-count[data-awaiting],\nbody:not([data-ds-dark-theme]) .dap-toggle[data-awaiting] .dap-toggle-count {\n background: rgb(253, 244, 208);\n}",
3184
+ ),
3185
+ "浅色主题数量徽标覆盖声明体完整:仅等待卡浅色金色背景、无描边与外环(防空规则回归)",
3186
+ );
3187
+ assert.ok(bundle.includes("`${total} 个活动会话,${blocked} 个等待你答复`"), "数量徽标 aria-label 携带阻塞等待计数说明(R-01-002/AC-06)");
3188
+ assert.ok(bundle.includes("border-radius: 999px; padding: 1px 8px 1px 3px;\n}\n/* 胶囊圆底类型图标"), "等待胶囊规则正确闭合,后续为圆底图标段(R-01-002/AC-04 结构回归防护)");
3189
+ assert.ok(
3190
+ bundle.includes("border-radius: 999px;\n padding: 0 7px;\n}\n[data-dsh-activity-pane] .dap-count[data-awaiting] {"),
3191
+ "数量徽标基态规则无描边、正确闭合,紧随其后为等待态变体(R-01-001/AC-04 结构回归防护)",
3192
+ );
3193
+ // CSS 模板结构完整:花括号配平,不错位吞并后续规则(R-01-002/AC-04 结构回归防护)。
3194
+ assert.equal(
3195
+ (bundle.match(/\{/g) ?? []).length,
3196
+ (bundle.match(/\}/g) ?? []).length,
3197
+ "bundle 花括号配平(CSS 模板不错位吞并后续规则)",
3198
+ );
3199
+
3200
+ // R-01-001/AC-04
3201
+ // 数量徽标紧跟标题文字(去掉 margin-left: auto),配色同样柔和化。
3202
+ assert.ok(bundle.includes('[data-dsh-activity-pane] .dap-count {\n flex: none;\n font-size: 10px;'), "数量徽标紧跟标题文字(不再 margin-left: auto)");
3203
+
3204
+ // R-01-003/AC-06、AC-07
3205
+ // 工作区徽标「文件夹图标+名称文本」双段:图标与左边栏工作区条目同源,字号不低于 10.5px。
3206
+ assert.ok(bundle.includes("createWorkspaceFolderIcon"), "工作区徽标使用与左边栏同源的 canonical 文件夹图标工厂(R-01-003/AC-06)");
3207
+ assert.ok(bundle.includes("M5.05582 0.518756L4.50669 0.86654"), "文件夹图标 path 与 dsh-client-ui-primitives IconFolderClose16 同源(R-01-003/AC-06)");
3208
+ assert.ok(bundle.includes('[data-dsh-activity-pane] .dap-workspace {\n width: fit-content; max-width: 100%; display: flex; align-items: center; gap: 3px;\n overflow: hidden;\n font-size: 10.5px; line-height: 14px;'), "工作区名称字号提升为 10.5px 且胶囊改「图标+文本」双段布局(R-01-003/AC-07)");
3209
+ assert.ok(bundle.includes("[data-dsh-activity-pane] .dap-workspace-icon { flex: none; display: inline-flex; }"), "工作区图标 flex:none 不被挤压截断(R-01-003/AC-06 结构回归防护)");
3210
+ assert.ok(bundle.includes(".dap-workspace-text {\n min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"), "省略号截断只作用于工作区名称文本段(R-01-003/AC-06 结构回归防护)");
3211
+ assert.ok(bundle.includes("restoreTextField(workspaceText, entry.workspaceTitle)"), "工作区名称只写入文本段,不覆盖图标(R-01-003/AC-06)");
3212
+ assert.ok(bundle.includes('workspace.append(workspaceIcon, makeEl("span", "dap-workspace-text"))'), "文件夹图标先于名称文本段加入胶囊(R-01-003/AC-06 顺序锚点)");
3213
+ assert.ok(bundle.includes("if (workspaceText !== null) restoreTextField(workspaceText, entry.workspaceTitle)"), "热装旧骨架无文本段时容空跳过,不中断渲染(R-01-003/AC-06 健壮性)");
3214
+
3215
+ // R-01-003/AC-08、AC-09、AC-10、AC-11、AC-12
3216
+ // 工作区徽标按身份派生基色、经同屏跨色区槽位消解后着色:渲染层写入 --dap-workspace-hue,
3217
+ // CSS 以 OKLCH 调色板色直接呈现文字,底色/描边在 OKLCH 空间同色相混合;胶囊几何与字号不变。
3218
+ assert.ok(bundle.includes("resolveWorkspaceHues(visibleEntries.map((entry) => entry.workspaceKey))"), "渲染层按同帧可见身份集合消解色相(R-01-003/AC-12)");
3219
+ assert.ok(bundle.includes("hueByWorkspace.get(entry.workspaceKey)"), "每张卡使用集合消解后的工作区色相(R-01-003/AC-08、AC-12)");
3220
+ assert.ok(bundle.includes('style.setProperty("--dap-workspace-hue"'), "渲染层把消解后色相写入徽标 --dap-workspace-hue(R-01-003/AC-08、AC-12)");
3221
+ assert.ok(bundle.includes('style.removeProperty("--dap-workspace-hue")'), "徽标隐藏时移除色相变量,不留陈旧着色(R-01-003/AC-08)");
3222
+ assert.ok(bundle.includes("--dap-workspace-color: oklch(0.78 0.16 var(--dap-workspace-hue, 235))"), "深色主题文字使用 OKLCH 高明度中高彩度调色板色(R-01-003/AC-11)");
3223
+ assert.ok(bundle.includes("color: var(--dap-workspace-color)"), "徽标文字直接使用调色板色、不混 currentColor(R-01-003/AC-11)");
3224
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 14%, transparent)"), "深色主题底色在 OKLCH 空间同色相铺底(R-01-003/AC-11)");
3225
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 34%, transparent)"), "深色主题描边在 OKLCH 空间同色相混合(R-01-003/AC-11)");
3226
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-workspace {"), "浅色主题单独校准徽标配色(R-01-003/AC-10、AC-11)");
3227
+ assert.ok(bundle.includes("--dap-workspace-color: oklch(0.48 0.15 var(--dap-workspace-hue, 235))"), "浅色主题文字使用 OKLCH 低明度中高彩度调色板色(R-01-003/AC-11)");
3228
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 10%, transparent)"), "浅色主题底色更轻(R-01-003/AC-11)");
3229
+ assert.ok(bundle.includes("color-mix(in oklch, var(--dap-workspace-color) 28%, transparent)"), "浅色主题描边(R-01-003/AC-11)");
3230
+ assert.ok(!bundle.includes("color-mix(in srgb, var(--dap-workspace-color) 92%, currentColor)"), "工作区文字不得再以 currentColor 冲淡调色板色(R-01-003/AC-11)");
3231
+
3232
+ // R-01-010/AC-01、R-01-010/AC-05
3233
+ // 两区分隔线上下各保留 10px 留白;历史区无内容时整段隐藏、分隔线不占位。
3234
+ assert.ok(bundle.includes("border-top: 1px solid color-mix(in srgb, currentColor 10%, transparent);\n padding: 10px 8px 0;\n margin-top: 10px;"), "分隔线上下各 10px 留白");
3235
+ assert.ok(bundle.includes(".dap-recent[hidden] { display: none; }"), "历史区无内容时整段隐藏(分隔线不占位)");
3236
+
3237
+ // R-01-010/AC-07
3238
+ // 活动区→历史区迁移动画:旧卡克隆 ghost FLIP 平移淡降 + 真卡淡入,transitionend 收口,reduced-motion 降级。
3239
+ assert.ok(bundle.includes("[data-dsh-activity-pane] > .dap-move-ghost"), "迁移 ghost 挂载于窗格内,卡片样式作用域生效(不虚框)");
3240
+ assert.ok(bundle.includes("renderedPane.appendChild(plan.ghost)"), "ghost 挂载于窗格元素内(absolute、窗格相对坐标)");
3241
+ assert.ok(bundle.includes("transition: transform 0.3s ease, width 0.3s ease, height 0.3s ease"), "ghost 平移同时形变至目标矩形(精准落位)");
3242
+ assert.ok(bundle.includes("opacity 0.1s ease 0.2s"), "ghost 到位后才淡出(不在飞行途中消失)");
3243
+ assert.ok(bundle.includes(".dap-move-in"), "目标最近卡迁移时淡入");
3244
+ assert.ok(bundle.includes('"transitionend"'), "ghost 生命周期由 transitionend 收口(不引入定时器)");
3245
+ assert.ok(bundle.includes('matchMedia?.("(prefers-reduced-motion: reduce)")'), "reduced-motion 时跳过迁移动画直接落位");
3246
+
3247
+ // R-01-010/AC-07(双向)
3248
+ // 历史区→活动区反向迁移:检测接线 prevRenderedRecentIds,ghost 源池为历史卡池、目标池为活动卡池。
3249
+ assert.ok(
3250
+ bundle.includes("...movedToActiveIds(prevRenderedRecentIds, active, recent).map((id) => ({ id, from: recentCardsById, to: cardsById }))"),
3251
+ "历史区→活动区迁移经 movedToActiveIds 检测,ghost 源/目标卡池反向接线",
3252
+ );
3253
+ assert.ok(bundle.includes("const target = plan.to.get(plan.id)?.el;"), "ghost 目标矩形按迁移方向从目标卡池量取(双向共用)");
3254
+ assert.ok(
3255
+ bundle.includes("prevRenderedRecentIds = new Set(recent.map((entry) => String(entry.id)))"),
3256
+ "上一帧历史区 id 集合随签名提交记账(反向迁移检测前提)",
3257
+ );
3258
+
3259
+ // R-01-010/AC-10 受影响卡片 FLIP 过渡
3260
+ // 迁移帧内位置变化的其它卡片(含历史区段头)以反向位移 + dap-shift 过渡平滑归位,不瞬间跳变。
3261
+ assert.ok(
3262
+ bundle.includes("[data-dsh-activity-pane] .dap-shift {\n transition: transform 0.3s ease;\n}"),
3263
+ "受影响卡片经 dap-shift 获得 transform 过渡(与 ghost 同时长同缓动)",
3264
+ );
3265
+ assert.ok(
3266
+ bundle.includes("const shiftRects = movePlans.length > 0 ? snapshotShiftRects() : null;"),
3267
+ "仅迁移帧量取受影响卡片矩形;reduced-motion 下 prepareMoveGhosts 返回空、FLIP 整体跳过",
3268
+ );
3269
+ assert.ok(bundle.includes('renderedPane?.querySelector(".dap-recent-head")'), "历史区段头一并纳入 FLIP 量取(段头不瞬间跳变)");
3270
+ assert.ok(bundle.includes("void el.offsetWidth;"), "反向位移先无过渡落位、reflow 后挂过渡类归零(FLIP 标准序)");
3271
+ assert.ok(
3272
+ bundle.includes('if (event.target !== el || event.propertyName !== "transform") return;'),
3273
+ "transitionend 冒泡隔离:子元素过渡(进度条 width)不提前收口,只收口本元素 transform 过渡",
3274
+ );
3275
+ assert.ok(
3276
+ bundle.includes('el.addEventListener("transitionend", cleanup)') && !bundle.includes('"transitionend", cleanup, { once: true }'),
3277
+ "平移收口监听不用 once(once 会被冒泡事件空耗),命中后手动移除",
3278
+ );
3279
+ assert.ok(bundle.includes("cancelShift(rec.el);"), "卡片被 prune 时同步取消其平移状态(不残留监听与内联位移)");
3280
+ assert.ok(
3281
+ bundle.includes("for (const el of [...shiftCleanups.keys()]) cancelShift(el);"),
3282
+ "卸载时清理全部在飞平移(R-02-003 卸载不残留)",
3283
+ );
3284
+
3285
+ // R-01-004/AC-03
3286
+ // 滚动条仅滚动时显示:thumb 默认透明、data-scrolling 时显示;Firefox 路径在 @supports 门内。
3287
+ assert.ok(bundle.includes(".dap-scroll::-webkit-scrollbar-thumb {\n background: transparent;"), "滚动条 thumb 默认透明(不滚动时不显示)");
3288
+ assert.ok(bundle.includes(".dap-scroll[data-scrolling]::-webkit-scrollbar-thumb"), "滚动中经 data-scrolling 显示滚动条");
3289
+ assert.ok(bundle.includes("@supports not selector(::-webkit-scrollbar)") && bundle.includes("scrollbar-color: transparent transparent"), "Firefox 路径以 @supports 门隔离(防 Chromium 丢弃伪元素规则)");
3290
+ assert.ok(bundle.includes('scroll?.addEventListener("scroll", onScroll, { passive: true })'), "滚动监听置位 data-scrolling");
3291
+ assert.ok(bundle.includes('scroll?.removeEventListener("scroll", onScroll);\n\t\t\tif (scrollHideTimer !== null) clearTimeout(scrollHideTimer);'), "unbind 同步清理滚动监听与隐藏定时器(R-02-003/AC-02)");
3292
+
3293
+ // R-01-018 回到顶部悬浮图标按钮
3294
+ // R-01-018/AC-01、R-01-018/AC-03:骨架按钮默认 hidden,滚动监听按 TOP_THRESHOLD 阈值揭隐/隐藏;
3295
+ // R-01-018/AC-02:激活 scrollTo 回顶,reduced-motion 直接定位;R-01-018/AC-04:窄条态 CSS 隐藏;
3296
+ // R-01-018/AC-05:纯图标(无文字、aria-label 可访问名称)、右下角定位、不透明底色。
3297
+ assert.ok(
3298
+ bundle.includes('<button class="dap-top" type="button" aria-label="回到顶部" title="回到顶部" hidden></button>'),
3299
+ "窗格骨架含默认隐藏的「回到顶部」图标按钮:无文字、aria-label 提供可访问名称(键盘激活与 click 同路径)",
3300
+ );
3301
+ assert.ok(
3302
+ bundle.includes('pane.querySelector(".dap-top").append(createTopIcon());')
3303
+ && bundle.includes('function createTopIcon()')
3304
+ && bundle.includes('d: "M7 12.5V2"'),
3305
+ "按钮图标在窗格创建时经 createTopIcon 注入(向上箭头描边几何,14 盒,createInlineIcon 保证 aria-hidden)",
3306
+ );
3307
+ assert.ok(
3308
+ bundle.includes("[data-dsh-activity-pane] .dap-top {\n position: absolute;\n bottom: 12px;\n right: 12px;"),
3309
+ "回到顶部按钮悬浮定位于窗格右下角(不居中,R-01-018/AC-01)",
3310
+ );
3311
+ assert.ok(!bundle.includes(".dap-top {\n position: absolute;\n bottom: 12px;\n left: 50%;"), "底部居中定位已移除");
3312
+ assert.ok(
3313
+ bundle.includes("border-radius: 999px;\n background: #1d1f25;")
3314
+ && !bundle.includes(".dap-top {\n position: absolute;\n bottom: 12px;\n right: 12px;\n z-index: 6;\n display: flex;\n align-items: center;\n justify-content: center;\n width: 28px;\n height: 28px;\n padding: 0;\n border: 1px solid color-mix"),
3315
+ "按钮底色为不透明纯色(非 color-mix 半透明,R-01-018/AC-05)",
3316
+ );
3317
+ assert.ok(
3318
+ bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-top {\n background: var(--dsw-alias-bg-layer-2, #ffffff);\n border-color: var(--dsw-alias-border-l2, rgba(0, 0, 0, 0.1));"),
3319
+ "浅色主题底色取外壳 layer-2 别名(同样不透明,R-01-018/AC-05)",
3320
+ );
3321
+ assert.ok(
3322
+ bundle.includes("[data-dsh-activity-pane] .dap-top[hidden] { display: none; }"),
3323
+ "基类 display:flex 会压过 UA [hidden] 规则,显式补 hidden 隐藏(未超阈值不显示)",
3324
+ );
3325
+ assert.ok(
3326
+ bundle.includes('const topBtn = pane.querySelector(".dap-top");')
3327
+ && bundle.includes("if (topBtn !== null && scroll !== null) topBtn.hidden = scroll.scrollTop <= TOP_THRESHOLD;")
3328
+ && bundle.includes("syncTopBtn();"),
3329
+ "按钮显隐收敛到 syncTopBtn 单点:scrollTop 超 TOP_THRESHOLD 显示、阈值内隐藏(复用既有 scroll 监听,无新增监听)",
3330
+ );
3331
+ assert.ok(
3332
+ bundle.includes('pane.setAttribute("data-collapsed", "false");\n\t\t\tqueueSync();\n\t\t\tnotifyLayoutChange();\n\t\t\t// 折叠期间 display:none 可能令 scrollTop 归零而不派发 scroll 事件,展开时同步一次。\n\t\t\tsyncTopBtn();'),
3333
+ "窄条展开时同步一次按钮显隐(折叠期 scrollTop 归零不一定派发 scroll 事件,R-01-018/AC-03)",
3334
+ );
3335
+ assert.ok(
3336
+ bundle.includes('scroll?.scrollTo({ top: 0, behavior: prefersReducedMotion() ? "auto" : "smooth" });'),
3337
+ "激活回顶:reduced-motion 直接定位,否则平滑滚动(R-01-018/AC-02)",
3338
+ );
3339
+ assert.ok(
3340
+ bundle.includes('[data-dsh-activity-pane][data-collapsed="true"] .dap-top { display: none; }'),
3341
+ "桌面折叠窄条态不显示回到顶部按钮(R-01-018/AC-04)",
3342
+ );
3343
+ assert.ok(
3344
+ bundle.includes('topBtn?.addEventListener("click", onTopClick);')
3345
+ && bundle.includes('topBtn?.removeEventListener("click", onTopClick);'),
3346
+ "按钮 click 监听随 bindPaneControls 绑定并在 unbind 清理(R-02-003/AC-02)",
3347
+ );
3348
+
3349
+ // ---- 回归锚点:浅色主题适配 ----
3350
+ // 外壳以 body[data-ds-dark-theme] 标记深色(缺省即浅色)并翻转整套 --dsw-alias-* 变量;
3351
+ // 暗色硬编码色仅在属性缺省时被浅色别名覆盖,深色规则保持原值。
3352
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card {"), "浅色卡片底色/描边/阴影有独立覆盖块");
3353
+ assert.ok(bundle.includes("background: var(--dsw-alias-bg-layer-2, #ffffff);"), "浅色卡片底色取外壳 layer-2 别名");
3354
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace-item,\nbody:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace-label {"), "浅色时间线文字取外壳 label-secondary 别名");
3355
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-track {"), "浅色进度轨道底色有覆盖");
3356
+ assert.ok(bundle.includes("body:not([data-ds-dark-theme]) .dap-toggle {"), "浅色移动端浮动开关底色取外壳浮动按钮填充");
3357
+ assert.ok(bundle.includes(".dap-card {\n position: relative;\n flex: none;\n min-width: 0;\n padding: 9px 11px;\n border-radius: 14px;\n background: rgba(29, 31, 37, 0.94);"), "深色卡片底色保持原值(浅色仅经覆盖块生效)");
3358
+ assert.ok(bundle.includes("color: #c7ced9; font-size: 10px; line-height: 14px;"), "深色时间线文字保持原值");
3359
+ assert.ok(!bundle.includes("@media (prefers-color-scheme"), "主题跟随外壳 data-ds-dark-theme 标记,不另读系统媒体查询(避免与外壳手动主题设置脱节)");
3360
+ // 覆盖块必须不接管 ::before 状态圆点基色:基色规则若被覆盖会以更高优先级
3361
+ // 压掉 running/done/error/stopped 状态色。
3362
+ assert.ok(!bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-trace-item::before"), "浅色覆盖不接管 ::before 圆点(保留状态色)");
3363
+ // R-01-006/AC-01 当前会话高亮在浅色下同样生效:浅色 .dap-card/:hover/[data-kind] 覆盖
3364
+ // 的优先级均高于基态 [data-current] 规则,浅色块必须在其后重声明描边/光晕。
3365
+ assert.ok(
3366
+ bundle.includes("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-current] {\n border-color: color-mix(in srgb, #65a0ff 75%, transparent);\n box-shadow: 0 0 0 1px color-mix(in srgb, #65a0ff 45%, transparent), 0 0 12px color-mix(in srgb, #65a0ff 30%, transparent);\n}"),
3367
+ "浅色块重声明当前会话描边与光晕(与深色同值)",
3368
+ );
3369
+ const lightCurrentAt = bundle.indexOf("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-current] {");
3370
+ assert.ok(
3371
+ lightCurrentAt > bundle.indexOf("body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card:hover {")
3372
+ && lightCurrentAt > bundle.indexOf('body:not([data-ds-dark-theme]) [data-dsh-activity-pane] .dap-card[data-kind="recent"] {'),
3373
+ "浅色 [data-current] 重声明位于 :hover 与 [data-kind=recent] 覆盖之后(同优先级后定义者胜)",
3374
+ );
3375
+ // R-01-006/AC-01 等待卡同为当前会话时仍以蓝色描边/光晕高亮:[data-current] 基态规则
3376
+ // 与 [data-kind="awaiting"] 同优先级且定义在前,须由组合选择器(0-4-0)重声明。
3377
+ assert.ok(
3378
+ bundle.includes('[data-dsh-activity-pane] .dap-card[data-kind="awaiting"][data-current] {\n border-color: color-mix(in srgb, #65a0ff 75%, transparent);\n box-shadow: 0 0 0 1px color-mix(in srgb, #65a0ff 45%, transparent), 0 0 12px color-mix(in srgb, #65a0ff 30%, transparent);\n}'),
3379
+ "等待当前卡重声明蓝色描边与光晕(组合选择器压过等待态橙色)",
3380
+ );
3381
+
3382
+
3383
+ // ---- R-01-002/AC-10~AC-12 宿主侧完成确认契约(C-030)----
3384
+ // R-01-002/AC-12 刷新/重连恢复:状态由宿主侧持久化承载,不依赖客户端在线观测。
3385
+ const hostSource = await readFile(join(root, "src/host.mjs"), "utf8");
3386
+ const hostEntry = await readFile(join(root, ".dsh-plugin/index.mjs"), "utf8");
3387
+ execFileSync(process.execPath, ["--check", join(root, "src/host.mjs")], { stdio: "pipe" });
3388
+ execFileSync(process.execPath, ["--check", join(root, ".dsh-plugin/index.mjs")], { stdio: "pipe" });
3389
+ assert.ok(hostEntry.includes("from '../src/host.mjs'") && hostEntry.includes("export { apply, inject, name }"), "宿主侧入口转发 src/host.mjs(免构建)");
3390
+ assert.ok(hostSource.includes("ctx.on('session/event'") && hostSource.includes("event?.type !== 'turn/end'"), "宿主侧订阅 session/event 并过滤 turn/end(AC-03)");
3391
+ assert.ok(hostSource.includes("event.time"), "以事件顶层 time 登记回合结束时刻");
3392
+ assert.ok(hostSource.includes("storageDomain.open(domainSpec)") && hostSource.includes("acks: domainTable(ackRecord)"), "完成确认状态持久化于 storageDomain 表(AC-12)");
3393
+ assert.ok(hostSource.includes("const API_PATH = '/dsh-activity-pane/api'") && hostSource.includes("path: API_PATH"), "宿主侧路由挂载于 /dsh-activity-pane/api");
3394
+ assert.ok(hostSource.includes("'/acks/stream'") && hostSource.includes("text/event-stream"), "SSE 推送通道(AC-11、AC-12)");
3395
+ assert.ok(hostSource.includes("'/ack'") && hostSource.includes("ackedAt: Date.now()"), "ack 写回路由(AC-10~AC-12)");
3396
+ assert.ok(hostSource.includes("streamClients") && hostSource.includes("for (const res of streamClients)"), "SSE 连接集合随卸载全数关闭");
3397
+
3398
+
3399
+ // ---- E2E runner 浏览器生命周期契约(C-046、C-047,T-085)----
3400
+ const e2eRunnerSource = await readFile(join(root, "e2e/run.mjs"), "utf8");
3401
+ assert.equal(e2eRunnerSource.match(/chromium\.launch\(/g)?.length, 1, "runner 只有一条 Chromium 启动路径");
3402
+ assert.ok(e2eRunnerSource.includes("context = await browser.newContext()"), "每次隔离环境创建独立 browser context");
3403
+ assert.ok(e2eRunnerSource.includes("browser?.close()"), "每个 spec 都关闭浏览器进程");
3404
+ assert.ok(!e2eRunnerSource.includes("sharedBrowser"), "不保留未使用或跨环境共享的浏览器进程");
3405
+ assert.ok(e2eRunnerSource.includes("const MAX_CONCURRENCY = 1") && e2eRunnerSource.includes("Math.min(MAX_CONCURRENCY, specFiles.length)"), "E2E 固定顺序执行,保持资源上限与日志顺序稳定");
3406
+ assert.ok(!e2eRunnerSource.includes("RECOVER") && !e2eRunnerSource.includes("stallRecoveries"), "普通失败与列表停滞均不得换环境重试");
3407
+ const { formatPassTimings, settleCleanupSteps } = await import("../e2e/boot.mjs");
3408
+ assert.equal(
3409
+ formatPassTimings("sample.mjs", { boot: 1, browser: 2, spec: 3, cleanup: 4, total: 10 }),
3410
+ "e2e: PASS sample.mjs(boot=1ms browser=2ms spec=3ms cleanup=4ms total=10ms)",
3411
+ "PASS 日志可观察地分列 boot/browser/spec/cleanup/total",
3412
+ );
3413
+ const cleanupOrder = [];
3414
+ const cleanupErrors = await settleCleanupSteps([
3415
+ ["first", async () => { cleanupOrder.push("first"); throw new Error("expected cleanup failure"); }],
3416
+ ["second", async () => { cleanupOrder.push("second"); }],
3417
+ ]);
3418
+ assert.deepEqual(cleanupOrder, ["first", "second"], "cleanup 单步失败后继续释放其余资源");
3419
+ assert.equal(cleanupErrors.length, 1, "cleanup 错误显式返回给 runner 裁决失败,不被吞掉");
3420
+ const e2eHelperSource = await readFile(join(root, "e2e/helpers.mjs"), "utf8");
3421
+ assert.equal(e2eHelperSource.match(/page\.goto\(url/g)?.length, 1, "每个 spec 只建立一个页面连接世代");
3422
+ assert.ok(e2eHelperSource.includes("const PANE_READY_TIMEOUT_MS = 6_000"), "单页面连接世代使用固定 6s 观察窗口");
3423
+ assert.ok(!e2eHelperSource.includes("ERR_PANE_STALL"), "helpers 不保留列表停滞专用恢复错误码");
3424
+ assert.ok(e2eHelperSource.includes('throw new Error("窗格列表加载失败")'), "明确列表失败立即抛错,不等待超时或进入恢复");
3425
+
3426
+ // ---- GitHub CI 触发策略(C-050,T-086)----
3427
+ const ciWorkflowSource = await readFile(join(root, ".github/workflows/ci.yml"), "utf8");
3428
+ const ciTriggerBlock = ciWorkflowSource.match(/^on:\n([\s\S]*?)^permissions:/m)?.[1] ?? "";
3429
+ assert.equal(
3430
+ ciTriggerBlock,
3431
+ " workflow_dispatch:\n push:\n branches: [main]\n\n",
3432
+ "hosted CI trigger 仅允许 workflow_dispatch 与 main push,不得加入 PR、tag 或其它 push filter",
3433
+ );
3434
+ assert.ok(!ciWorkflowSource.includes("runner.tool_cache"), "job 级 env 不引用尚不可用的 runner context");
3435
+ assert.ok(ciWorkflowSource.includes("fetch-depth: 0"), "CI checkout 保留完整历史以验证 terminal task commit 证据");
3436
+ assert.ok(ciWorkflowSource.includes("timeout-minutes: 30"), "顺序 E2E 拥有明确 hosted timeout");
3437
+ assert.ok(ciWorkflowSource.includes("node-version: 24.16.0"), "hosted Node 与本地稳定基线一致");
3438
+
3439
+ // ---- E2E 基建:mock LLM 剧本服务行为断言(C-045,T-082、T-088、T-089)----
3440
+ // 浏览器 spec 驱动真实 UI;fast/slow/ask/runtime/error 的响应形状与分流规则在此做 Node 级行为验证。
3441
+ const { startMockLlm } = await import("../e2e/mock-llm.mjs");
3442
+ const { MOCK_ERROR_MESSAGE } = await import("../e2e/helpers.mjs");
3443
+ const mock = await startMockLlm();
3444
+ try {
3445
+ /** 请求 mock 并解析 SSE 负载序列([DONE] 收尾)。 */
3446
+ async function requestScenario(text, extraMessages = []) {
3447
+ const res = await fetch(`${mock.url}/chat/completions`, {
3448
+ method: "POST",
3449
+ headers: { "content-type": "application/json" },
3450
+ body: JSON.stringify({
3451
+ model: "deepseek-v4-flash",
3452
+ stream: true,
3453
+ messages: [...extraMessages, { role: "user", content: text }],
3454
+ }),
3455
+ });
3456
+ assert.equal(res.status, 200, "mock 接受 chat/completions 请求");
3457
+ const raw = await res.text();
3458
+ const events = raw.split("\n\n").filter(Boolean);
3459
+ assert.equal(events.at(-1), "data: [DONE]", "SSE 以 [DONE] 收尾(dsh-llm-deepseek 协议期望)");
3460
+ return events.slice(0, -1).map((line) => JSON.parse(line.replace(/^data: /, "")));
3461
+ }
3462
+
3463
+ // fast:无关键词走默认剧本(单文本块 + stop + 尾随 usage);显式 e2e:fast 同路径。
3464
+ const fast = await requestScenario("随便聊聊");
3465
+ assert.ok(fast[0].choices[0].delta.content.length > 0, "fast 首块携带文本");
3466
+ assert.equal(fast.at(-2).choices[0].finish_reason, "stop", "fast 以 stop 收尾");
3467
+ assert.ok(fast.at(-1).usage.completion_tokens > 0, "尾随 usage-only 块");
3468
+ await requestScenario("e2e:fast 探针");
3469
+
3470
+ // slow:24 个内容块分帧到达(会话保持运行)。
3471
+ const slow = await requestScenario("e2e:slow 探针");
3472
+ const slowContent = slow.filter((e) => e.choices?.[0]?.delta?.content);
3473
+ assert.equal(slowContent.length, 24, "slow 分 24 块流式输出");
3474
+ assert.equal(slow.at(-2).choices[0].finish_reason, "stop", "slow 以 stop 收尾");
3475
+
3476
+ // slow disconnect:客户端读到首块后断开,服务端停止剩余 chunk timers 与收尾写入。
3477
+ const streamBeforeDisconnect = mock.streamLog.length;
3478
+ const abort = new AbortController();
3479
+ const disconnected = await fetch(`${mock.url}/chat/completions`, {
3480
+ method: "POST",
3481
+ headers: { "content-type": "application/json" },
3482
+ signal: abort.signal,
3483
+ body: JSON.stringify({ model: "deepseek-v4-flash", stream: true, messages: [{ role: "user", content: "e2e:slow 断开探针" }] }),
3484
+ });
3485
+ await disconnected.body.getReader().read();
3486
+ abort.abort();
3487
+ await new Promise((resolve) => setTimeout(resolve, 350));
3488
+ const streamAfterDisconnect = mock.streamLog.length;
3489
+ await new Promise((resolve) => setTimeout(resolve, 350));
3490
+ assert.ok(streamAfterDisconnect > streamBeforeDisconnect && streamAfterDisconnect - streamBeforeDisconnect < 24, "slow 断开后未发送剩余全部内容块");
3491
+ assert.equal(mock.streamLog.length, streamAfterDisconnect, "slow 断开后 chunk 计数稳定,无遗留 timers 继续写入");
3492
+
3493
+ // ask:tool_calls 增量可重组为合法 ask_user_question 参数,finish_reason 为 tool_calls。
3494
+ const ask = await requestScenario("e2e:ask 探针");
3495
+ const askArgs = ask.flatMap((e) => e.choices?.[0]?.delta?.tool_calls ?? []).map((c) => c.function?.arguments ?? "").join("");
3496
+ const parsed = JSON.parse(askArgs);
3497
+ assert.ok(parsed.questions[0].question.length > 0 && parsed.questions[0].options.length === 2, "ask 工具参数重组为合法提问负载");
3498
+ assert.equal(ask.at(-2).choices[0].finish_reason, "tool_calls", "ask 以 tool_calls 收尾");
3499
+
3500
+ // runtime:首请求仍为 ask tool;其 tool 结果后续请求进入 slow,供浏览器证明 tool→stream 时间线更新。
3501
+ const runtimeAsk = await requestScenario("e2e:runtime 探针");
3502
+ assert.equal(runtimeAsk.at(-2).choices[0].finish_reason, "tool_calls", "runtime 首回合以 ask tool_calls 收尾");
3503
+ const runtimeAfterTool = await requestScenario("继续", [
3504
+ { role: "user", content: "e2e:runtime 历史指令" },
3505
+ { role: "tool", tool_call_id: "call_e2e_ask", content: "继续" },
3506
+ ]);
3507
+ assert.equal(runtimeAfterTool.filter((e) => e.choices?.[0]?.delta?.content).length, 24, "runtime tool 结果后进入 24 块 slow 流式输出");
3508
+
3509
+ // error:非重试型 HTTP 400 携带稳定 provider error,驱动真实 Agent error turn/end。
3510
+ const errorResponse = await fetch(`${mock.url}/chat/completions`, {
3511
+ method: "POST",
3512
+ headers: { "content-type": "application/json" },
3513
+ body: JSON.stringify({ model: "deepseek-v4-flash", stream: true, messages: [{ role: "user", content: "e2e:error 探针" }] }),
3514
+ });
3515
+ assert.equal(errorResponse.status, 400, "error 剧本返回非重试型 HTTP 400");
3516
+ assert.deepEqual(await errorResponse.json(), {
3517
+ error: { message: MOCK_ERROR_MESSAGE, type: "invalid_request_error", code: "e2e_failure" },
3518
+ }, "error 剧本返回稳定 OpenAI 兼容错误负载");
3519
+
3520
+ // 分流规则:含 tool 结果的回合一律 fast 收口,忽略历史消息里的关键词。
3521
+ const afterTool = await requestScenario("继续", [
3522
+ { role: "user", content: "e2e:slow 历史指令" },
3523
+ { role: "tool", tool_call_id: "call_e2e_ask", content: "继续" },
3524
+ ]);
3525
+ assert.ok(afterTool.length <= 4 && afterTool.at(-2).choices[0].finish_reason === "stop", "tool 结果回合直接 fast 收口");
3526
+
3527
+ // 默认剧本:无关键词走 fast;非 chat/completions 路径 404。
3528
+ assert.deepEqual(mock.scenarioLog, ["fast", "fast", "slow", "slow", "ask", "runtime", "slow", "error", "fast"], "scenarioLog 记录默认/显式 fast、slow/断开、ask、runtime→slow、error 与普通 tool 收口");
3529
+ const wrongPath = await fetch(`${mock.url}/models`);
3530
+ assert.equal(wrongPath.status, 404, "非 chat/completions 路径返回 404");
3531
+ } finally {
3532
+ await mock.close();
3533
+ }
3534
+
3535
+
3536
+ console.log("check: all assertions passed");