@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.
- package/CHANGELOG.md +40 -8
- package/QUICKSTART.md +10 -10
- package/README.en.md +4 -3
- package/README.md +10 -4
- package/SPEC.md +2 -2
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CONFIG.md +33 -6
- package/docs/DEV.md +3 -3
- package/docs/FAQ.md +4 -4
- package/docs/FEATURES.md +36 -5
- package/docs/OPERATIONS.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/docs/README.en.md +3 -2
- package/docs/TROUBLESHOOTING.md +3 -3
- package/package.json +13 -8
- package/src/activity/index.js +649 -0
- package/src/activity/model.js +341 -0
- package/src/activity/store.js +625 -0
- package/src/config.js +29 -2
- package/src/features.js +26 -0
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/live.js +22 -2
- package/src/recall/privacy.mjs +105 -16
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/service.mjs +27 -11
- package/test/activity-realtime.test.mjs +959 -0
- package/test/activity-stream.test.mjs +512 -0
- package/test/autobind.test.mjs +25 -11
- package/test/config-schema.test.mjs +6 -0
- package/test/feature-flags.test.mjs +17 -0
- package/test/fixtures/activity-append-child.mjs +29 -0
- package/test/live.test.mjs +6 -3
- package/test/packaging.test.mjs +7 -5
- package/test/router-background/router.test.mjs +44 -12
- package/test/run-rc.mjs +16 -0
- package/test/safety-defaults.test.mjs +5 -3
- package/test/state-truth.test.mjs +937 -0
- 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
|
+
});
|
package/test/autobind.test.mjs
CHANGED
|
@@ -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
|
|
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
|
|
298
|
-
//
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|