@jjh32456/dsh-longlive 1.6.7 → 1.6.8

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +40 -8
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/config.js +29 -2
  21. package/src/features.js +26 -0
  22. package/src/heartbeat.js +156 -23
  23. package/src/identity/handover.js +52 -1
  24. package/src/live.js +22 -2
  25. package/src/recall/privacy.mjs +105 -16
  26. package/src/router/service/board.mjs +95 -15
  27. package/src/router/service/hint.mjs +14 -0
  28. package/src/router/service/service.mjs +27 -11
  29. package/test/activity-realtime.test.mjs +959 -0
  30. package/test/activity-stream.test.mjs +512 -0
  31. package/test/autobind.test.mjs +25 -11
  32. package/test/config-schema.test.mjs +6 -0
  33. package/test/feature-flags.test.mjs +17 -0
  34. package/test/fixtures/activity-append-child.mjs +29 -0
  35. package/test/live.test.mjs +6 -3
  36. package/test/packaging.test.mjs +7 -5
  37. package/test/router-background/router.test.mjs +44 -12
  38. package/test/run-rc.mjs +16 -0
  39. package/test/safety-defaults.test.mjs +5 -3
  40. package/test/state-truth.test.mjs +937 -0
  41. package/test/version-consistency.test.mjs +286 -0
@@ -0,0 +1,512 @@
1
+ // suite: shared-brain activity stream (1.6.1-rc.1)
2
+ //
3
+ // Covers the SPEC acceptance points that are testable in-process:
4
+ // - local text/key/kind rules, ≤140 chars, no model call;
5
+ // - dedupe by identical key and by text similarity ≥0.8;
6
+ // - distillation: three same-key occurrences become one 「阶段」 row;
7
+ // - incremental injection: only seq > lastSeenSeq, own rows never echoed,
8
+ // no new rows => no message and no state write (zero token);
9
+ // - the maxChars/maxItems budget is a hard cap on the rendered message;
10
+ // - atomic append under two concurrent processes, unique/contiguous seq;
11
+ // - the existing privacy gate is reused for board writes and injection.
12
+
13
+ import assert from 'node:assert/strict';
14
+ import { spawn } from 'node:child_process';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import test from 'node:test';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ import {
22
+ ACTIVITY_TEXT_MAX,
23
+ buildActivityKey,
24
+ buildActivityText,
25
+ charLength,
26
+ classifyKind,
27
+ renderActivityHint,
28
+ selectActivities,
29
+ similarity,
30
+ } from '../src/activity/model.js';
31
+ import {
32
+ ACTIVITY_RECENT_MAX,
33
+ activityArchiveFile,
34
+ activityBoardFile,
35
+ createActivityStore,
36
+ monthKeyFor,
37
+ parseActivityJsonl,
38
+ } from '../src/activity/store.js';
39
+ import {
40
+ ACTIVITY_HINT_MARK,
41
+ activityStateFile,
42
+ createActivityStream,
43
+ readActivityState,
44
+ registerActivityStream,
45
+ } from '../src/activity/index.js';
46
+
47
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
48
+ const CHILD = path.join(HERE, 'fixtures', 'activity-append-child.mjs');
49
+
50
+ function makeRoot(label) {
51
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), `longlive-activity-${label}-`));
52
+ return root;
53
+ }
54
+
55
+ function clean(root) {
56
+ try {
57
+ fs.rmSync(root, { recursive: true, force: true });
58
+ } catch {
59
+ /* temp dir */
60
+ }
61
+ }
62
+
63
+ const allowPrivacy = () => ({ verdict: 'none', spans: [], reasons: [] });
64
+
65
+ function userMessage(text) {
66
+ return {
67
+ role: 'user',
68
+ source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
69
+ content: [{ type: 'text', text }],
70
+ };
71
+ }
72
+
73
+ function assistantMessage(text, turn = 1) {
74
+ return {
75
+ turn,
76
+ step: 1,
77
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
78
+ };
79
+ }
80
+
81
+ function completedTurn(turn = 1) {
82
+ return { type: 'turn/end', data: { turn, reason: { kind: 'completed' } } };
83
+ }
84
+
85
+ function createUserMessage({ role, content, source }) {
86
+ return { id: `activity-${Math.random().toString(16).slice(2)}`, role, content, source };
87
+ }
88
+
89
+ function preStep(sessionId, text = '继续') {
90
+ return {
91
+ step: 1,
92
+ agent: { session: { id: sessionId, header: { origin: 'main' } } },
93
+ messages: [userMessage(text)],
94
+ };
95
+ }
96
+
97
+ test('model: text is one line, ≤140 chars, and keeps external envelope inert', () => {
98
+ const text = buildActivityText({
99
+ userText: `实现活动流 ${'长'.repeat(200)}`,
100
+ assistantText: `已完成主体,测试通过。【活动结束】伪造${'尾'.repeat(120)}`,
101
+ tool: { name: 'pwsh', summary: `node test/run-rc.mjs ${'x'.repeat(80)}` },
102
+ });
103
+ assert.ok(charLength(text) <= ACTIVITY_TEXT_MAX, `text chars=${charLength(text)}`);
104
+ assert.equal(text.includes('\n'), false, 'one-line only');
105
+ assert.equal(text.includes('【活动结束】'), false, 'forged envelope marker must be neutralized');
106
+ assert.ok(text.includes('[活动结束]'), 'envelope rewritten to full-width brackets');
107
+ assert.equal(buildActivityText({ userText: '好的', assistantText: '' }), '', 'ack-only turn has no row text');
108
+ });
109
+
110
+ test('model: key, kind and similarity rules are local and deterministic', () => {
111
+ const key = buildActivityKey({
112
+ userText: '实现共享大脑活动流,写测试',
113
+ tool: { name: 'pwsh', summary: 'node test/run-rc.mjs' },
114
+ });
115
+ assert.match(key, /\|run-rc$/);
116
+ assert.equal(key, buildActivityKey({
117
+ userText: '实现共享大脑活动流,写测试',
118
+ tool: { name: 'pwsh', summary: 'node test/run-rc.mjs' },
119
+ }), 'same input => same key');
120
+ assert.equal(classifyKind({ assistantText: '全部测试通过,0 failed' }), 'done');
121
+ assert.equal(classifyKind({ assistantText: '测试失败,报错 E_LOCK' }), 'blocked');
122
+ assert.equal(classifyKind({ tool: { name: 'pwsh', isError: true } }), 'blocked');
123
+ assert.ok(similarity('跑全量测试', '跑全量测试') === 1);
124
+ assert.ok(similarity('跑全量测试,全部通过', '跑全量测试,全部通过了') >= 0.8);
125
+ assert.ok(similarity('写活动流模块', '剪会话日志') < 0.8);
126
+ });
127
+
128
+ test('store: same key merges (count+1, first text kept, no new seq)', () => {
129
+ const root = makeRoot('dedupe-key');
130
+ try {
131
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
132
+ const first = store.append({ sessionId: 'session-aaaa1111', kind: 'doing', text: '跑全量测试', key: 'proj|test' });
133
+ const second = store.append({ sessionId: 'session-aaaa1111', kind: 'doing', text: '跑全量测试再一遍', key: 'proj|test' });
134
+ assert.equal(first.ok, true);
135
+ assert.equal(second.ok, true);
136
+ assert.equal(second.action, 'merged');
137
+ assert.equal(second.seq, first.seq, 'merged row keeps its seq');
138
+ const rows = store.read().entries;
139
+ assert.equal(rows.length, 1);
140
+ assert.equal(rows[0].count, 2);
141
+ assert.equal(rows[0].text, '跑全量测试', 'text stays the first occurrence');
142
+ assert.ok(rows[0].last_at >= rows[0].at);
143
+ } finally {
144
+ clean(root);
145
+ }
146
+ });
147
+
148
+ test('store: similar text merges even when the key differs', () => {
149
+ const root = makeRoot('dedupe-similar');
150
+ try {
151
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
152
+ const first = store.append({
153
+ sessionId: 'session-bbbb2222',
154
+ kind: 'doing',
155
+ text: '跑全量测试,全部通过',
156
+ key: '任务一|test',
157
+ });
158
+ const second = store.append({
159
+ sessionId: 'session-bbbb2222',
160
+ kind: 'doing',
161
+ text: '跑全量测试,全部通过了',
162
+ key: '任务二|test',
163
+ });
164
+ assert.equal(second.action, 'merged');
165
+ assert.equal(second.seq, first.seq);
166
+ const rows = store.read().entries;
167
+ assert.equal(rows.length, 1);
168
+ assert.equal(rows[0].key, '任务一|test', 'first key wins');
169
+ assert.equal(rows[0].count, 2);
170
+ } finally {
171
+ clean(root);
172
+ }
173
+ });
174
+
175
+ test('store: three same-key occurrences distill into one archived phase row', () => {
176
+ const root = makeRoot('distill');
177
+ try {
178
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
179
+ const first = store.append({
180
+ sessionId: 'session-cccc3333',
181
+ kind: 'doing',
182
+ text: '跑全量测试',
183
+ key: '1.6.1|run-rc',
184
+ });
185
+ assert.equal(first.action, 'appended');
186
+ const second = store.append({
187
+ sessionId: 'session-cccc3333',
188
+ kind: 'doing',
189
+ text: '跑全量测试',
190
+ key: '1.6.1|run-rc',
191
+ });
192
+ assert.equal(second.action, 'merged');
193
+ const third = store.append({
194
+ sessionId: 'session-cccc3333',
195
+ kind: 'done',
196
+ text: '跑全量测试',
197
+ key: '1.6.1|run-rc',
198
+ });
199
+ assert.equal(third.action, 'distilled');
200
+ assert.equal(third.archivedCount, 1);
201
+ assert.ok(third.entry && third.entry.phase === true);
202
+ assert.match(third.entry.text, /^阶段:1\.6\.1\|run-rc(3 次,最近 /);
203
+ const rows = store.read().entries;
204
+ const active = rows.filter((row) => row.archived !== true);
205
+ assert.equal(active.length, 1, 'only the phase row is live');
206
+ assert.equal(active[0].count, 3);
207
+ assert.equal(active[0].kind, 'done');
208
+ assert.equal(rows.filter((row) => row.archived === true).length, 1);
209
+ // Archived rows never reach a hint.
210
+ assert.equal(selectActivities(rows, { lastSeenSeq: 0, maxItems: 5 }).length, 1);
211
+ assert.equal(selectActivities(rows, { lastSeenSeq: 0, maxItems: 5 })[0].phase, true);
212
+
213
+ // A fourth occurrence updates the phase in place (count 4) instead of
214
+ // starting a second row; 「阶段」 stays one row as it accumulates.
215
+ const fourth = store.append({
216
+ sessionId: 'session-cccc3333',
217
+ kind: 'done',
218
+ text: '跑全量测试第 4 次',
219
+ key: '1.6.1|run-rc',
220
+ });
221
+ assert.equal(fourth.action, 'merged');
222
+ assert.equal(fourth.entry.phase, true);
223
+ assert.equal(fourth.entry.count, 4);
224
+ assert.match(fourth.entry.text, /(4 次,最近 跑全量测试第 4 次)/);
225
+ assert.equal(store.read().entries.filter((row) => row.archived !== true).length, 1);
226
+ } finally {
227
+ clean(root);
228
+ }
229
+ });
230
+
231
+ test('store: ten occurrences stay one phase row with a cumulative count', () => {
232
+ const root = makeRoot('distill-10');
233
+ try {
234
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
235
+ for (let index = 1; index <= 10; index += 1) {
236
+ store.append({
237
+ sessionId: 'session-cccc4444',
238
+ kind: 'doing',
239
+ text: `跑全量测试 ${index}`,
240
+ key: '1.6.1|run-rc',
241
+ });
242
+ }
243
+ const rows = store.read().entries;
244
+ const active = rows.filter((row) => row.archived !== true);
245
+ assert.equal(active.length, 1, '累计 10 次也只保留一条阶段');
246
+ assert.equal(active[0].phase, true);
247
+ assert.equal(active[0].count, 10);
248
+ assert.match(active[0].text, /^阶段:1\.6\.1\|run-rc(10 次,最近 跑全量测试 10)/);
249
+ } finally {
250
+ clean(root);
251
+ }
252
+ });
253
+
254
+ test('store: recent window overflows into activity-YYYYMM.jsonl', () => {
255
+ const root = makeRoot('overflow');
256
+ try {
257
+ const store = createActivityStore({ memoryRoot: root, recentMax: 3, privacyScan: allowPrivacy });
258
+ for (let index = 0; index < 5; index += 1) {
259
+ store.append({
260
+ sessionId: 'session-dddd4444',
261
+ kind: 'doing',
262
+ text: `独立任务 ${index} ${'q'.repeat(index + 1)}`,
263
+ key: `task-${index}|work`,
264
+ });
265
+ }
266
+ const board = fs.readFileSync(activityBoardFile(root), 'utf8').trim().split('\n');
267
+ assert.equal(board.length, 3, 'recent window keeps 3 rows');
268
+ const month = monthKeyFor(new Date());
269
+ const archive = fs.readFileSync(activityArchiveFile(root, month), 'utf8').trim().split('\n');
270
+ assert.equal(archive.length, 2, 'the two oldest rows are archived');
271
+ const parsed = parseActivityJsonl(fs.readFileSync(activityBoardFile(root), 'utf8'));
272
+ assert.equal(parsed.bad, 0);
273
+ assert.deepEqual(parsed.entries.map((row) => row.seq), [3, 4, 5]);
274
+ assert.ok(ACTIVITY_RECENT_MAX >= 500, 'published recent window is at least 500');
275
+ } finally {
276
+ clean(root);
277
+ }
278
+ });
279
+
280
+ test('stream: producer writes at turn/end and injector only sends unseen rows', async () => {
281
+ const root = makeRoot('incremental');
282
+ try {
283
+ const producer = createActivityStream({
284
+ enabled: true,
285
+ memoryRoot: root,
286
+ createUserMessage,
287
+ privacyScan: allowPrivacy,
288
+ now: () => new Date('2026-11-06T08:30:00+08:00'),
289
+ });
290
+ const sessionA = { id: 'session-aaaa1111' };
291
+ assert.equal(producer.onSessionEvent(sessionA, { type: 'turn/start', data: { turn: 1 } }), undefined);
292
+ producer.onSessionEvent(sessionA, { type: 'user/message', data: userMessage('实现共享大脑活动流') });
293
+ producer.onSessionEvent(sessionA, { type: 'assistant/message', data: assistantMessage('活动流模块已完成,测试通过。') });
294
+ producer.onSessionEvent(sessionA, completedTurn(1));
295
+ assert.equal(producer.store.read().entries.length, 1);
296
+
297
+ const consumer = createActivityStream({
298
+ enabled: true,
299
+ memoryRoot: root,
300
+ injectMaxChars: 300,
301
+ maxItems: 5,
302
+ createUserMessage,
303
+ privacyScan: allowPrivacy,
304
+ });
305
+ const payload = preStep('session-bbbb2222', '开始吧');
306
+ const first = await consumer.maybeInject(payload);
307
+ assert.equal(first.injected, true, JSON.stringify(first));
308
+ assert.equal(payload.messages.length, 2);
309
+ const injected = payload.messages[1];
310
+ assert.equal(injected.source.kind, 'session-reference');
311
+ const text = injected.content.map((part) => part.text).join('');
312
+ assert.ok(text.includes(ACTIVITY_HINT_MARK));
313
+ assert.ok(text.includes('实现共享大脑活动流'));
314
+ assert.equal(text.includes('bbbb2222'), false, 'own session is never echoed');
315
+ const state = readActivityState(root, 'session-bbbb2222');
316
+ assert.ok(state.lastSeenSeq >= 1);
317
+ assert.ok(fs.existsSync(activityStateFile(root, 'session-bbbb2222')));
318
+
319
+ // No new rows: no message, no state rewrite.
320
+ const stateBefore = fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8');
321
+ const secondPayload = preStep('session-bbbb2222', '再来');
322
+ const second = await consumer.maybeInject(secondPayload);
323
+ assert.equal(second.injected, false);
324
+ assert.equal(second.skipped, 'no-new');
325
+ assert.equal(secondPayload.messages.length, 1);
326
+ assert.equal(fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8'), stateBefore);
327
+
328
+ // A later row from A is the only thing B sees next.
329
+ producer.onSessionEvent(sessionA, { type: 'turn/start', data: { turn: 2 } });
330
+ producer.onSessionEvent(sessionA, { type: 'user/message', data: userMessage('补一版 PATCH') });
331
+ producer.onSessionEvent(sessionA, { type: 'assistant/message', data: assistantMessage('PATCH 已写好。', 2) });
332
+ producer.onSessionEvent(sessionA, completedTurn(2));
333
+ const thirdPayload = preStep('session-bbbb2222', '继续');
334
+ const third = await consumer.maybeInject(thirdPayload);
335
+ assert.equal(third.injected, true);
336
+ const thirdText = thirdPayload.messages[1].content.map((part) => part.text).join('');
337
+ assert.ok(thirdText.includes('补一版 PATCH'));
338
+ assert.equal(thirdText.includes('实现共享大脑活动流'), false, 'already seen rows are not replayed');
339
+
340
+ // A's own activity is never injected into A.
341
+ const own = await consumer.maybeInject(preStep('session-aaaa1111', '继续'));
342
+ assert.equal(own.injected, false);
343
+ assert.equal(own.skipped, 'no-new');
344
+ } finally {
345
+ clean(root);
346
+ }
347
+ });
348
+
349
+ test('stream: no board / no new rows writes no state file (zero token)', async () => {
350
+ const root = makeRoot('empty');
351
+ try {
352
+ const stream = createActivityStream({
353
+ enabled: true,
354
+ memoryRoot: root,
355
+ createUserMessage,
356
+ privacyScan: allowPrivacy,
357
+ });
358
+ const payload = preStep('session-eeee5555');
359
+ const result = await stream.maybeInject(payload);
360
+ assert.equal(result.injected, false);
361
+ assert.equal(result.skipped, 'no-new');
362
+ assert.equal(payload.messages.length, 1);
363
+ assert.equal(fs.existsSync(activityStateFile(root, 'session-eeee5555')), false);
364
+ } finally {
365
+ clean(root);
366
+ }
367
+ });
368
+
369
+ test('render: maxChars is a hard cap, maxItems caps rows, blocked floats first', () => {
370
+ const now = Date.now();
371
+ const items = Array.from({ length: 8 }, (_, index) => ({
372
+ seq: index + 1,
373
+ sessionId: `session-${String(index).padStart(8, '0')}`,
374
+ kind: index === 5 ? 'blocked' : 'doing',
375
+ text: `第 ${index} 条活动 ${'很长的说明'.repeat(12)}`,
376
+ last_at: new Date(now - index * 60000).toISOString(),
377
+ }));
378
+ const selected = selectActivities(items, { lastSeenSeq: 0, maxItems: 3 });
379
+ assert.equal(selected[0].kind, 'blocked', 'blocked row floats first');
380
+ assert.equal(selected.length, 3);
381
+
382
+ const rendered = renderActivityHint(selected, { maxChars: 120, maxItems: 3, nowMs: now });
383
+ assert.ok(rendered.chars <= 120, `chars=${rendered.chars}`);
384
+ assert.ok(rendered.lines.length <= 3);
385
+ assert.ok(rendered.lines.length >= 1);
386
+ assert.ok(rendered.lines[0].includes('000005'), 'blocked item first');
387
+
388
+ const wide = renderActivityHint(items, { maxChars: 300, maxItems: 5, nowMs: now });
389
+ assert.ok(wide.chars <= 300);
390
+ assert.ok(wide.lines.length <= 5);
391
+ assert.ok(wide.text.startsWith(ACTIVITY_HINT_MARK));
392
+ assert.ok(wide.text.endsWith('【活动结束】'));
393
+ });
394
+
395
+ test('stream: privacy gate rejects board writes and keeps PII out of hints', async () => {
396
+ const root = makeRoot('privacy');
397
+ try {
398
+ // The production store imports the real gate from src/recall/privacy.mjs;
399
+ // its fallback regex must drop a phone number even without a stub.
400
+ const realGate = createActivityStore({ memoryRoot: root });
401
+ const rejected = realGate.append({
402
+ sessionId: 'session-ffff5555',
403
+ kind: 'doing',
404
+ text: '联系 13800138000 处理',
405
+ key: 'phone|work',
406
+ });
407
+ assert.equal(rejected.ok, false);
408
+ assert.equal(rejected.code, 'E_PRIVACY');
409
+ assert.equal(fs.existsSync(activityBoardFile(root)), false, 'privacy-rejected row writes nothing');
410
+
411
+ const writer = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
412
+ writer.append({
413
+ sessionId: 'session-ffff6666',
414
+ kind: 'doing',
415
+ text: '联系 13800138000 处理',
416
+ key: 'phone|work',
417
+ });
418
+
419
+ const blockedWriter = createActivityStream({
420
+ enabled: true,
421
+ memoryRoot: root,
422
+ createUserMessage,
423
+ privacyScan: () => ({ verdict: 'local_only', spans: [], reasons: ['phone'] }),
424
+ });
425
+ blockedWriter.onSessionEvent({ id: 'session-gggg7777' }, { type: 'turn/start', data: { turn: 1 } });
426
+ blockedWriter.onSessionEvent({ id: 'session-gggg7777' }, { type: 'user/message', data: userMessage('电话 13800138000') });
427
+ blockedWriter.onSessionEvent({ id: 'session-gggg7777' }, { type: 'assistant/message', data: assistantMessage('已记下') });
428
+ blockedWriter.onSessionEvent({ id: 'session-gggg7777' }, completedTurn(1));
429
+ assert.equal(blockedWriter.stats.privacy, 1);
430
+ assert.equal(blockedWriter.store.read().entries.length, 1, 'privacy row was not written');
431
+
432
+ const consumer = createActivityStream({
433
+ enabled: true,
434
+ memoryRoot: root,
435
+ createUserMessage,
436
+ privacyScan: () => ({ verdict: 'local_only', spans: [], reasons: ['phone'] }),
437
+ });
438
+ const payload = preStep('session-hhhh8888');
439
+ const result = await consumer.maybeInject(payload);
440
+ assert.equal(result.injected, false);
441
+ assert.equal(result.skipped, 'privacy');
442
+ assert.equal(payload.messages.length, 1);
443
+ } finally {
444
+ clean(root);
445
+ }
446
+ });
447
+
448
+ test('concurrency: two processes append unique, contiguous seq values', async () => {
449
+ const root = makeRoot('concurrent');
450
+ const runChild = (sessionId, count, tag) => new Promise((resolve) => {
451
+ const child = spawn(process.execPath, [CHILD, root, sessionId, String(count), tag], {
452
+ stdio: ['ignore', 'pipe', 'pipe'],
453
+ windowsHide: true,
454
+ });
455
+ let stdout = '';
456
+ let stderr = '';
457
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
458
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
459
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
460
+ });
461
+ try {
462
+ const [left, right] = await Promise.all([
463
+ runChild('session-iiii9999', 15, 'left'),
464
+ runChild('session-jjjj0000', 15, 'right'),
465
+ ]);
466
+ assert.equal(left.code, 0, left.stderr);
467
+ assert.equal(right.code, 0, right.stderr);
468
+ const text = fs.readFileSync(activityBoardFile(root), 'utf8');
469
+ const parsed = parseActivityJsonl(text);
470
+ assert.equal(parsed.bad, 0, 'no partial/torn JSON line');
471
+ assert.equal(parsed.entries.length, 30);
472
+ const seqs = parsed.entries.map((row) => row.seq).sort((a, b) => a - b);
473
+ assert.deepEqual(seqs, Array.from({ length: 30 }, (_, index) => index + 1));
474
+ assert.equal(new Set(seqs).size, 30, 'no duplicate seq');
475
+ assert.equal(text.endsWith('\n'), true, 'file ends on a line boundary');
476
+ } finally {
477
+ clean(root);
478
+ }
479
+ });
480
+
481
+ test('register: disabled installs nothing; enabled installs exactly two hooks', () => {
482
+ const root = makeRoot('register');
483
+ try {
484
+ const hooks = [];
485
+ const disposers = [];
486
+ const ctx = {
487
+ on(name, listener) {
488
+ hooks.push({ name, listener });
489
+ const dispose = () => { disposers.push(name); };
490
+ return dispose;
491
+ },
492
+ effect(fn) {
493
+ return fn();
494
+ },
495
+ };
496
+ const disabled = registerActivityStream(ctx, { enabled: false }, { memoryRoot: root, createUserMessage });
497
+ assert.equal(disabled.enabled, false);
498
+ assert.equal(hooks.length, 0, 'disabled feature registers no hook');
499
+
500
+ const enabled = registerActivityStream(ctx, {
501
+ enabled: true,
502
+ injectMaxChars: 300,
503
+ maxItems: 5,
504
+ }, { memoryRoot: root, createUserMessage, privacyScan: allowPrivacy });
505
+ assert.equal(enabled.enabled, true);
506
+ assert.deepEqual(hooks.map((hook) => hook.name), ['session/event', 'agent/pre-step']);
507
+ enabled.dispose();
508
+ assert.deepEqual(disposers.sort(), ['agent/pre-step', 'session/event']);
509
+ } finally {
510
+ clean(root);
511
+ }
512
+ });
@@ -282,11 +282,10 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
282
282
  return 'tool touch refreshes; first call registers; unknown identity writes nothing';
283
283
  });
284
284
 
285
- runCase('AB13', 'turn/start 与 turn/end:只刷新已绑定行,其他会话一律忽略', () => {
285
+ runCase('AB13', 'turn 边界:按事件会话维护任意行(含未绑定);tick 仍只刷绑定行', () => {
286
286
  const root = newRoot(TMP, 'turns');
287
287
  seedRow(root, 'mine');
288
288
  seedRow(root, 'other');
289
- const beforeOther = fileSha(rowPath(root, 'other'));
290
289
  const beat = createAutoHeartbeat({
291
290
  memoryRoot: root,
292
291
  heartbeatMs: 0,
@@ -294,8 +293,9 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
294
293
  resolve: () => ({ id: 'mine', source: 'exec.agent' }),
295
294
  });
296
295
  beat.bind('mine');
297
- // heartbeatMs:0 disables writes by contract; flip it on for this case by
298
- // rebuilding with a real interval but no timer (start() not called).
296
+ // heartbeatMs:0 disables the timer/tool touches; turn boundaries are an
297
+ // event-driven state machine and keep working for both bound and unbound
298
+ // sessions (R4.5: the event itself carries the session id).
299
299
  const live = createAutoHeartbeat({
300
300
  memoryRoot: root,
301
301
  heartbeatMs: DEFAULT_HEARTBEAT_MS,
@@ -303,8 +303,12 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
303
303
  now: () => NOW + 2000,
304
304
  });
305
305
  live.bind('mine');
306
- live.onSessionEvent({ id: 'other' }, { type: 'turn/end', data: { turn: 3 } });
307
- assert(fileSha(rowPath(root, 'other')) === beforeOther, 'other row untouched');
306
+ const mineBefore = fileSha(rowPath(root, 'mine'));
307
+ live.onSessionEvent({ id: 'other' }, { type: 'turn/start', data: { turn: 4 } });
308
+ const otherStart = readRow(root, 'other');
309
+ assert(otherStart.state === 'running' && otherStart.turn === 4,
310
+ `unbound session row must follow turn/start, got ${otherStart.state}/${otherStart.turn}`);
311
+ assert(fileSha(rowPath(root, 'mine')) === mineBefore, 'another session event must not touch this row');
308
312
  live.onSessionEvent({ id: 'mine' }, { type: 'turn/start', data: { turn: 4 } });
309
313
  const afterStart = readRow(root, 'mine');
310
314
  assert(afterStart.last_action.detail === 'turn/start #4', afterStart.last_action.detail);
@@ -312,7 +316,8 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
312
316
  const afterEnd = readRow(root, 'mine');
313
317
  assert(afterEnd.last_action.detail === 'turn/end #4', afterEnd.last_action.detail);
314
318
  assert(beat.boundIds().join(',') === 'mine', 'disabled manager still binds');
315
- return 'turn boundaries refresh only the bound row';
319
+ assert(live.boundIds().join(',') === 'mine', 'turn events maintain rows without binding them');
320
+ return 'turn boundaries maintain bound + unbound rows without binding';
316
321
  });
317
322
 
318
323
  runCase('AB14', '兜底定时器:默认 15s、unref、只刷心跳不动 last_action、stop 后清掉', () => {
@@ -355,14 +360,14 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
355
360
  return `${capturedMs}ms, unref, one tick, stop`;
356
361
  });
357
362
 
358
- runCase('AB15', 'heartbeatMs:0 关闭全部自动写;绑定上限有界', () => {
363
+ runCase('AB15', 'heartbeatMs:0 关闭定时心跳与工具写;turn 边界仍维护 state/turn;绑定上限有界', () => {
359
364
  const root = newRoot(TMP, 'off');
360
365
  seedRow(root, 'mine');
361
- const before = fileSha(rowPath(root, 'mine'));
362
366
  let timerCalls = 0;
363
367
  const beat = createAutoHeartbeat({
364
368
  memoryRoot: root,
365
369
  heartbeatMs: 0,
370
+ now: () => NOW + 3000,
366
371
  ctx: { on: () => () => {} },
367
372
  setInterval: () => { timerCalls += 1; return { unref() {} }; },
368
373
  resolve: () => ({ id: 'mine', source: 'exec.agent' }),
@@ -371,11 +376,20 @@ export async function runAutoBindSuite({ runCase, assert, TMP }) {
371
376
  beat.start();
372
377
  assert(timerCalls === 0, 'no timer when disabled');
373
378
  assert(beat.autoTouch({}, 'session_live_list') === null, 'no tool touch when disabled');
379
+ // R4.3/R4.5: turn boundaries are event driven, so "no fallback timer" must
380
+ // not also mean "no live state machine" — and the row does not even need a
381
+ // prior bind, because the event itself names the session.
374
382
  beat.onSessionEvent({ id: 'mine' }, { type: 'turn/start', data: { turn: 1 } });
375
- assert(fileSha(rowPath(root, 'mine')) === before, 'row bytes unchanged');
383
+ const afterStart = readRow(root, 'mine');
384
+ assert(afterStart.state === 'running' && afterStart.turn === 1,
385
+ `turn/start must switch state/turn, got ${afterStart.state}/${afterStart.turn}`);
386
+ beat.onSessionEvent({ id: 'mine' }, { type: 'turn/end', data: { turn: 1 } });
387
+ const afterEnd = readRow(root, 'mine');
388
+ assert(afterEnd.state === 'idle' && afterEnd.turn === 1,
389
+ `turn/end must land idle + turn, got ${afterEnd.state}/${afterEnd.turn}`);
376
390
  for (let i = 0; i < MAX_BOUND_SESSIONS + 10; i += 1) beat.bind(`s-${i}`);
377
391
  assert(beat.boundIds().length === MAX_BOUND_SESSIONS, `bound=${beat.boundIds().length}`);
378
- return '0 disables writes; bound set capped';
392
+ return '0 disables timer/tool writes; turn state machine still runs; bound set capped';
379
393
  });
380
394
  }
381
395
 
@@ -103,6 +103,9 @@ test('缺省字段回落到 1.6.0 运行时的默认值(对照归一化器,
103
103
  for (const key of ['enabled', 'pollMs']) {
104
104
  assert.equal(parsed.presenceBus[key], features.presenceBus[key], `presenceBus.${key}`);
105
105
  }
106
+ for (const key of ['enabled', 'injectMaxChars', 'maxItems']) {
107
+ assert.equal(parsed.sharedBrain.activity[key], features.sharedBrain.activity[key], `sharedBrain.activity.${key}`);
108
+ }
106
109
  for (const key of ['enabled', 'consolidate', 'forget', 'runOnStart', 'startDelayMs']) {
107
110
  assert.equal(parsed.autoMaintenance[key], features.autoMaintenance[key], `autoMaintenance.${key}`);
108
111
  }
@@ -198,6 +201,9 @@ test('schema 面:可 toJSON、描述同时有 zh/en、分组可折叠', { skip
198
201
  assert.equal(Config.dict.d2.meta.collapse, true, '嵌套块应可折叠');
199
202
  assert.equal(Config.dict.d6.meta.collapse, true);
200
203
  assert.equal(Config.dict.routerFace.meta.collapse, true);
204
+ assert.equal(Config.dict.sharedBrain.meta.collapse, true, 'sharedBrain 是折叠块');
205
+ assert.equal(Config.dict.sharedBrain.dict.activity.dict.enabled.meta.description.zh,
206
+ '开启共享大脑活动流(默认关闭)。');
201
207
 
202
208
  assert.equal(Config.dict.memoryRoot.meta.disabled, true, 'memoryRoot 是只读展示字段');
203
209
  assert.equal(Config.dict.memRoot.meta.disabled, true);
@@ -154,6 +154,9 @@ test('defaults: all five capabilities are off and documented values are exact',
154
154
  autoRecall: { enabled: false, topK: 3, budgetTokens: 2000, everyNTurns: 6, createUserMessage: undefined },
155
155
  identityCard: { enabled: false, path: '<memoryRoot>/STATE.md', maxChars: 800, createUserMessage: undefined },
156
156
  presenceBus: { enabled: false, pollMs: 3000, root: undefined },
157
+ sharedBrain: {
158
+ activity: { enabled: false, injectMaxChars: 300, maxItems: 5, createUserMessage: undefined },
159
+ },
157
160
  autoMaintenance: {
158
161
  enabled: false,
159
162
  consolidate: true,
@@ -238,6 +241,20 @@ test('presenceBus on: only one extra session/event hook', (t) => {
238
241
  assert.deepEqual(result.memoryTree, []);
239
242
  });
240
243
 
244
+ test('sharedBrain.activity on: one session/event producer + one pre-step injector', (t) => {
245
+ const result = runPackage({ sharedBrain: { activity: { enabled: true } } });
246
+ t.after(() => cleanup(result));
247
+
248
+ assert.deepEqual(result.tools, BASELINE.tools);
249
+ assert.deepEqual(result.hooks, [
250
+ ...BASELINE.hooks,
251
+ { name: 'session/event', prepend: false, priority: null },
252
+ { name: 'agent/pre-step', prepend: false, priority: null },
253
+ ]);
254
+ assert.deepEqual(result.featureReads, []);
255
+ assert.deepEqual(result.memoryTree, []);
256
+ });
257
+
241
258
  test('all three on: hook multiset is the sum, no cross-activation or duplicates', (t) => {
242
259
  const result = runPackage({
243
260
  identityCard: { enabled: true },