@qzsy/dsh-command-palette 1.3.1 → 1.4.1

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,780 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Standalone verification for the command palette's session status surface.
4
+ *
5
+ * Covers:
6
+ * - loading the prebuilt browser module through a fake
7
+ * `window.__ModuleLoader__` and instantiating its factory with React;
8
+ * - the completion/failure badge matrix (pending > running > failure > unread);
9
+ * - the `api-session/error` failure feed lifecycle and failure store;
10
+ * - failure message normalization (whitespace collapse + 180 char cap);
11
+ * - workspace aggregation priority/counts and subagent filtering;
12
+ * - session-group summary segments and session row wiring;
13
+ * - the production injection surface (inject/apply and hooks.sessionFailures).
14
+ *
15
+ * Usage (from the repository root):
16
+ * node plugins/packages/dsh-command-palette/scripts/verify-status.mjs
17
+ *
18
+ * Exit code 0 means every check passed; 1 lists the failures.
19
+ *
20
+ * Only Node built-ins and the repository's own `react` dependency are used.
21
+ * The runtime file is never modified.
22
+ */
23
+ import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+ import { dirname, join, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+ import { isDeepStrictEqual, inspect } from 'node:util';
28
+ import vm from 'node:vm';
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const REPO_ROOT = resolve(HERE, '../../../..');
32
+ const CLIENT_PATH = join(REPO_ROOT, 'plugins', 'packages', 'dsh-command-palette', 'runtime', 'client.js');
33
+ const REPO_PACKAGE_JSON = join(REPO_ROOT, 'package.json');
34
+ const EXPECTED_MODULE_ID = '@qzsy/dsh-command-palette';
35
+ const MINUTE = 60 * 1000;
36
+ const NOW = 1_700_000_000_000;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Tiny check harness
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const results = [];
43
+
44
+ function oneLine(value, limit = 420) {
45
+ const flat = String(value).replace(/\s+/g, ' ').trim();
46
+ return flat.length > limit ? `${flat.slice(0, limit - 1)}…` : flat;
47
+ }
48
+
49
+ function formatValue(value) {
50
+ let rendered;
51
+ try {
52
+ rendered = inspect(value, {
53
+ depth: 6,
54
+ breakLength: Infinity,
55
+ compact: true,
56
+ maxArrayLength: 20,
57
+ maxStringLength: 300
58
+ });
59
+ } catch {
60
+ rendered = String(value);
61
+ }
62
+ return oneLine(rendered, 320);
63
+ }
64
+
65
+ function equal(actual, expected, what = 'value') {
66
+ if (!isDeepStrictEqual(actual, expected)) {
67
+ throw new Error(`${what}: expected ${formatValue(expected)}, got ${formatValue(actual)}`);
68
+ }
69
+ }
70
+
71
+ function truthy(value, what = 'condition') {
72
+ if (!value) throw new Error(`${what}: expected a truthy value, got ${formatValue(value)}`);
73
+ }
74
+
75
+ function check(name, body) {
76
+ try {
77
+ body();
78
+ results.push({ name, ok: true });
79
+ console.log(`PASS ${name}`);
80
+ } catch (error) {
81
+ const detail = oneLine(error instanceof Error ? error.message : String(error));
82
+ results.push({ name, ok: false, detail });
83
+ console.log(`FAIL ${name} :: ${detail}`);
84
+ }
85
+ }
86
+
87
+ function report() {
88
+ const failed = results.filter((entry) => !entry.ok);
89
+ const passed = results.length - failed.length;
90
+ console.log(`\nSummary: ${passed} passed, ${failed.length} failed, ${results.length} checks`);
91
+ if (failed.length === 0) {
92
+ console.log('Result: OK');
93
+ return 0;
94
+ }
95
+ console.log('\nFailed checks:');
96
+ for (const entry of failed) console.log(` - ${entry.name}: ${entry.detail}`);
97
+ console.log('\nResult: FAILED');
98
+ return 1;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Boot: capture the window.__ModuleLoader__ registration and run the factory
103
+ // ---------------------------------------------------------------------------
104
+
105
+ const requireFromRepo = createRequire(REPO_PACKAGE_JSON);
106
+
107
+ function boot() {
108
+ let source;
109
+ try {
110
+ source = readFileSync(CLIENT_PATH, 'utf8');
111
+ } catch (error) {
112
+ throw new Error(`cannot read ${CLIENT_PATH}: ${error.message}`);
113
+ }
114
+
115
+ let registration = null;
116
+ const hadWindow = Object.prototype.hasOwnProperty.call(globalThis, 'window');
117
+ const previousWindow = globalThis.window;
118
+ globalThis.window = {
119
+ __ModuleLoader__: {
120
+ load(next) {
121
+ if (registration !== null) throw new Error('window.__ModuleLoader__.load was called more than once');
122
+ registration = next;
123
+ }
124
+ }
125
+ };
126
+ try {
127
+ vm.runInThisContext(source, { filename: CLIENT_PATH });
128
+ } finally {
129
+ if (hadWindow) globalThis.window = previousWindow;
130
+ else delete globalThis.window;
131
+ }
132
+
133
+ if (registration === null) throw new Error('runtime/client.js did not call window.__ModuleLoader__.load');
134
+ if (typeof registration.factory !== 'function') throw new Error('module registration has no factory');
135
+ const plugin = registration.factory((id) => requireFromRepo(id));
136
+ return { plugin, registration, source };
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // Stubs and fixtures
141
+ // ---------------------------------------------------------------------------
142
+
143
+ /** zh values for every key touched by the helpers under test. */
144
+ const ZH = {
145
+ 'status.running': '工作中',
146
+ 'status.pending': '待处理',
147
+ 'status.approval': '待批准',
148
+ 'status.planReview': '待审阅',
149
+ 'status.question': '待回答',
150
+ 'status.done': '已完成',
151
+ 'status.doneTitle': '已完成,尚未查看',
152
+ 'status.error': '出错',
153
+ 'status.errorTitle': '运行出错:{message}',
154
+ 'status.errorUnreadTitle': '运行出错(尚未查看):{message}',
155
+ 'status.errorUnknown': '未知错误',
156
+ 'status.runningCount': '{n} 个工作中',
157
+ 'status.pendingCount': '{n} 个待处理',
158
+ 'status.errorCount': '{n} 个出错',
159
+ 'status.unreadCount': '{n} 个未读',
160
+ 'group.active': '{n} 个进行中',
161
+ 'group.pending': '{n} 个待处理',
162
+ 'group.error': '{n} 个出错',
163
+ 'group.unread': '{n} 个未读',
164
+ 'item.untitled': '未命名',
165
+ 'item.noWorkspace': '未分组',
166
+ 'time.now': '刚刚',
167
+ 'time.short.m': '{n}m',
168
+ 'time.short.h': '{n}h',
169
+ 'time.short.d': '{n}d',
170
+ 'time.short.mo': '{n}mo',
171
+ 'time.short.y': '{n}y'
172
+ };
173
+
174
+ function makeT() {
175
+ return function t(key, params) {
176
+ const template = ZH[key];
177
+ if (typeof template !== 'string') throw new Error(`stub t() is missing zh dictionary key: ${key}`);
178
+ if (params === undefined) return template;
179
+ return template.replace(/\{(\w+)\}/g, (match, name) => (
180
+ Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
181
+ ));
182
+ };
183
+ }
184
+
185
+ const t = makeT();
186
+ const at = (minutes) => NOW - minutes * MINUTE;
187
+
188
+ function createFakeRemote() {
189
+ const handlers = new Map();
190
+ const subscriptions = [];
191
+ const disposals = [];
192
+
193
+ return {
194
+ remote: {
195
+ $on(event, handler) {
196
+ subscriptions.push(event);
197
+ if (!handlers.has(event)) handlers.set(event, new Set());
198
+ handlers.get(event).add(handler);
199
+ let disposed = false;
200
+ return () => {
201
+ if (disposed) return;
202
+ disposed = true;
203
+ disposals.push(event);
204
+ handlers.get(event)?.delete(handler);
205
+ };
206
+ }
207
+ },
208
+ subscriptions,
209
+ disposals,
210
+ emit(event, ...args) {
211
+ const listeners = handlers.get(event);
212
+ if (listeners === undefined || listeners.size === 0) throw new Error(`no live handler subscribed to ${event}`);
213
+ for (const listener of [...listeners]) listener(...args);
214
+ },
215
+ countHandlers() {
216
+ let total = 0;
217
+ for (const listeners of handlers.values()) total += listeners.size;
218
+ return total;
219
+ }
220
+ };
221
+ }
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // Checks
225
+ // ---------------------------------------------------------------------------
226
+
227
+ function runChecks({ plugin, registration, source }) {
228
+ const api = plugin.__test;
229
+
230
+ // --- module loading and production surface ------------------------------
231
+
232
+ check('load: runtime registers exactly one module factory', () => {
233
+ equal(registration.id, EXPECTED_MODULE_ID, 'module id');
234
+ equal(typeof registration.factory, 'function', 'registration factory');
235
+ });
236
+
237
+ check('surface: plugin exposes inject and apply', () => {
238
+ truthy(Array.isArray(plugin.inject), 'inject is an array');
239
+ equal(typeof plugin.apply, 'function', 'apply');
240
+ });
241
+
242
+ check('surface: inject declares the remote capability', () => {
243
+ truthy(plugin.inject.includes('remote'), "inject contains 'remote'");
244
+ });
245
+
246
+ check('surface: __test exposes the documented helpers', () => {
247
+ const expected = [
248
+ 'createFailureStore',
249
+ 'installFailureFeed',
250
+ 'normalizeFailureMessage',
251
+ 'sessionBadge',
252
+ 'sessionItems',
253
+ 'sessionState',
254
+ 'summarizeSessionRows',
255
+ 'workspaceItems'
256
+ ].sort();
257
+ equal(Object.keys(api).sort(), expected, '__test keys');
258
+ for (const name of expected) equal(typeof api[name], 'function', `__test.${name}`);
259
+ });
260
+
261
+ check('source: PaletteDialog destructures useSessionFailures', () => {
262
+ truthy(
263
+ /function\s+PaletteDialog\s*\([^)]*\buseSessionFailures\b[^)]*\)/.test(source),
264
+ 'PaletteDialog signature should destructure useSessionFailures'
265
+ );
266
+ });
267
+
268
+ check('source: injected() exposes hooks.sessionFailures', () => {
269
+ truthy(
270
+ /const\s+injected\s*=\s*\(\)\s*=>\s*\(\{[\s\S]*?hooks\s*:\s*\{[\s\S]*?\bsessionFailures\s*:\s*failures\.store\b/.test(source),
271
+ 'injected() hooks should expose the failure store as sessionFailures'
272
+ );
273
+ });
274
+
275
+ check('source: apply wires the failure store and feed', () => {
276
+ truthy(
277
+ /const\s+failures\s*=\s*createFailureStore\(\)/.test(source),
278
+ 'apply should create the failure store'
279
+ );
280
+ truthy(
281
+ /ctx\.effect\(\s*\(\)\s*=>\s*installFailureFeed\(ctx,\s*failures\)/.test(source),
282
+ 'apply should install the failure feed through ctx.effect'
283
+ );
284
+ });
285
+
286
+ // --- normalizeFailureMessage -------------------------------------------
287
+
288
+ check('normalize: collapses whitespace and trims', () => {
289
+ equal(
290
+ api.normalizeFailureMessage(' network timeout\n\twhile reading '),
291
+ 'network timeout while reading'
292
+ );
293
+ });
294
+
295
+ check('normalize: keeps a message exactly at the 180 character limit', () => {
296
+ const input = 'a'.repeat(180);
297
+ const output = api.normalizeFailureMessage(input);
298
+ equal(output.length, 180, 'length');
299
+ equal(output, input, 'message');
300
+ });
301
+
302
+ check('normalize: caps long messages at 180 characters with an ellipsis', () => {
303
+ const output = api.normalizeFailureMessage('b'.repeat(400));
304
+ equal(output.length, 180, 'length');
305
+ equal(output, `${'b'.repeat(179)}…`, 'message');
306
+ });
307
+
308
+ check('normalize: non-string input becomes an empty string', () => {
309
+ for (const input of [undefined, null, 42, true, {}, [], Symbol('x')]) {
310
+ equal(api.normalizeFailureMessage(input), '', `normalize(${String(input)})`);
311
+ }
312
+ });
313
+
314
+ // --- failure store + installFailureFeed lifecycle -----------------------
315
+
316
+ check('store: starts with an empty snapshot Map', () => {
317
+ const { store } = api.createFailureStore();
318
+ truthy(store.getSnapshot() instanceof Map, 'snapshot is a Map');
319
+ equal(store.getSnapshot().size, 0, 'snapshot size');
320
+ });
321
+
322
+ check('feed: subscribes to the four session lifecycle events', () => {
323
+ const fake = createFakeRemote();
324
+ const failures = api.createFailureStore();
325
+ const dispose = api.installFailureFeed({ remote: fake.remote }, failures);
326
+ equal(fake.subscriptions, [
327
+ 'api-session/error',
328
+ 'api-session/status',
329
+ 'api-session/activity',
330
+ 'api-session/removed'
331
+ ], 'subscribed events');
332
+ equal(fake.countHandlers(), 4, 'live handlers');
333
+ equal(typeof dispose, 'function', 'disposer');
334
+ dispose();
335
+ });
336
+
337
+ check('feed: api-session/error records a normalized failure per session', () => {
338
+ const fake = createFakeRemote();
339
+ const failures = api.createFailureStore();
340
+ api.installFailureFeed({ remote: fake.remote }, failures);
341
+ fake.emit('api-session/error', 's1', ' provider 500\nrequest failed ');
342
+ fake.emit('api-session/error', 's2', 'socket closed');
343
+ const snapshot = failures.store.getSnapshot();
344
+ equal(snapshot.size, 2, 'snapshot size');
345
+ equal(snapshot.get('s1').message, 'provider 500 request failed', 's1 message');
346
+ equal(typeof snapshot.get('s1').at, 'number', 's1 timestamp');
347
+ equal(snapshot.get('s2').message, 'socket closed', 's2 message');
348
+ });
349
+
350
+ check('feed: duplicate errors keep the latest message', () => {
351
+ const fake = createFakeRemote();
352
+ const failures = api.createFailureStore();
353
+ api.installFailureFeed({ remote: fake.remote }, failures);
354
+ fake.emit('api-session/error', 's1', 'first failure');
355
+ fake.emit('api-session/error', 's1', 'second failure');
356
+ const snapshot = failures.store.getSnapshot();
357
+ equal(snapshot.size, 1, 'snapshot size');
358
+ equal(snapshot.get('s1').message, 'second failure', 'latest message');
359
+ });
360
+
361
+ check('feed: a new turn (status running=true) clears the failure', () => {
362
+ const fake = createFakeRemote();
363
+ const failures = api.createFailureStore();
364
+ api.installFailureFeed({ remote: fake.remote }, failures);
365
+ fake.emit('api-session/error', 's1', 'boom');
366
+ fake.emit('api-session/status', 's1', true);
367
+ equal(failures.store.getSnapshot().has('s1'), false, 'failure cleared');
368
+ });
369
+
370
+ check('feed: status running=false does not clear the failure', () => {
371
+ const fake = createFakeRemote();
372
+ const failures = api.createFailureStore();
373
+ api.installFailureFeed({ remote: fake.remote }, failures);
374
+ fake.emit('api-session/error', 's1', 'boom');
375
+ fake.emit('api-session/status', 's1', false);
376
+ equal(failures.store.getSnapshot().get('s1').message, 'boom', 'failure retained');
377
+ });
378
+
379
+ check('feed: a new user prompt (activity) clears the failure', () => {
380
+ const fake = createFakeRemote();
381
+ const failures = api.createFailureStore();
382
+ api.installFailureFeed({ remote: fake.remote }, failures);
383
+ fake.emit('api-session/error', 's1', 'boom');
384
+ fake.emit('api-session/activity', 's1');
385
+ equal(failures.store.getSnapshot().has('s1'), false, 'failure cleared');
386
+ });
387
+
388
+ check('feed: a removed session clears the failure', () => {
389
+ const fake = createFakeRemote();
390
+ const failures = api.createFailureStore();
391
+ api.installFailureFeed({ remote: fake.remote }, failures);
392
+ fake.emit('api-session/error', 's1', 'boom');
393
+ fake.emit('api-session/removed', 's1');
394
+ equal(failures.store.getSnapshot().has('s1'), false, 'failure cleared');
395
+ });
396
+
397
+ check('feed: disposer removes every subscription and is idempotent', () => {
398
+ const fake = createFakeRemote();
399
+ const failures = api.createFailureStore();
400
+ const dispose = api.installFailureFeed({ remote: fake.remote }, failures);
401
+ dispose();
402
+ equal(fake.countHandlers(), 0, 'live handlers after dispose');
403
+ equal([...fake.disposals].sort(), [
404
+ 'api-session/activity',
405
+ 'api-session/error',
406
+ 'api-session/removed',
407
+ 'api-session/status'
408
+ ], 'disposed events');
409
+ dispose();
410
+ });
411
+
412
+ check('feed: missing ctx.remote returns a no-op disposer', () => {
413
+ for (const ctx of [undefined, null, {}, { remote: {} }, { remote: { $on: 'not-a-function' } }]) {
414
+ const dispose = api.installFailureFeed(ctx, api.createFailureStore());
415
+ equal(typeof dispose, 'function', 'disposer');
416
+ dispose();
417
+ dispose();
418
+ }
419
+ });
420
+
421
+ check('feed: empty/invalid ids and messages are contained', () => {
422
+ const fake = createFakeRemote();
423
+ const failures = api.createFailureStore();
424
+ api.installFailureFeed({ remote: fake.remote }, failures);
425
+ for (const sessionId of ['', null, 42, {}, []]) {
426
+ fake.emit('api-session/error', sessionId, 'ignored');
427
+ }
428
+ equal(failures.store.getSnapshot().size, 0, 'no entries for invalid ids');
429
+ fake.emit('api-session/error', 's-ok', undefined);
430
+ fake.emit('api-session/error', 's-ok2', { message: 'not a string' });
431
+ const snapshot = failures.store.getSnapshot();
432
+ equal(snapshot.get('s-ok').message, '', 'undefined message normalizes to empty');
433
+ equal(snapshot.get('s-ok2').message, '', 'object message normalizes to empty');
434
+ for (const sessionId of ['', null, 42]) {
435
+ failures.record(sessionId, 'ignored');
436
+ failures.clear(sessionId);
437
+ }
438
+ });
439
+
440
+ // --- badge state matrix -------------------------------------------------
441
+
442
+ const STALE_FAILURE = { message: 'stale network error', at: NOW };
443
+ const FAILURE = { message: 'provider 500', at: NOW };
444
+ const badge = (row, status, failure) => api.sessionBadge(row, status, failure, t);
445
+
446
+ check('badge: pending approval outranks running, failure and unread', () => {
447
+ equal(
448
+ badge({ running: true }, { running: true, pendingInteraction: { kind: 'approval' }, completionUnread: true }, FAILURE),
449
+ { tone: 'pending', label: '待批准' }
450
+ );
451
+ });
452
+
453
+ check('badge: pending plan review outranks running, failure and unread', () => {
454
+ equal(
455
+ badge({ running: true }, { running: true, pendingInteraction: { kind: 'plan-review' }, completionUnread: true }, FAILURE),
456
+ { tone: 'pending', label: '待审阅' }
457
+ );
458
+ });
459
+
460
+ check('badge: pending question outranks running, failure and unread', () => {
461
+ equal(
462
+ badge({ running: true }, { running: true, pendingInteraction: { kind: 'question' }, completionUnread: true }, FAILURE),
463
+ { tone: 'pending', label: '待回答' }
464
+ );
465
+ });
466
+
467
+ check('badge: other pending kinds fall back to the pending label', () => {
468
+ equal(
469
+ badge({ running: true }, { running: true, pendingInteraction: { kind: 'permission' }, completionUnread: true }, FAILURE),
470
+ { tone: 'pending', label: '待处理' }
471
+ );
472
+ });
473
+
474
+ check('badge: running outranks a stale failure and unread completion', () => {
475
+ equal(
476
+ badge({}, { running: true, completionUnread: true }, STALE_FAILURE),
477
+ { tone: 'running', label: '工作中' }
478
+ );
479
+ });
480
+
481
+ check('badge: legacy row.running still outranks a failure', () => {
482
+ equal(
483
+ badge({ running: true }, undefined, FAILURE),
484
+ { tone: 'running', label: '工作中' }
485
+ );
486
+ });
487
+
488
+ check('badge: a failure without unread completion is an error badge with the plain tooltip', () => {
489
+ const expected = { tone: 'error', label: '出错', title: '运行出错:provider 500' };
490
+ equal(badge({}, undefined, FAILURE), expected, 'failure without a status snapshot');
491
+ equal(badge({}, { completionUnread: false }, FAILURE), expected, 'failure with viewed completion');
492
+ });
493
+
494
+ check('badge: a failure with unread completion uses the unread tooltip', () => {
495
+ equal(
496
+ badge({}, { completionUnread: true }, FAILURE),
497
+ { tone: 'error', label: '出错', title: '运行出错(尚未查看):provider 500' }
498
+ );
499
+ });
500
+
501
+ check('badge: an empty failure message falls back to the unknown-error text', () => {
502
+ equal(
503
+ badge({}, {}, { message: '', at: NOW }),
504
+ { tone: 'error', label: '出错', title: '运行出错:未知错误' }
505
+ );
506
+ });
507
+
508
+ check('badge: unread success is a done badge with the unread tooltip', () => {
509
+ equal(
510
+ badge({}, { completionUnread: true }, undefined),
511
+ { tone: 'done', label: '已完成', title: '已完成,尚未查看' }
512
+ );
513
+ });
514
+
515
+ check('badge: viewed success has no badge', () => {
516
+ equal(badge({}, { completionUnread: false }, undefined), null, 'viewed completion');
517
+ equal(badge({}, {}, undefined), null, 'status without unread completion');
518
+ equal(badge({}, undefined, undefined), null, 'no status and no failure');
519
+ });
520
+
521
+ // --- session group summary ---------------------------------------------
522
+
523
+ check('summary: emits present tones in running, pending, error, unread order', () => {
524
+ equal(
525
+ api.summarizeSessionRows([
526
+ { badge: { tone: 'error' } },
527
+ { badge: { tone: 'done' } },
528
+ { badge: { tone: 'pending' } },
529
+ { badge: { tone: 'running' } },
530
+ { badge: { tone: 'error' } }
531
+ ], t),
532
+ ['1 个进行中', '1 个待处理', '2 个出错', '1 个未读']
533
+ );
534
+ });
535
+
536
+ check('summary: omits tones that are not present', () => {
537
+ equal(
538
+ api.summarizeSessionRows([
539
+ { badge: { tone: 'running' } },
540
+ { badge: { tone: 'running' } },
541
+ { badge: { tone: 'done' } }
542
+ ], t),
543
+ ['2 个进行中', '1 个未读']
544
+ );
545
+ });
546
+
547
+ check('summary: returns no segments for empty or badge-less rows', () => {
548
+ equal(api.summarizeSessionRows([], t), []);
549
+ equal(api.summarizeSessionRows([{}, { badge: null }, { badge: { tone: 'unknown' } }], t), []);
550
+ });
551
+
552
+ // --- workspace aggregation ---------------------------------------------
553
+
554
+ const sessions = {
555
+ byId: {
556
+ p1: { id: 'p1', updatedAt: at(2) },
557
+ p2: { id: 'p2', updatedAt: at(1) },
558
+ e1: { id: 'e1', updatedAt: at(3) },
559
+ e4: { id: 'e4', updatedAt: at(4) },
560
+ r1: { id: 'r1', updatedAt: at(5) },
561
+ u1: { id: 'u1', updatedAt: at(6) },
562
+ e2: { id: 'e2', updatedAt: at(10) },
563
+ e3: { id: 'e3', updatedAt: at(12) },
564
+ r2: { id: 'r2', updatedAt: at(2) },
565
+ ux: { id: 'ux', updatedAt: at(1) },
566
+ r3: { id: 'r3', updatedAt: at(3) },
567
+ r4: { id: 'r4', updatedAt: at(8) },
568
+ u2: { id: 'u2', updatedAt: at(1) },
569
+ u3: { id: 'u3', updatedAt: at(20) },
570
+ u4: { id: 'u4', updatedAt: at(6) },
571
+ u5: { id: 'u5', updatedAt: at(7) },
572
+ v1: { id: 'v1', updatedAt: at(9) },
573
+ n1: { id: 'n1', updatedAt: at(5) },
574
+ sub1: { id: 'sub1', updatedAt: at(1), origin: 'subagent' }
575
+ }
576
+ };
577
+ const statuses = new Map([
578
+ ['p1', { pendingInteraction: { kind: 'approval' } }],
579
+ ['p2', { pendingInteraction: { kind: 'question' } }],
580
+ ['r1', { running: true }],
581
+ ['u1', { completionUnread: true }],
582
+ ['r2', { running: true }],
583
+ ['ux', { completionUnread: true }],
584
+ ['r3', { running: true }],
585
+ ['r4', { running: true }],
586
+ ['u2', { completionUnread: true }],
587
+ ['u3', { completionUnread: true }],
588
+ ['u4', { completionUnread: true }],
589
+ ['u5', { completionUnread: true }],
590
+ ['n1', { completionUnread: true }],
591
+ ['sub1', { pendingInteraction: { kind: 'approval' } }]
592
+ ]);
593
+ const failures = new Map([
594
+ ['e1', { message: 'boom one', at: NOW }],
595
+ ['e4', { message: 'boom four', at: NOW }],
596
+ ['e2', { message: 'boom two', at: NOW }],
597
+ ['e3', { message: 'boom three', at: NOW }]
598
+ ]);
599
+ const workspaces = {
600
+ items: [
601
+ {
602
+ workspaceId: 'ws-pending',
603
+ path: 'D:/projects/ws-pending',
604
+ title: 'Pending',
605
+ sessionIds: ['p1', 'p2', 'e1', 'e4', 'r1', 'u1'],
606
+ createdAt: new Date(at(60 * 24 * 30)).toISOString()
607
+ },
608
+ {
609
+ workspaceId: 'ws-error',
610
+ path: 'D:/projects/ws-error',
611
+ title: 'Error',
612
+ sessionIds: ['e2', 'e3', 'r2', 'ux'],
613
+ createdAt: new Date(at(60 * 24 * 30)).toISOString()
614
+ },
615
+ {
616
+ workspaceId: 'ws-running',
617
+ path: 'D:/projects/ws-running',
618
+ title: 'Running',
619
+ sessionIds: ['r3', 'r4', 'u2', 'u3'],
620
+ createdAt: new Date(at(60 * 24 * 30)).toISOString()
621
+ },
622
+ {
623
+ workspaceId: 'ws-unread',
624
+ path: 'D:/projects/ws-unread',
625
+ title: 'Unread',
626
+ sessionIds: ['u4', 'u5'],
627
+ // Newer than every session on purpose: recency/meta must use sessions.
628
+ createdAt: new Date(at(1)).toISOString()
629
+ },
630
+ {
631
+ workspaceId: 'ws-quiet',
632
+ path: 'D:/projects/ws-quiet',
633
+ title: 'Quiet',
634
+ sessionIds: ['v1', 'missing-session'],
635
+ createdAt: new Date(at(60 * 24 * 30)).toISOString()
636
+ },
637
+ {
638
+ workspaceId: 'ws-subagent',
639
+ path: 'D:/projects/ws-subagent',
640
+ title: 'Subagent',
641
+ sessionIds: ['n1', 'sub1'],
642
+ createdAt: new Date(at(60 * 24 * 30)).toISOString()
643
+ }
644
+ ],
645
+ archivedSessionIds: []
646
+ };
647
+ const workspaceRows = api.workspaceItems(sessions, workspaces, NOW, t, 'D:/projects', statuses, failures, false, undefined);
648
+ const workspaceById = new Map(workspaceRows.map((item) => [item.workspaceId, item]));
649
+
650
+ check('workspaces: pending outranks error/running/unread and counts pending sessions', () => {
651
+ const item = workspaceById.get('ws-pending');
652
+ truthy(item !== undefined, 'ws-pending row');
653
+ equal(item.badge, { tone: 'pending', label: '2 个待处理' }, 'badge');
654
+ });
655
+
656
+ check('workspaces: error outranks running/unread and counts failed sessions', () => {
657
+ const item = workspaceById.get('ws-error');
658
+ truthy(item !== undefined, 'ws-error row');
659
+ equal(item.badge, { tone: 'error', label: '2 个出错' }, 'badge');
660
+ });
661
+
662
+ check('workspaces: running outranks unread and counts running sessions', () => {
663
+ const item = workspaceById.get('ws-running');
664
+ truthy(item !== undefined, 'ws-running row');
665
+ equal(item.badge, { tone: 'running', label: '2 个工作中' }, 'badge');
666
+ });
667
+
668
+ check('workspaces: unread-only workspace aggregates to done with its count', () => {
669
+ const item = workspaceById.get('ws-unread');
670
+ truthy(item !== undefined, 'ws-unread row');
671
+ equal(item.badge, { tone: 'done', label: '2 个未读' }, 'badge');
672
+ });
673
+
674
+ check('workspaces: quiet workspace has no aggregate badge', () => {
675
+ const item = workspaceById.get('ws-quiet');
676
+ truthy(item !== undefined, 'ws-quiet row');
677
+ equal(item.badge, null, 'badge');
678
+ });
679
+
680
+ check('workspaces: newest session drives recency/meta instead of workspace.createdAt', () => {
681
+ const item = workspaceById.get('ws-unread');
682
+ truthy(item !== undefined, 'ws-unread row');
683
+ equal(item.recency, at(6), 'recency from newest session');
684
+ equal(item.meta, '6m', 'relative meta from newest session');
685
+ });
686
+
687
+ check('workspaces: subagent sessions are excluded when includeSubagents is false', () => {
688
+ const item = workspaceById.get('ws-subagent');
689
+ truthy(item !== undefined, 'ws-subagent row');
690
+ equal(item.badge, { tone: 'done', label: '1 个未读' }, 'badge');
691
+ equal(item.recency, at(5), 'recency ignores the subagent session');
692
+ equal(item.meta, '5m', 'meta ignores the subagent session');
693
+ });
694
+
695
+ check('workspaces: includeSubagents=true lets a newer subagent drive the aggregate', () => {
696
+ const rows = api.workspaceItems(sessions, workspaces, NOW, t, 'D:/projects', statuses, failures, true, undefined);
697
+ const item = rows.find((entry) => entry.workspaceId === 'ws-subagent');
698
+ truthy(item !== undefined, 'ws-subagent row');
699
+ equal(item.badge, { tone: 'pending', label: '1 个待处理' }, 'badge');
700
+ equal(item.recency, at(1), 'recency includes the subagent session');
701
+ equal(item.meta, '1m', 'meta includes the subagent session');
702
+ });
703
+
704
+ // --- sessionItems wiring ------------------------------------------------
705
+
706
+ const sessionRowFixtures = {
707
+ byId: {
708
+ s1: { id: 's1', updatedAt: at(3), title: ' Alpha ', cwd: 'D:/projects/alpha' },
709
+ s2: { id: 's2', updatedAt: at(2), title: '', displayTitle: 'Beta', cwd: 'D:/projects/beta' },
710
+ sBlank: { id: 'sBlank', updatedAt: at(1), blank: true, title: 'Blank', cwd: 'D:/projects/blank' },
711
+ sArchived: { id: 'sArchived', updatedAt: NOW, title: 'Archived', cwd: 'D:/projects/archived' }
712
+ }
713
+ };
714
+ const sessionRowWorkspaces = {
715
+ items: [
716
+ {
717
+ workspaceId: 'ws-alpha',
718
+ path: 'D:/projects/alpha',
719
+ title: 'Alpha workspace',
720
+ sessionIds: ['s1', 'sArchived'],
721
+ createdAt: new Date(NOW).toISOString()
722
+ }
723
+ ],
724
+ archivedSessionIds: ['sArchived']
725
+ };
726
+ const sessionRowStatuses = new Map([['s1', { running: true }]]);
727
+ const sessionRowFailures = new Map([['s2', { message: 'provider 500', at: NOW }]]);
728
+ const sessionRows = api.sessionItems(
729
+ sessionRowFixtures,
730
+ sessionRowWorkspaces,
731
+ NOW,
732
+ t,
733
+ sessionRowStatuses,
734
+ sessionRowFailures,
735
+ undefined
736
+ );
737
+
738
+ check('sessions: filters blank and archived rows', () => {
739
+ equal(
740
+ sessionRows.map((row) => row.sessionId).sort(),
741
+ ['s1', 's2'],
742
+ 'visible session ids'
743
+ );
744
+ });
745
+
746
+ check('sessions: wires the failure feed into an error badge', () => {
747
+ const row = sessionRows.find((entry) => entry.sessionId === 's2');
748
+ truthy(row !== undefined, 's2 row');
749
+ equal(row.badge, { tone: 'error', label: '出错', title: '运行出错:provider 500' }, 'badge');
750
+ equal(row.failure, { message: 'provider 500', at: NOW }, 'failure payload');
751
+ });
752
+
753
+ check('sessions: wires the status snapshot into a running badge', () => {
754
+ const row = sessionRows.find((entry) => entry.sessionId === 's1');
755
+ truthy(row !== undefined, 's1 row');
756
+ equal(row.badge, { tone: 'running', label: '工作中' }, 'badge');
757
+ equal(row.status, { running: true }, 'status payload');
758
+ });
759
+ }
760
+
761
+ // ---------------------------------------------------------------------------
762
+ // Entry point
763
+ // ---------------------------------------------------------------------------
764
+
765
+ function main() {
766
+ let booted;
767
+ try {
768
+ booted = boot();
769
+ } catch (error) {
770
+ const message = oneLine(error instanceof Error ? error.message : String(error));
771
+ console.log(`FAIL boot: ${message}`);
772
+ console.log('\nSummary: 0 passed, 1 failed, 1 check');
773
+ console.log(`\nFailed checks:\n - boot: ${message}`);
774
+ return 1;
775
+ }
776
+ runChecks(booted);
777
+ return report();
778
+ }
779
+
780
+ process.exitCode = main();