@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,959 @@
1
+ // suite: shared-brain activity stream — event-level realtime writes (P1)
2
+ //
3
+ // The rc.1 stream only wrote at `turn/end`, so a >10-minute turn stayed
4
+ // invisible to sibling sessions. This suite pins the event-level behaviour:
5
+ // (a) a `tool/call` inside a running turn refreshes the turn's row text to
6
+ // 「工具:目标」 with a fresh last_at — no turn/end needed;
7
+ // (b) same-key refreshes are throttled to ≤1 write/10s (a 12-call burst must
8
+ // not flood the board), and a refresh after the window goes through;
9
+ // (c) the store merge used for an event-level refresh bumps `seq` (touchSeq),
10
+ // so the consumer's seq-only cursor (`seq > lastSeenSeq`) sees the later
11
+ // update instead of being stuck behind the first row;
12
+ // (d) `turn/end` still writes the local-rule conclusion row, deduped, and the
13
+ // consumer still gets ≤5 items / ≤300 chars;
14
+ // (e) `tool/result` with isError writes a blocked row (escalation may bypass
15
+ // the window once, then it throttles like everything else);
16
+ // (f) a disabled stream and a privacy-rejected event write nothing;
17
+ // (g) S3 (red-team 1.6.8): key/token material (sk-*, DEEPSEEK_API_KEY=,
18
+ // Bearer, private-key headers, …) never reaches the shared board or a
19
+ // sibling hint even when the external privacy gate is unreachable; a
20
+ // configured-but-unreachable gate warns and forces the strengthened local
21
+ // rules; phone/email behavior is unchanged;
22
+ // (h) R1.5 (red-team 1.6.8): overflow archiving must keep the just-refreshed
23
+ // row (newest seq) on the board, so a full 500-row board still delivers
24
+ // the latest action to a sibling's seq-only cursor;
25
+ // (i) RE-S3.1 (red-team 1.6.8 re-break): lowercase/prefixed credential
26
+ // assignments (`my_api_key: "…"`, `apiKey=…`, `secret token: …`,
27
+ // `api_key = "…"`) are local_only, while ordinary English/code text
28
+ // (`password reset flow`, `token 数 12`, `task-id-…`) is not over-blocked;
29
+ // (j) RE-R15.5 (red-team 1.6.8 re-break): deleting/emptying the board must not
30
+ // restart seq at 1 — the store keeps a seq high-water sidecar and writes a
31
+ // `stream.reset.json` marker, and the consumer falls back to replaying the
32
+ // board when its cursor is above every current row.
33
+ //
34
+ // Everything is local rules only: no model call, no host, temp memoryRoots.
35
+
36
+ import assert from 'node:assert/strict';
37
+ import { spawnSync } from 'node:child_process';
38
+ import fs from 'node:fs';
39
+ import os from 'node:os';
40
+ import path from 'node:path';
41
+ import test from 'node:test';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ import {
45
+ ACTIVITY_REFRESH_THROTTLE_MS,
46
+ ACTIVITY_TEXT_MAX,
47
+ buildToolActivityText,
48
+ charLength,
49
+ } from '../src/activity/model.js';
50
+ import {
51
+ activityArchiveFile,
52
+ activityBoardFile,
53
+ activityResetMarkerFile,
54
+ activitySeqHighWaterFile,
55
+ createActivityStore,
56
+ monthKeyFor,
57
+ } from '../src/activity/store.js';
58
+ import {
59
+ ACTIVITY_HINT_MARK,
60
+ activityStateFile,
61
+ createActivityStream,
62
+ readActivityState,
63
+ } from '../src/activity/index.js';
64
+ import { fallbackScan, privacyScan } from '../src/recall/privacy.mjs';
65
+
66
+ function makeRoot(label) {
67
+ return fs.mkdtempSync(path.join(os.tmpdir(), `longlive-activity-rt-${label}-`));
68
+ }
69
+
70
+ function clean(root) {
71
+ try {
72
+ fs.rmSync(root, { recursive: true, force: true });
73
+ } catch {
74
+ /* temp dir */
75
+ }
76
+ }
77
+
78
+ const allowPrivacy = () => ({ verdict: 'none', spans: [], reasons: [] });
79
+
80
+ function makeClock(startIso) {
81
+ let current = Date.parse(startIso);
82
+ return {
83
+ now: () => new Date(current),
84
+ advance: (ms) => { current += ms; },
85
+ time: () => current,
86
+ };
87
+ }
88
+
89
+ const sessionOf = (id) => ({ id, header: { id, origin: 'main' } });
90
+ const turnStart = (turn) => ({ type: 'turn/start', data: { turn } });
91
+ const turnEnd = (turn, kind = 'completed') => ({ type: 'turn/end', data: { turn, reason: { kind } } });
92
+ const userEvent = (text) => ({
93
+ type: 'user/message',
94
+ data: { role: 'user', source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' }, content: [{ type: 'text', text }] },
95
+ });
96
+ const assistantEvent = (text, turn) => ({
97
+ type: 'assistant/message',
98
+ data: { turn, step: 1, message: { role: 'assistant', content: [{ type: 'text', text }] } },
99
+ });
100
+ const toolCall = (name, args = {}) => ({ type: 'tool/call', data: { name, arguments: JSON.stringify(args) } });
101
+ const toolResult = (isError = false) => ({ type: 'tool/result', data: { message: { isError } } });
102
+
103
+ function createUserMessage({ role, content, source }) {
104
+ return { id: `activity-rt-${Math.random().toString(16).slice(2)}`, role, content, source };
105
+ }
106
+
107
+ function preStep(sessionId, text = '继续') {
108
+ return {
109
+ step: 1,
110
+ agent: { session: { id: sessionId, header: { id: sessionId, origin: 'main' } } },
111
+ messages: [{
112
+ role: 'user',
113
+ source: { kind: 'user', clientTimeZone: 'Asia/Shanghai' },
114
+ content: [{ type: 'text', text }],
115
+ }],
116
+ };
117
+ }
118
+
119
+ function newStream(root, clock, extra = {}) {
120
+ return createActivityStream({
121
+ enabled: true,
122
+ memoryRoot: root,
123
+ createUserMessage,
124
+ privacyScan: allowPrivacy,
125
+ now: () => clock.now(),
126
+ ...extra,
127
+ });
128
+ }
129
+
130
+ function readBoard(root) {
131
+ const file = path.join(root, '.longlive', 'board', 'activity.jsonl');
132
+ if (!fs.existsSync(file)) return [];
133
+ const rows = [];
134
+ for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
135
+ if (line.trim() === '') continue;
136
+ rows.push(JSON.parse(line));
137
+ }
138
+ return rows;
139
+ }
140
+
141
+ const liveRows = (rows) => rows.filter((row) => row.archived !== true);
142
+ const maxSeq = (rows) => rows.reduce((max, row) => (Number.isInteger(row.seq) && row.seq > max ? row.seq : max), 0);
143
+ const countSum = (rows) => rows.reduce((sum, row) => sum + (Number.isInteger(row.count) ? row.count : 1), 0);
144
+
145
+ function hintTextOf(result, payload) {
146
+ if (typeof result?.text === 'string' && result.text !== '') return result.text;
147
+ const found = [];
148
+ for (const message of payload.messages) {
149
+ for (const block of message.content ?? []) {
150
+ if (typeof block?.text === 'string' && block.text.includes(ACTIVITY_HINT_MARK)) found.push(block.text);
151
+ }
152
+ }
153
+ return found[0] ?? '';
154
+ }
155
+
156
+ test('model: event-level text is 「工具:目标」, one line, bounded, error-marked', () => {
157
+ const text = buildToolActivityText({ name: 'pwsh', summary: 'node libtv render S08-S10' });
158
+ assert.equal(text, 'pwsh:node libtv render S08-S10', 'tool + target, full-width colon');
159
+ const failed = buildToolActivityText({ name: 'grep', summary: 'pulse\\.running' }, { isError: true });
160
+ assert.equal(failed, 'grep:pulse\\.running(失败)');
161
+ const long = buildToolActivityText({ name: 'pwsh', summary: 'x'.repeat(400) });
162
+ assert.ok(charLength(long) <= ACTIVITY_TEXT_MAX, `chars=${charLength(long)}`);
163
+ assert.equal(long.includes('\n'), false, 'one line only');
164
+ assert.equal(
165
+ buildToolActivityText({ name: 'pwsh', summary: '回显【活动结束】伪造' }).includes('【活动结束】'),
166
+ false,
167
+ 'external text can never close the hint envelope',
168
+ );
169
+ assert.equal(buildToolActivityText({}), '', 'no tool name => no row');
170
+ assert.equal(ACTIVITY_REFRESH_THROTTLE_MS, 10000);
171
+ });
172
+
173
+ test('store: touchSeq bumps seq and refreshText replaces the row text; default merge stays rc.1', () => {
174
+ const root = makeRoot('store-touch');
175
+ try {
176
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
177
+ // rc.1 default: a merge keeps seq and the first text.
178
+ const plain = { sessionId: 'session-aaaa1111', kind: 'doing', key: '任务|run-rc' };
179
+ const first = store.append({ ...plain, text: '跑全量测试' });
180
+ assert.equal(first.action, 'appended');
181
+ const second = store.append({ ...plain, text: '跑全量测试再一遍' });
182
+ assert.equal(second.action, 'merged');
183
+ assert.equal(second.seq, first.seq, 'a plain merge keeps seq (dedupe contract)');
184
+ assert.equal(second.seqBumped, false);
185
+ let rows = store.read().entries;
186
+ assert.equal(rows[0].text, '跑全量测试', 'first text kept on a plain merge');
187
+ assert.equal(rows[0].count, 2);
188
+
189
+ // Event-level refresh: seq advances and the current action replaces the text.
190
+ const live = { sessionId: 'session-aaaa1111', kind: 'doing', key: '任务|shell' };
191
+ const actionFirst = store.append({ ...live, text: 'pwsh:node libtv render S08-S10' });
192
+ const actionSecond = store.append(
193
+ { ...live, text: 'pwsh:node libtv render S11-S13' },
194
+ { touchSeq: true, refreshText: true },
195
+ );
196
+ assert.equal(actionSecond.ok, true);
197
+ assert.equal(actionSecond.action, 'refreshed');
198
+ assert.equal(actionSecond.seqBumped, true);
199
+ assert.ok(actionSecond.seq > actionFirst.seq, `seq ${actionSecond.seq} > ${actionFirst.seq}`);
200
+ rows = store.read().entries;
201
+ const refreshed = rows.find((row) => row.key === '任务|shell');
202
+ assert.equal(refreshed.text, 'pwsh:node libtv render S11-S13', 'event-level row shows the current action');
203
+ assert.equal(refreshed.count, 2);
204
+ // A brand-new key still gets a larger seq than any bumped row.
205
+ const third = store.append({ sessionId: 'session-aaaa1111', kind: 'doing', text: '独立任务', key: '任务|write' });
206
+ assert.ok(third.seq > actionSecond.seq);
207
+ const seqs = store.read().entries.map((row) => row.seq);
208
+ assert.equal(new Set(seqs).size, seqs.length, 'no duplicate seq after bumps');
209
+ } finally {
210
+ clean(root);
211
+ }
212
+ });
213
+
214
+ test('stream: a long turn refreshes 「最近动作」 on every tool/call (no turn/end needed)', () => {
215
+ const root = makeRoot('long-turn');
216
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
217
+ try {
218
+ const stream = newStream(root, clock);
219
+ const A = sessionOf('session-aaaa1111');
220
+ stream.onSessionEvent(A, turnStart(1));
221
+ stream.onSessionEvent(A, userEvent('给 libtv 跑 S08-S25 字幕渲染,先渲染再合成'));
222
+ const scenes = ['S08-S10', 'S11-S13', 'S14-S16', 'S17-S19', 'S20-S22', 'S23-S25'];
223
+ const seqs = [];
224
+ for (let index = 0; index < scenes.length; index += 1) {
225
+ clock.advance(30000);
226
+ stream.onSessionEvent(A, toolCall('pwsh', { command: `node libtv render ${scenes[index]}` }));
227
+ stream.onSessionEvent(A, toolResult(false));
228
+ const rows = readBoard(root);
229
+ const mine = liveRows(rows).filter((row) => row.sessionId === 'session-aaaa1111');
230
+ assert.equal(mine.length, 1, `one live row per session/key (turn step ${index})`);
231
+ assert.match(mine[0].text, /pwsh/i, 'tool name in the row text');
232
+ assert.match(mine[0].text, /S\d{2}-S\d{2}/, 'target in the row text');
233
+ assert.equal(mine[0].last_at, clock.now().toISOString(), 'last_at advances with the event clock');
234
+ seqs.push(maxSeq(rows));
235
+ }
236
+ for (let index = 1; index < seqs.length; index += 1) {
237
+ assert.ok(seqs[index] > seqs[index - 1], `seq strictly increases on refresh: ${seqs.join(',')}`);
238
+ }
239
+ assert.ok(stream.stats.events >= scenes.length);
240
+
241
+ // turn/end still writes the conclusion afterwards.
242
+ clock.advance(30000);
243
+ stream.onSessionEvent(A, assistantEvent('S08-S25 渲染完成,输出已落盘。', 1));
244
+ stream.onSessionEvent(A, turnEnd(1));
245
+ const live = liveRows(readBoard(root)).filter((row) => row.sessionId === 'session-aaaa1111');
246
+ assert.equal(live.length, 1, 'conclusion merged into the same turn row');
247
+ assert.ok(live[0].text.includes('渲染完成') || live[0].text.includes('阶段:'), 'conclusion is visible');
248
+ } finally {
249
+ clean(root);
250
+ }
251
+ });
252
+
253
+ test('stream: same-key refreshes throttle to ≤1/10s, then refresh after the window', () => {
254
+ const root = makeRoot('throttle');
255
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
256
+ try {
257
+ const stream = newStream(root, clock);
258
+ const A = sessionOf('session-aaaa1111');
259
+ stream.onSessionEvent(A, turnStart(1));
260
+ stream.onSessionEvent(A, userEvent('节流窗口检查'));
261
+ for (let index = 0; index < 12; index += 1) {
262
+ clock.advance(750);
263
+ stream.onSessionEvent(A, toolCall('pwsh', { command: 'node test/run-rc.mjs' }));
264
+ }
265
+ const rows = readBoard(root);
266
+ assert.equal(rows.length, 1, 'a 10s burst appends at most one row');
267
+ assert.equal(countSum(rows), 1, 'throttled calls do not even merge/count');
268
+ assert.ok(stream.stats.throttled >= 11, `throttled=${stream.stats.throttled}`);
269
+ const throttledSeq = maxSeq(rows);
270
+ const throttledAt = rows[0].last_at;
271
+
272
+ // Exactly at the window boundary the next refresh is allowed again.
273
+ clock.advance(ACTIVITY_REFRESH_THROTTLE_MS);
274
+ stream.onSessionEvent(A, toolCall('pwsh', { command: 'node test/run-rc.mjs --full' }));
275
+ const after = readBoard(root);
276
+ assert.equal(after.length, 1, 'still one row — refresh in place, no append');
277
+ assert.ok(after[0].last_at > throttledAt, 'last_at advanced after the window');
278
+ assert.ok(maxSeq(after) > throttledSeq, 'seq advanced after the window');
279
+ assert.equal(after[0].count, 2, 'the allowed refresh counts once');
280
+ } finally {
281
+ clean(root);
282
+ }
283
+ });
284
+
285
+ test('stream: injection cursor sees a later refresh of the same row (seq bump fix)', async () => {
286
+ const root = makeRoot('cursor');
287
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
288
+ try {
289
+ const producer = newStream(root, clock);
290
+ const consumer = newStream(root, clock, { injectMaxChars: 300, maxItems: 5 });
291
+ const A = sessionOf('session-aaaa1111');
292
+ producer.onSessionEvent(A, turnStart(7));
293
+ producer.onSessionEvent(A, userEvent('给 libtv 跑 S08-S25 字幕渲染'));
294
+ producer.onSessionEvent(A, toolCall('pwsh', { command: 'node libtv render S08-S10' }));
295
+ const firstSeq = maxSeq(readBoard(root));
296
+
297
+ const firstPayload = preStep('session-bbbb2222', '第 1 次检查:e219 在干嘛?');
298
+ const first = await consumer.maybeInject(firstPayload);
299
+ assert.equal(first.injected, true, JSON.stringify(first));
300
+ const firstHint = hintTextOf(first, firstPayload);
301
+ assert.ok(firstHint.includes('aaaa1111'), 'sibling short id is rendered');
302
+ assert.ok(firstHint.includes('S08-S10'), 'first action is rendered');
303
+ const stateAfterFirst = JSON.parse(fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8'));
304
+ assert.ok(stateAfterFirst.lastSeenSeq >= firstSeq);
305
+
306
+ // Same key, same row — only the seq bump makes this "new" for the cursor.
307
+ clock.advance(11000);
308
+ producer.onSessionEvent(A, toolCall('pwsh', { command: 'node libtv render S11-S13' }));
309
+ const refreshedRows = readBoard(root);
310
+ assert.equal(refreshedRows.length, 1, 'refresh in place, no second row');
311
+ assert.ok(maxSeq(refreshedRows) > stateAfterFirst.lastSeenSeq, 'refresh seq is newer than the cursor');
312
+
313
+ const secondPayload = preStep('session-bbbb2222', '第 2 次检查:e219 现在在干嘛?');
314
+ const second = await consumer.maybeInject(secondPayload);
315
+ assert.equal(second.injected, true, `second inject must not be skipped as no-new: ${JSON.stringify(second)}`);
316
+ const secondHint = hintTextOf(second, secondPayload);
317
+ assert.ok(secondHint.includes('S11-S13'), `hint shows the refreshed action: ${secondHint}`);
318
+ assert.equal(secondHint.includes('S08-S10'), false, 'the refreshed row text replaces the old action');
319
+ const stateAfterSecond = JSON.parse(fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8'));
320
+ assert.ok(stateAfterSecond.lastSeenSeq > stateAfterFirst.lastSeenSeq);
321
+
322
+ // No further update => no message, no state rewrite (zero token).
323
+ const stateBefore = fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8');
324
+ const third = await consumer.maybeInject(preStep('session-bbbb2222', '第 3 次检查'));
325
+ assert.equal(third.injected, false);
326
+ assert.equal(third.skipped, 'no-new');
327
+ assert.equal(fs.readFileSync(activityStateFile(root, 'session-bbbb2222'), 'utf8'), stateBefore);
328
+ } finally {
329
+ clean(root);
330
+ }
331
+ });
332
+
333
+ test('stream: turn/end conclusion rows are still written, deduped and injected within budget', async () => {
334
+ const root = makeRoot('conclusion');
335
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
336
+ try {
337
+ const producer = newStream(root, clock);
338
+ const consumer = newStream(root, clock, { injectMaxChars: 300, maxItems: 5 });
339
+ const A = sessionOf('session-dddd4444');
340
+ const conclusion = '本轮结论:活动流已通过 R1-R6 验收,无阻塞。';
341
+ for (let turn = 1; turn <= 6; turn += 1) {
342
+ clock.advance(60000);
343
+ producer.onSessionEvent(A, turnStart(turn));
344
+ producer.onSessionEvent(A, userEvent('整理活动流验收交接'));
345
+ producer.onSessionEvent(A, assistantEvent(conclusion, turn));
346
+ producer.onSessionEvent(A, turnEnd(turn));
347
+ }
348
+ const live = liveRows(readBoard(root)).filter((row) => row.sessionId === 'session-dddd4444');
349
+ assert.ok(live.length > 0, 'turn/end wrote the conclusion row');
350
+ const conclusionRows = live.filter((row) => /结论|通过|验收|交接/.test(row.text));
351
+ assert.ok(conclusionRows.length > 0, 'conclusion semantics are in the row text');
352
+ assert.ok(conclusionRows.length <= 2, `6 identical conclusions stay ≤2 rows (got ${conclusionRows.length})`);
353
+ const merged = conclusionRows.some((row) => (row.count ?? 1) >= 2 || row.phase === true);
354
+ assert.ok(merged, 'repeated conclusions merge (count grows) or distill into a phase row');
355
+
356
+ const payload = preStep('session-eeee5555', '交接看板:e219 留下什么结论?');
357
+ const result = await consumer.maybeInject(payload);
358
+ assert.equal(result.injected, true, JSON.stringify(result));
359
+ const hint = hintTextOf(result, payload);
360
+ assert.ok(charLength(hint) <= 300, `hint chars=${charLength(hint)}`);
361
+ const itemLines = hint.split('\n').filter((line) => line.trim().startsWith('- '));
362
+ assert.ok(itemLines.length <= 5, `items=${itemLines.length}`);
363
+ assert.equal(new Set(itemLines.map((line) => line.trim())).size, itemLines.length, 'no duplicate item lines');
364
+ assert.ok(hint.includes('结论'), 'sibling sees the conclusion');
365
+ } finally {
366
+ clean(root);
367
+ }
368
+ });
369
+
370
+ test('stream: tool/result isError writes blocked (escalation once), then throttles', () => {
371
+ const root = makeRoot('error');
372
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
373
+ try {
374
+ const stream = newStream(root, clock);
375
+ const A = sessionOf('session-ffff5555');
376
+ stream.onSessionEvent(A, turnStart(1));
377
+ stream.onSessionEvent(A, userEvent('跑全量测试'));
378
+ stream.onSessionEvent(A, toolCall('pwsh', { command: 'node test/run-rc.mjs' }));
379
+ const before = readBoard(root)[0];
380
+ assert.equal(before.kind, 'doing');
381
+
382
+ clock.advance(500);
383
+ stream.onSessionEvent(A, toolResult(true));
384
+ const afterError = readBoard(root);
385
+ assert.equal(afterError.length, 1);
386
+ assert.equal(afterError[0].kind, 'blocked', 'a hard tool error becomes a blocked row');
387
+ assert.ok(afterError[0].text.includes('(失败)'));
388
+ assert.ok(maxSeq(afterError) > before.seq, 'the blocked escalation bumps seq (siblings see it)');
389
+ const blockedAt = afterError[0].last_at;
390
+ const blockedSeq = maxSeq(afterError);
391
+
392
+ // A second error inside the window is throttled: escalation already happened.
393
+ clock.advance(500);
394
+ stream.onSessionEvent(A, toolResult(true));
395
+ const afterSecond = readBoard(root);
396
+ assert.equal(afterSecond[0].last_at, blockedAt);
397
+ assert.equal(maxSeq(afterSecond), blockedSeq);
398
+ assert.ok(stream.stats.throttled >= 1);
399
+ } finally {
400
+ clean(root);
401
+ }
402
+ });
403
+
404
+ test('stream: disabled writes nothing; privacy-rejected event writes nothing', () => {
405
+ const root = makeRoot('disabled');
406
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
407
+ try {
408
+ const disabled = createActivityStream({
409
+ enabled: false,
410
+ memoryRoot: root,
411
+ createUserMessage,
412
+ privacyScan: allowPrivacy,
413
+ now: () => clock.now(),
414
+ });
415
+ const A = sessionOf('session-gggg7777');
416
+ disabled.onSessionEvent(A, turnStart(1));
417
+ disabled.onSessionEvent(A, userEvent('实现活动流'));
418
+ disabled.onSessionEvent(A, toolCall('pwsh', { command: 'node test/run-rc.mjs' }));
419
+ disabled.onSessionEvent(A, turnEnd(1));
420
+ assert.equal(fs.existsSync(path.join(root, '.longlive', 'board')), false, 'disabled stream never touches the board');
421
+
422
+ const writer = newStream(root, clock, {
423
+ privacyScan: () => ({ verdict: 'local_only', spans: [], reasons: ['phone'] }),
424
+ });
425
+ writer.onSessionEvent(A, turnStart(1));
426
+ writer.onSessionEvent(A, userEvent('联系 13800138000 处理'));
427
+ writer.onSessionEvent(A, toolCall('pwsh', { command: 'node test/run-rc.mjs --phone 13800138000' }));
428
+ assert.equal(readBoard(root).length, 0, 'privacy gate still drops event-level rows');
429
+ assert.equal(writer.stats.privacy, 1);
430
+ } finally {
431
+ clean(root);
432
+ }
433
+ });
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // S3 (red-team 1.6.8): keys must never reach the shared board / sibling hint
437
+ // ---------------------------------------------------------------------------
438
+
439
+ test('privacy fallback: key/token patterns are local_only; phone/email behavior unchanged', () => {
440
+ const blocked = [
441
+ ['api-key', 'DEEPSEEK_API_KEY=sk-live-abc1234567890'],
442
+ ['api-key', 'sk-live-abc1234567890'],
443
+ ['api-key', 'sk-proj-AbCdEf1234567890xyz'],
444
+ ['api-key', 'SK-LIVE-ABCDEFGH123456'],
445
+ ['bearer', 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijkl'],
446
+ ['api-key', 'api_key=abcdef1234567890'],
447
+ ['token', 'token=abc123456789'],
448
+ ['private-key', '-----BEGIN RSA PRIVATE KEY-----'],
449
+ ['private-key', '-----BEGIN OPENSSH PRIVATE KEY-----'],
450
+ ['aws-key', 'AKIAABCDEFGHIJKLMNOP'],
451
+ ['github-token', 'ghp_abcdefghijklmnopqrstuvwxyz1234'],
452
+ ['slack-token', 'xoxb-123456789012-abcdefghij'],
453
+ // RE-S3.1: lowercase / prefixed assignment forms.
454
+ ['api-key', 'my_api_key: "abcd12345678"'],
455
+ ['api-key', 'apiKey=abcdef123456'],
456
+ ['token', 'secret token: abcdef123456'],
457
+ ['api-key', 'api_key = "abcdef123456"'],
458
+ ['api-key', 'my password = hunter2hunter2'],
459
+ ['token', 'refresh_token=abcdefgh12345678'],
460
+ ];
461
+ for (const [kind, text] of blocked) {
462
+ const verdict = fallbackScan(text);
463
+ assert.equal(verdict.verdict, 'local_only', `fallback must block ${JSON.stringify(text)}`);
464
+ assert.ok(verdict.reasons.includes(kind), `${kind} not in ${JSON.stringify(verdict.reasons)} for ${text}`);
465
+ }
466
+ const benign = [
467
+ 'node --test test/auto-recall.test.mjs',
468
+ '第 137 行 2026-11-04',
469
+ 'token 数 12000',
470
+ 'task-id-1234567890 is a task id',
471
+ 'read:C:\\proj\\src\\index.js',
472
+ 'pwsh:node libtv render S08-S10',
473
+ '阶段:libtv跑s08s25字幕渲染|shell(7 次,最近 pwsh:node libtv render S09-S11)',
474
+ 'sk-learn 课程',
475
+ 'sha256 8e46b624db96801e',
476
+ // RE-S3.1 false-positive guard: ordinary English/code must keep writing.
477
+ 'the token is invalid',
478
+ 'password reset flow',
479
+ 'secret sauce recipe',
480
+ 'refresh token rotation is described here',
481
+ 'password 字段在 config.js 里',
482
+ 'risk-assessment-value',
483
+ 'AKIA 前缀用于 AWS key',
484
+ 'my api design: build a service',
485
+ ];
486
+ for (const text of benign) {
487
+ assert.equal(fallbackScan(text).verdict, 'none', `fallback must not block ordinary text ${JSON.stringify(text)}`);
488
+ }
489
+ // 1.6.7 behavior must stay: phone/email/id-card still local_only.
490
+ assert.ok(fallbackScan('call 13800138000').reasons.includes('phone'));
491
+ assert.ok(fallbackScan('client@example.com').reasons.includes('email'));
492
+ assert.ok(fallbackScan('11010519491231002X').reasons.includes('id-card'));
493
+ assert.equal(privacyScan('联系 13700002222 或 client@example.com').verdict, 'local_only');
494
+ assert.equal(privacyScan('node --test test/auto-recall.test.mjs').verdict, 'none');
495
+ });
496
+
497
+ test('store: prefixed/lowercase credential assignments are rejected, ordinary text is not (RE-S3.1)', () => {
498
+ const root = makeRoot('privacy-prefixed');
499
+ try {
500
+ const store = createActivityStore({ memoryRoot: root }); // production gate
501
+ const credentials = [
502
+ 'my_api_key: "abcd12345678"',
503
+ 'apiKey=abcdef123456',
504
+ 'secret token: abcdef123456',
505
+ 'api_key = "abcdef123456"',
506
+ 'my password = hunter2hunter2',
507
+ 'DEEPSEEK_API_KEY=sk-live-abc1234567890',
508
+ ];
509
+ for (const credential of credentials) {
510
+ const result = store.append({
511
+ sessionId: 'session-priv0001',
512
+ kind: 'doing',
513
+ text: `pwsh: echo ${credential}`,
514
+ key: `priv|${credential.slice(0, 8)}`,
515
+ });
516
+ assert.equal(result.ok, false, `credential was written: ${JSON.stringify(credential)}`);
517
+ assert.equal(result.code, 'E_PRIVACY');
518
+ }
519
+ assert.equal(readBoard(root).length, 0, 'no credential row reached the board');
520
+ const ordinary = [
521
+ 'task-id-1234567890 完成',
522
+ 'token 数 12,超预算',
523
+ 'risk-assessment-value',
524
+ 'password 字段在 config.js 里',
525
+ 'AKIA 前缀用于 AWS key',
526
+ 'password reset flow',
527
+ ];
528
+ for (const text of ordinary) {
529
+ const result = store.append({
530
+ sessionId: 'session-fp000001',
531
+ kind: 'doing',
532
+ text,
533
+ key: `fp|${text.slice(0, 6)}`,
534
+ });
535
+ assert.equal(result.ok, true, `ordinary text over-blocked: ${JSON.stringify(text)} -> ${result.code}`);
536
+ }
537
+ } finally {
538
+ clean(root);
539
+ }
540
+ });
541
+
542
+ test('stream: default privacy gate blocks API keys on the board and at inject time', async () => {
543
+ const root = makeRoot('privacy-keys');
544
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
545
+ try {
546
+ // No privacyScan option => the production gate (src/recall/privacy.mjs).
547
+ const producer = createActivityStream({
548
+ enabled: true,
549
+ memoryRoot: root,
550
+ createUserMessage,
551
+ now: () => clock.now(),
552
+ });
553
+ const A = sessionOf('session-aaaa1111');
554
+ producer.onSessionEvent(A, turnStart(1));
555
+ producer.onSessionEvent(A, userEvent('填一下 DEEPSEEK_API_KEY=sk-live-abc1234567890 到配置'));
556
+ producer.onSessionEvent(A, toolCall('pwsh', { command: 'echo sk-live-abc1234567890 > .env' }));
557
+ const rows = readBoard(root);
558
+ assert.equal(
559
+ rows.some((row) => String(row.text).includes('sk-live-abc1234567890')),
560
+ false,
561
+ 'API key text must never be written to the shared board',
562
+ );
563
+ assert.ok(producer.stats.privacy >= 1, `stats.privacy=${producer.stats.privacy}`);
564
+
565
+ // A key row already on disk (older/forged writer) must be filtered at inject.
566
+ const boardFile = path.join(root, '.longlive', 'board', 'activity.jsonl');
567
+ fs.mkdirSync(path.dirname(boardFile), { recursive: true });
568
+ const lines = readBoard(root).map((row) => JSON.stringify(row));
569
+ lines.push(JSON.stringify({
570
+ schemaVersion: 1,
571
+ seq: 900,
572
+ sessionId: 'session-forged01',
573
+ at: '2026-11-06T07:59:00.000Z',
574
+ last_at: '2026-11-06T07:59:00.000Z',
575
+ kind: 'doing',
576
+ text: 'pwsh:echo sk-live-abc1234567890 > .env',
577
+ key: 'x|shell',
578
+ norm: 'n',
579
+ count: 1,
580
+ }));
581
+ fs.writeFileSync(boardFile, `${lines.join('\n')}\n`, 'utf8');
582
+ const consumer = createActivityStream({
583
+ enabled: true,
584
+ memoryRoot: root,
585
+ createUserMessage,
586
+ now: () => clock.now(),
587
+ });
588
+ const payload = preStep('session-bbbb2222', '看板');
589
+ const result = await consumer.maybeInject(payload);
590
+ assert.equal(result.injected, false, JSON.stringify(result));
591
+ assert.equal(result.skipped, 'privacy');
592
+ const hint = hintTextOf(result, payload);
593
+ assert.equal(hint.includes('sk-live-abc1234567890'), false, 'sibling hint must not carry the key');
594
+
595
+ // Phone path unchanged (existing contract): write rejected, nothing written.
596
+ const phoneStore = createActivityStore({ memoryRoot: root });
597
+ const phone = phoneStore.append({
598
+ sessionId: 'session-ffff5555',
599
+ kind: 'doing',
600
+ text: '联系 13800138000 处理',
601
+ key: 'phone|work',
602
+ });
603
+ assert.equal(phone.ok, false);
604
+ assert.equal(phone.code, 'E_PRIVACY');
605
+ } finally {
606
+ clean(root);
607
+ }
608
+ });
609
+
610
+ test('privacy: configured-but-unreachable DSH_PRIVACY_GATE warns and forces the strengthened fallback', () => {
611
+ const privacyAbs = fileURLToPath(new URL('../src/recall/privacy.mjs', import.meta.url));
612
+ const probe = [
613
+ "import { pathToFileURL } from 'node:url';",
614
+ 'const warnings = [];',
615
+ "process.on('warning', (w) => warnings.push(String(w.message)));",
616
+ 'const m = await import(pathToFileURL(process.env.RT_PRIVACY_ABS).href);',
617
+ 'await new Promise((resolve) => setImmediate(resolve));',
618
+ 'process.stdout.write(JSON.stringify({',
619
+ " key: m.privacyScan('DEEPSEEK_API_KEY=sk-live-abc1234567890').verdict,",
620
+ " phone: m.privacyScan('call 13800138000').verdict,",
621
+ ' warnings,',
622
+ ' status: m.privacyGateStatus,',
623
+ '}));',
624
+ ].join('\n');
625
+ const child = spawnSync(process.execPath, ['--input-type=module', '-e', probe], {
626
+ encoding: 'utf8',
627
+ timeout: 60_000,
628
+ windowsHide: true,
629
+ env: {
630
+ ...process.env,
631
+ DSH_PRIVACY_GATE: path.join(os.tmpdir(), 'no-such-privacy-gate-dir', 'privacy-gate.js'),
632
+ RT_PRIVACY_ABS: privacyAbs,
633
+ },
634
+ });
635
+ assert.equal(child.status, 0, child.stderr);
636
+ const out = JSON.parse(child.stdout);
637
+ assert.equal(out.key, 'local_only', 'key must be blocked even when the configured gate is unreachable');
638
+ assert.equal(out.phone, 'local_only');
639
+ assert.equal(out.status.configured, true);
640
+ assert.equal(out.status.forcedFallback, true);
641
+ assert.ok(
642
+ out.warnings.some((warning) => warning.includes('DSH_PRIVACY_GATE') && warning.includes('强制')),
643
+ `expected an unreachable-gate warning, got ${JSON.stringify(out.warnings)}`,
644
+ );
645
+ });
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // R1.5 (red-team 1.6.8): overflow must keep the just-refreshed row on the board
649
+ // ---------------------------------------------------------------------------
650
+
651
+ test('store: overflow archives the oldest rows, never the just-refreshed row (board already over cap)', () => {
652
+ const root = makeRoot('overflow-refresh');
653
+ try {
654
+ const recentMax = 50;
655
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy, recentMax });
656
+ // Simulate a board that grew past the cap because a previous archive append
657
+ // failed. The refreshed target is physically FIRST with the smallest seq,
658
+ // which is exactly the row a position-based slice would archive. The target
659
+ // session keeps ≤10 live rows so the dedupe window can still match its key.
660
+ const at = '2026-11-06T07:00:00.000Z';
661
+ const seeded = [];
662
+ for (let index = 1; index <= 10; index += 1) {
663
+ seeded.push({
664
+ schemaVersion: 1,
665
+ seq: index,
666
+ sessionId: 'session-aaaaaaaa',
667
+ at,
668
+ last_at: at,
669
+ kind: 'doing',
670
+ text: `旧动作 ${index}`,
671
+ key: index === 3 ? 'keep|key' : `task|old-${index}`,
672
+ norm: `n${index}`,
673
+ dupOf: null,
674
+ count: 1,
675
+ });
676
+ }
677
+ for (let index = 0; index < recentMax; index += 1) {
678
+ seeded.push({
679
+ schemaVersion: 1,
680
+ seq: 11 + index,
681
+ sessionId: 'session-bbbbbbbb',
682
+ at,
683
+ last_at: at,
684
+ kind: 'doing',
685
+ text: `填充 ${index}`,
686
+ key: `fill|${index}`,
687
+ norm: `m${index}`,
688
+ dupOf: null,
689
+ count: 1,
690
+ });
691
+ }
692
+ fs.mkdirSync(path.join(root, '.longlive', 'board'), { recursive: true });
693
+ fs.writeFileSync(activityBoardFile(root), `${seeded.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
694
+
695
+ const refreshed = store.append(
696
+ { sessionId: 'session-aaaaaaaa', kind: 'doing', text: 'read:最新动作 999', key: 'keep|key' },
697
+ { touchSeq: true, refreshText: true },
698
+ );
699
+ assert.equal(refreshed.action, 'refreshed', JSON.stringify(refreshed));
700
+ const after = store.read().entries;
701
+ const kept = after.find((row) => row.seq === refreshed.seq);
702
+ assert.ok(kept, 'the refreshed row must stay on the active board');
703
+ assert.notEqual(kept.archived, true);
704
+ assert.equal(kept.text, 'read:最新动作 999');
705
+ assert.ok(after.length <= recentMax, `cap enforced, board rows=${after.length}`);
706
+ assert.equal(Math.max(...after.map((row) => row.seq)), refreshed.seq, 'refreshed row is the newest seq');
707
+ const month = monthKeyFor(new Date());
708
+ const archiveFile = activityArchiveFile(root, month);
709
+ if (fs.existsSync(archiveFile)) {
710
+ assert.equal(
711
+ fs.readFileSync(archiveFile, 'utf8').includes('read:最新动作 999'),
712
+ false,
713
+ 'the refreshed row must never be shipped to the archive',
714
+ );
715
+ }
716
+ } finally {
717
+ clean(root);
718
+ }
719
+ });
720
+
721
+ test('store+stream: a full 500-row board keeps the refreshed action visible to a sibling cursor', async () => {
722
+ const root = makeRoot('overflow-500');
723
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
724
+ try {
725
+ const at = '2026-11-06T07:00:00.000Z';
726
+ const boardDir = path.join(root, '.longlive', 'board');
727
+ fs.mkdirSync(boardDir, { recursive: true });
728
+ const rows = [];
729
+ let seq = 0;
730
+ for (let index = 0; index < 10; index += 1) {
731
+ seq += 1;
732
+ rows.push({
733
+ schemaVersion: 1, seq, sessionId: 'session-aaaaaaaa', at, last_at: at, kind: 'doing',
734
+ text: `旧动作 ${index}`, key: `task|old-${index}`, norm: `n${index}`, dupOf: null, count: 1,
735
+ });
736
+ }
737
+ for (let index = 0; index < 490; index += 1) {
738
+ seq += 1;
739
+ rows.push({
740
+ schemaVersion: 1, seq, sessionId: 'session-bbbbbbbb', at, last_at: at, kind: 'doing',
741
+ text: `填充 ${index}`, key: `fill|${index}`, norm: `m${index}`, dupOf: null, count: 1,
742
+ });
743
+ }
744
+ fs.writeFileSync(path.join(boardDir, 'activity.jsonl'), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
745
+
746
+ const consumer = createActivityStream({
747
+ enabled: true,
748
+ memoryRoot: root,
749
+ createUserMessage,
750
+ privacyScan: allowPrivacy,
751
+ injectMaxChars: 300,
752
+ maxItems: 5,
753
+ now: () => clock.now(),
754
+ });
755
+ const firstPayload = preStep('session-cccccccc', '先看一眼');
756
+ const first = await consumer.maybeInject(firstPayload);
757
+ assert.equal(first.injected, true, JSON.stringify(first));
758
+ const cursor = maxSeq(readBoard(root));
759
+ assert.equal(cursor, 500);
760
+
761
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
762
+ const refreshed = store.append(
763
+ { sessionId: 'session-aaaaaaaa', kind: 'doing', text: 'read:最新动作 999', key: 'task|old-0' },
764
+ { touchSeq: true, refreshText: true },
765
+ );
766
+ assert.equal(refreshed.action, 'refreshed');
767
+ assert.ok(refreshed.seq > cursor, `refresh seq ${refreshed.seq} must be newer than cursor ${cursor}`);
768
+ const added = store.append({ sessionId: 'session-dddddddd', kind: 'doing', text: 'glob:src/**/*.js', key: 'new|glob' });
769
+ assert.ok(added.ok, JSON.stringify(added));
770
+
771
+ const live = readBoard(root).filter((row) => row.archived !== true);
772
+ assert.ok(
773
+ live.some((row) => row.seq === refreshed.seq),
774
+ `refreshed row seq=${refreshed.seq} was archived by the 500-row overflow`,
775
+ );
776
+ assert.ok(live.length <= 500, `board capped at 500, got ${live.length}`);
777
+
778
+ const secondPayload = preStep('session-cccccccc', '再看一眼');
779
+ const second = await consumer.maybeInject(secondPayload);
780
+ assert.equal(second.injected, true, JSON.stringify(second));
781
+ const hint = hintTextOf(second, secondPayload);
782
+ assert.ok(hint.includes('最新动作 999'), `sibling cursor missed the refreshed action: ${JSON.stringify(hint)}`);
783
+ } finally {
784
+ clean(root);
785
+ }
786
+ });
787
+
788
+ // ---------------------------------------------------------------------------
789
+ // RE-R15.5 (red-team 1.6.8 re-break): board reset must not strand the cursor
790
+ // ---------------------------------------------------------------------------
791
+
792
+ test('store: seq high-water survives board deletion and a stream.reset marker is written', () => {
793
+ const root = makeRoot('reset-store');
794
+ try {
795
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
796
+ const seqs = [];
797
+ for (let index = 0; index < 5; index += 1) {
798
+ const result = store.append({
799
+ sessionId: 'session-aaaa1111',
800
+ kind: 'doing',
801
+ text: `pwsh:render a-${index}`,
802
+ key: `a|${index}`,
803
+ });
804
+ assert.equal(result.ok, true, JSON.stringify(result));
805
+ seqs.push(result.seq);
806
+ }
807
+ const lastSeq = seqs[seqs.length - 1];
808
+ const highWater = JSON.parse(fs.readFileSync(activitySeqHighWaterFile(root), 'utf8')).seq;
809
+ assert.equal(highWater, lastSeq, 'the sidecar tracks the newest seq');
810
+
811
+ fs.rmSync(activityBoardFile(root), { force: true });
812
+ const fresh = store.append({
813
+ sessionId: 'session-aaaa1111',
814
+ kind: 'doing',
815
+ text: 'read:after-reset.js',
816
+ key: 'reset|read',
817
+ });
818
+ assert.equal(fresh.ok, true, JSON.stringify(fresh));
819
+ assert.equal(fresh.streamReset, true);
820
+ assert.ok(fresh.seq > lastSeq, `seq must stay monotonic: fresh=${fresh.seq} previous=${lastSeq}`);
821
+ const marker = JSON.parse(fs.readFileSync(activityResetMarkerFile(root), 'utf8'));
822
+ assert.equal(marker.v, 1);
823
+ assert.equal(marker.reason, 'board-missing');
824
+ assert.ok(marker.previousHighWater >= lastSeq);
825
+ assert.equal(marker.firstSeq, fresh.seq);
826
+
827
+ // Emptying (not deleting) the file is the same reset path.
828
+ fs.writeFileSync(activityBoardFile(root), '', 'utf8');
829
+ const afterEmpty = store.append({
830
+ sessionId: 'session-aaaa1111',
831
+ kind: 'doing',
832
+ text: 'glob:after-empty.js',
833
+ key: 'empty|glob',
834
+ });
835
+ assert.equal(afterEmpty.ok, true, JSON.stringify(afterEmpty));
836
+ assert.equal(afterEmpty.streamReset, true);
837
+ assert.ok(afterEmpty.seq > fresh.seq);
838
+ const marker2 = JSON.parse(fs.readFileSync(activityResetMarkerFile(root), 'utf8'));
839
+ assert.equal(marker2.reason, 'board-empty');
840
+ } finally {
841
+ clean(root);
842
+ }
843
+ });
844
+
845
+ test('stream: sibling cursor recovers after the board is deleted (RE-R15.5)', async () => {
846
+ const root = makeRoot('reset-cursor');
847
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
848
+ try {
849
+ const store = createActivityStore({ memoryRoot: root, privacyScan: allowPrivacy });
850
+ const distinct = [
851
+ ['pwsh:node libtv render S08-S10', 'a|0'],
852
+ ['write:docs/a.md', 'a|1'],
853
+ ['glob:src/**/*.js', 'a|2'],
854
+ ['grep:pulse\\.running', 'a|3'],
855
+ ['read:README.md', 'a|4'],
856
+ ];
857
+ for (const [text, key] of distinct) {
858
+ const result = store.append({ sessionId: 'session-aaaa1111', kind: 'doing', text, key });
859
+ assert.equal(result.ok, true, JSON.stringify(result));
860
+ }
861
+ const consumer = createActivityStream({
862
+ enabled: true,
863
+ memoryRoot: root,
864
+ createUserMessage,
865
+ privacyScan: allowPrivacy,
866
+ now: () => clock.now(),
867
+ });
868
+ const firstPayload = preStep('session-bbbb2222', '第一轮');
869
+ const first = await consumer.maybeInject(firstPayload);
870
+ assert.equal(first.injected, true, JSON.stringify(first));
871
+ const cursor = readActivityState(root, 'session-bbbb2222').lastSeenSeq;
872
+ assert.equal(cursor, 5);
873
+
874
+ fs.rmSync(activityBoardFile(root), { force: true });
875
+ const fresh = store.append({
876
+ sessionId: 'session-aaaa1111',
877
+ kind: 'doing',
878
+ text: 'read:after-reset.js',
879
+ key: 'reset|read',
880
+ });
881
+ assert.equal(fresh.ok, true, JSON.stringify(fresh));
882
+ assert.ok(fresh.seq > cursor, `fresh seq ${fresh.seq} must be newer than cursor ${cursor}`);
883
+
884
+ const secondPayload = preStep('session-bbbb2222', '重置后第二轮');
885
+ const second = await consumer.maybeInject(secondPayload);
886
+ assert.equal(second.injected, true, JSON.stringify(second));
887
+ assert.ok(hintTextOf(second, secondPayload).includes('after-reset'));
888
+ const state = readActivityState(root, 'session-bbbb2222');
889
+ assert.ok(state.lastSeenSeq >= fresh.seq);
890
+ // The high-water sidecar keeps seq monotonic, so this recovery does not even
891
+ // need the cursor-fallback branch (that path is covered by the legacy test).
892
+ assert.notEqual(second.reset, true);
893
+ } finally {
894
+ clean(root);
895
+ }
896
+ });
897
+
898
+ test('stream: a cursor above every current row falls back and replays (legacy/reset boards)', async () => {
899
+ const root = makeRoot('reset-legacy');
900
+ const clock = makeClock('2026-11-06T08:00:00+08:00');
901
+ try {
902
+ // Legacy board without the high-water sidecar: seq restarted at 1..2.
903
+ const at = '2026-11-06T07:00:00.000Z';
904
+ fs.mkdirSync(path.join(root, '.longlive', 'board'), { recursive: true });
905
+ const rows = [
906
+ {
907
+ schemaVersion: 1, seq: 1, sessionId: 'session-cccc0001', at, last_at: at, kind: 'doing',
908
+ text: 'read:legacy-1.js', key: 'x|read', norm: 'n1', dupOf: null, count: 1,
909
+ },
910
+ {
911
+ schemaVersion: 1, seq: 2, sessionId: 'session-cccc0001', at, last_at: at, kind: 'doing',
912
+ text: 'read:legacy-2.js', key: 'y|read', norm: 'n2', dupOf: null, count: 1,
913
+ },
914
+ ];
915
+ fs.writeFileSync(activityBoardFile(root), `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8');
916
+ fs.mkdirSync(path.join(root, '.longlive', 'bridge'), { recursive: true });
917
+ fs.writeFileSync(activityStateFile(root, 'session-bbbb2222'), JSON.stringify({
918
+ v: 1,
919
+ sessionId: 'session-bbbb2222',
920
+ lastSeenSeq: 900,
921
+ lastInjectedSeq: 900,
922
+ lastInjectedAt: null,
923
+ injectedTurns: 3,
924
+ updatedAt: null,
925
+ }, null, 2));
926
+ const consumer = createActivityStream({
927
+ enabled: true,
928
+ memoryRoot: root,
929
+ createUserMessage,
930
+ privacyScan: allowPrivacy,
931
+ now: () => clock.now(),
932
+ });
933
+ const payload = preStep('session-bbbb2222', '游标高于板');
934
+ const result = await consumer.maybeInject(payload);
935
+ assert.equal(result.injected, true, JSON.stringify(result));
936
+ assert.equal(result.reset, true);
937
+ assert.ok(hintTextOf(result, payload).includes('legacy-2.js'));
938
+ const state = readActivityState(root, 'session-bbbb2222');
939
+ assert.ok(state.lastSeenSeq <= 2, `cursor must fall back to the board: ${state.lastSeenSeq}`);
940
+ assert.equal(state.resets, 1);
941
+
942
+ // Control: a cursor equal to board max is NOT treated as a reset (no replay).
943
+ fs.writeFileSync(activityStateFile(root, 'session-bbbb2222'), JSON.stringify({
944
+ v: 1,
945
+ sessionId: 'session-bbbb2222',
946
+ lastSeenSeq: 2,
947
+ lastInjectedSeq: 2,
948
+ lastInjectedAt: null,
949
+ injectedTurns: 1,
950
+ updatedAt: null,
951
+ }, null, 2));
952
+ const quiet = await consumer.maybeInject(preStep('session-bbbb2222', '没有新行'));
953
+ assert.equal(quiet.injected, false);
954
+ assert.equal(quiet.skipped, 'no-new');
955
+ assert.notEqual(quiet.reset, true);
956
+ } finally {
957
+ clean(root);
958
+ }
959
+ });