@modusensus/dsh-mneme 0.9.5 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -5
- package/docs/CONFIGURATION.md +21 -0
- package/docs/DAEMON.md +6 -2
- package/docs/DISTILL.md +90 -6
- package/docs/SLEEP.md +20 -6
- package/docs/STORAGE.md +1 -0
- package/lib/api-standalone.js +11 -3
- package/lib/api.js +25 -5
- package/lib/client.js +137 -10
- package/lib/config.js +33 -1
- package/lib/continuity.js +11 -3
- package/lib/dream/decisions.js +88 -27
- package/lib/dream/sleep.js +467 -81
- package/lib/dream.js +110 -13
- package/lib/fence.js +24 -0
- package/lib/index.js +15 -6
- package/lib/inject.js +9 -4
- package/lib/lang.js +17 -12
- package/lib/pipeline-nudge-auto.js +3 -0
- package/lib/pipeline-nudge.js +41 -8
- package/lib/pipeline-runner.js +15 -0
- package/lib/pre-inject-gate.js +14 -0
- package/lib/profile-extract.js +4 -1
- package/lib/provenance-http.js +162 -37
- package/lib/service.js +30 -0
- package/lib/session-gate.js +91 -0
- package/lib/settings.js +13 -1
- package/lib/store.js +526 -18
- package/lib/summarize.js +152 -15
- package/lib/tools.js +26 -6
- package/package.json +4 -2
- package/src/api-standalone.js +11 -3
- package/src/api.js +25 -5
- package/src/config.js +33 -1
- package/src/continuity.js +11 -3
- package/src/dream/decisions.js +88 -27
- package/src/dream/sleep.js +467 -81
- package/src/dream.js +110 -13
- package/src/fence.js +24 -0
- package/src/index.js +15 -6
- package/src/inject.js +9 -4
- package/src/lang.js +17 -12
- package/src/pipeline-nudge-auto.js +3 -0
- package/src/pipeline-nudge.js +41 -8
- package/src/pipeline-runner.js +15 -0
- package/src/pre-inject-gate.js +14 -0
- package/src/profile-extract.js +4 -1
- package/src/provenance-http.js +162 -37
- package/src/service.js +30 -0
- package/src/session-gate.js +91 -0
- package/src/settings.js +13 -1
- package/src/store.js +526 -18
- package/src/summarize.js +152 -15
- package/src/tools.js +26 -6
- package/test/api.test.js +153 -4
- package/test/client.test.js +215 -0
- package/test/config.test.js +8 -0
- package/test/conflict-reviewed.test.js +79 -0
- package/test/continuity.test.js +29 -1
- package/test/dependency-overrides.test.js +78 -0
- package/test/differentiate-feedback.test.js +248 -0
- package/test/dream-decision-evidence.test.js +314 -0
- package/test/dream-fence.test.js +43 -0
- package/test/inject-pools.test.js +2 -1
- package/test/mint-source.test.js +83 -0
- package/test/pipeline-nudge-auto.test.js +26 -2
- package/test/pipeline-nudge.test.js +87 -0
- package/test/pipeline-runner.test.js +26 -0
- package/test/prompt-stance.test.js +128 -0
- package/test/provenance-http.test.js +200 -17
- package/test/release-docs.test.js +7 -1
- package/test/security-doc.test.js +43 -4
- package/test/session-gate.test.js +264 -0
- package/test/settings.test.js +4 -0
- package/test/sleep-action-set.test.js +16 -11
- package/test/sleep-catchup.test.js +315 -0
- package/test/sleep-failure-backoff.test.js +156 -0
- package/test/sleep.test.js +293 -0
- package/test/standalone-api.test.js +50 -0
- package/test/store.test.js +220 -0
- package/test/summarize.test.js +172 -0
|
@@ -16,12 +16,19 @@ import { dirname, join, resolve } from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import {
|
|
18
18
|
DISTILL_RECORDS_DISABLED,
|
|
19
|
+
PROVENANCE_DEFAULT_WINDOWS,
|
|
20
|
+
PROVENANCE_MAX_WINDOWS,
|
|
19
21
|
PROVENANCE_UNAVAILABLE,
|
|
20
22
|
isDistillRecordsEnabled,
|
|
21
|
-
|
|
23
|
+
parseProvenanceLimit,
|
|
24
|
+
parseProvenanceSelector,
|
|
22
25
|
provenanceReply
|
|
23
26
|
} from "../src/provenance-http.js";
|
|
24
27
|
|
|
28
|
+
// 方向 1 的选择器:绝大多数用例只关心「读出去的那一侧」,方向本身用这个常量固定。
|
|
29
|
+
const MEM = { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS };
|
|
30
|
+
const SES = { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS };
|
|
31
|
+
|
|
25
32
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
26
33
|
|
|
27
34
|
// 开着开关的 cfg:这个端点返回蒸馏窗口的逐字原文,所以测试里的「正常路径」必须
|
|
@@ -31,19 +38,73 @@ const RECORDS_ON = { distillRecords: { enabled: true } };
|
|
|
31
38
|
function fakeService(over = {}) {
|
|
32
39
|
return {
|
|
33
40
|
provenanceFor: (id) => over.links ?? [],
|
|
41
|
+
// #524 方向 2 的存储读法:按会话列窗口(store 侧 GROUP BY 成一行一窗口)。
|
|
42
|
+
distillWindowsFor: (sessionId, limit) => over.windows ?? [],
|
|
34
43
|
distillRecordsFor: (sessionId, from, to) => over.records ?? [],
|
|
35
44
|
...over.service
|
|
36
45
|
};
|
|
37
46
|
}
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
// --- 入参:两个互斥主键 + limit(#524)---------------------------------------
|
|
49
|
+
// 方向 2 复用同一条路径,于是「哪个主键说了算」必须是个**显式**判断:都缺是请求
|
|
50
|
+
// 不成立(400 missing-id),都给也要 400——择一意味着调用方发一个自己也不知道会
|
|
51
|
+
// 读哪个主键的请求,而两个方向的结果形状不同(memoryId / sessionId 各有自己的键)。
|
|
52
|
+
|
|
53
|
+
test("parseProvenanceSelector:两个主键都缺 → 400 missing-id", () => {
|
|
54
|
+
for (const params of [
|
|
55
|
+
{}, { memoryId: null, sessionId: null }, { memoryId: "", sessionId: "" },
|
|
56
|
+
{ memoryId: " ", sessionId: "\t" }
|
|
57
|
+
]) {
|
|
58
|
+
const r = parseProvenanceSelector(params);
|
|
59
|
+
assert.equal(r.ok, false, `${JSON.stringify(params)} 不该通过`);
|
|
43
60
|
assert.equal(r.status, 400);
|
|
44
61
|
assert.equal(r.error, "missing-id");
|
|
45
62
|
}
|
|
46
|
-
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("parseProvenanceSelector:两个主键都给 → 400 ambiguous-id(不静默择一)", () => {
|
|
66
|
+
const r = parseProvenanceSelector({ memoryId: "m1", sessionId: "s1" });
|
|
67
|
+
assert.equal(r.ok, false);
|
|
68
|
+
assert.equal(r.status, 400);
|
|
69
|
+
assert.equal(r.error, "ambiguous-id");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("parseProvenanceSelector:两端空白裁掉,方向按哪个主键非空确定,limit 缺省 20", () => {
|
|
73
|
+
assert.deepEqual(parseProvenanceSelector({ memoryId: " m1 " }), {
|
|
74
|
+
ok: true,
|
|
75
|
+
selector: { mode: "memory", memoryId: "m1", limit: PROVENANCE_DEFAULT_WINDOWS }
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(parseProvenanceSelector({ sessionId: " s1 ", memoryId: " " }), {
|
|
78
|
+
ok: true,
|
|
79
|
+
selector: { mode: "session", sessionId: "s1", limit: PROVENANCE_DEFAULT_WINDOWS }
|
|
80
|
+
}, "另一侧是空白串时按缺省处理,不算「都给」");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("parseProvenanceLimit:只收十进制整数字面量,畸形与越界都算 400", () => {
|
|
84
|
+
assert.equal(parseProvenanceLimit(null).limit, PROVENANCE_DEFAULT_WINDOWS, "不带就是缺省");
|
|
85
|
+
assert.equal(parseProvenanceLimit("1").limit, 1);
|
|
86
|
+
assert.equal(parseProvenanceLimit(String(PROVENANCE_MAX_WINDOWS)).limit, PROVENANCE_MAX_WINDOWS);
|
|
87
|
+
// 两端空白是**裁掉**而不是判畸形——与 id 的处理保持一致(" 12 " 就是 12)。
|
|
88
|
+
// 但裁完必须是纯十进制字面量。
|
|
89
|
+
assert.equal(parseProvenanceLimit(" 12 ").limit, 12, "两端空白裁掉,与 id 一致");
|
|
90
|
+
// 下面这些如果走 Number(),"" / " " 会化成 0 个窗口,十六进制与指数写法也会被
|
|
91
|
+
// 静默接受——一个畸形输入拿到最坏解释。所以这里逐个钉住。
|
|
92
|
+
for (const raw of [
|
|
93
|
+
"", " ", "0", "-1", "1.5", "1e3", "0x10", "abc", "+1", "12abc",
|
|
94
|
+
String(PROVENANCE_MAX_WINDOWS + 1), "99999999999999999999"
|
|
95
|
+
]) {
|
|
96
|
+
const r = parseProvenanceLimit(raw);
|
|
97
|
+
assert.equal(r.ok, false, `${JSON.stringify(raw)} 不该通过`);
|
|
98
|
+
assert.equal(r.status, 400);
|
|
99
|
+
assert.equal(r.error, "invalid-limit");
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("parseProvenanceSelector:limit 畸形时连着主键一起 400(入参形状先于功能开关)", () => {
|
|
104
|
+
const r = parseProvenanceSelector({ sessionId: "s1", limit: "abc" });
|
|
105
|
+
assert.equal(r.ok, false);
|
|
106
|
+
assert.equal(r.status, 400);
|
|
107
|
+
assert.equal(r.error, "invalid-limit");
|
|
47
108
|
});
|
|
48
109
|
|
|
49
110
|
test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越界", () => {
|
|
@@ -55,7 +116,7 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
|
|
|
55
116
|
return [{ seq: 12 }, { seq: 10 }, { seq: 11 }];
|
|
56
117
|
}
|
|
57
118
|
};
|
|
58
|
-
const r = provenanceReply({ service,
|
|
119
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
59
120
|
assert.equal(r.status, 200);
|
|
60
121
|
assert.equal(r.body.memoryId, "m1");
|
|
61
122
|
assert.equal(r.body.windows.length, 1);
|
|
@@ -68,15 +129,57 @@ test("provenanceReply:一个链接一个窗口,原文按 seq 升序且不越
|
|
|
68
129
|
assert.deepEqual(seen, [["s1", 10, 12]], "区间原样透传给 store");
|
|
69
130
|
});
|
|
70
131
|
|
|
132
|
+
test("provenanceReply #512:窗口带得动归属的那一轮 run,读不到就整组缺省而不是猜", () => {
|
|
133
|
+
const runs = new Map([["r1", { run_type: "autoSummarize", trigger_source: "autoSummarize", status: "ok" }]]);
|
|
134
|
+
const service = {
|
|
135
|
+
provenanceFor: () => [
|
|
136
|
+
{ session_id: "s1", seq_start: 10, seq_end: 12, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r1" },
|
|
137
|
+
// 改造之前写下的链接:distill_run_id 是 NULL。
|
|
138
|
+
{ session_id: "s1", seq_start: 20, seq_end: 22, created_at: "2026-09-01T00:00:00Z", distill_run_id: null }
|
|
139
|
+
],
|
|
140
|
+
distillRecordsFor: () => [],
|
|
141
|
+
getDistillRun: (id) => runs.get(id),
|
|
142
|
+
mintsFor: () => [{ run_id: "d1", run_type: "dream", trigger_source: "autoDream", created_at: "2026-11-01T00:00:00Z" }]
|
|
143
|
+
};
|
|
144
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
145
|
+
assert.equal(r.status, 200);
|
|
146
|
+
const [fresh, legacy] = r.body.windows;
|
|
147
|
+
assert.equal(fresh.distill_run_id, "r1");
|
|
148
|
+
assert.equal(fresh.run_type, "autoSummarize");
|
|
149
|
+
assert.equal(fresh.trigger_source, "autoSummarize");
|
|
150
|
+
assert.equal(fresh.run_status, "ok");
|
|
151
|
+
// 历史链接:id 为 null,三个派生字段也必须是 null——不许拿区间或时间戳反推一轮。
|
|
152
|
+
assert.equal(legacy.distill_run_id, null);
|
|
153
|
+
assert.equal(legacy.run_type, null, "没有 run 就不给类型,否则等于替用户编了一次归因");
|
|
154
|
+
assert.equal(legacy.trigger_source, null);
|
|
155
|
+
assert.equal(legacy.run_status, null);
|
|
156
|
+
assert.equal(r.body.mints.length, 1);
|
|
157
|
+
assert.equal(r.body.mints[0].run_type, "dream");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("provenanceReply #512:service 没有 run 读取能力时,窗口字段照旧齐全但归因为空", () => {
|
|
161
|
+
// headless / 旧装配:provenanceFor 与 distillRecordsFor 在,getDistillRun / mintsFor 不在。
|
|
162
|
+
const service = {
|
|
163
|
+
provenanceFor: () => [{ session_id: "s1", seq_start: 1, seq_end: 2, created_at: "2026-11-02T00:00:00Z", distill_run_id: "r9" }],
|
|
164
|
+
distillRecordsFor: () => []
|
|
165
|
+
};
|
|
166
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
167
|
+
assert.equal(r.status, 200, "读 run 的能力缺一档不算装配坏——窗口本身仍可读");
|
|
168
|
+
assert.equal(r.body.windows[0].distill_run_id, "r9");
|
|
169
|
+
assert.equal(r.body.windows[0].run_type, null);
|
|
170
|
+
assert.deepEqual(r.body.mints, []);
|
|
171
|
+
});
|
|
172
|
+
|
|
71
173
|
test("provenanceReply:没有链接是有效答案——空 windows,200 不是 404", () => {
|
|
72
|
-
const r = provenanceReply({ service: fakeService(),
|
|
174
|
+
const r = provenanceReply({ service: fakeService(), selector: MEM, config: RECORDS_ON });
|
|
73
175
|
assert.equal(r.status, 200);
|
|
74
|
-
|
|
176
|
+
// #524 的 truncated 与 #512 的 mints 都是答复级读数,各自独立缺省。
|
|
177
|
+
assert.deepEqual(r.body, { memoryId: "m1", windows: [], truncated: false, mints: [] });
|
|
75
178
|
});
|
|
76
179
|
|
|
77
180
|
test("provenanceReply:方法没接线是装配坏了——503,不与「没有来源」混为一个读数", () => {
|
|
78
181
|
// headless / 旧装配:service 上没有这两个方法。
|
|
79
|
-
const r = provenanceReply({ service: {},
|
|
182
|
+
const r = provenanceReply({ service: {}, selector: MEM, config: RECORDS_ON });
|
|
80
183
|
assert.equal(r.status, 503);
|
|
81
184
|
assert.equal(r.body.error, PROVENANCE_UNAVAILABLE);
|
|
82
185
|
});
|
|
@@ -87,7 +190,7 @@ test("provenanceReply:读抛错只 warn 并回 500,不把异常漏给调用
|
|
|
87
190
|
provenanceFor: () => { throw new Error("db locked"); },
|
|
88
191
|
distillRecordsFor: () => []
|
|
89
192
|
};
|
|
90
|
-
const r = provenanceReply({ service,
|
|
193
|
+
const r = provenanceReply({ service, selector: MEM, config: RECORDS_ON, warn: (m) => warnings.push(m) });
|
|
91
194
|
assert.equal(r.status, 500);
|
|
92
195
|
assert.equal(r.body.error, "internal");
|
|
93
196
|
assert.equal(warnings.length, 1, "失败要留痕");
|
|
@@ -114,7 +217,7 @@ test("isDistillRecordsEnabled:只认严格的 true,其余一律算关(fail
|
|
|
114
217
|
test("provenanceReply:开关没开 → 409 distill-records-disabled,一个键都不多带", () => {
|
|
115
218
|
// config 整个缺失(旧调用方直连、daemon 最小 cfg)也走这一格:默认方向必须是关。
|
|
116
219
|
for (const cfg of [null, undefined, {}, { distillRecords: { enabled: false } }]) {
|
|
117
|
-
const r = provenanceReply({ service: fakeService(),
|
|
220
|
+
const r = provenanceReply({ service: fakeService(), selector: MEM, config: cfg });
|
|
118
221
|
assert.equal(r.status, 409, `${JSON.stringify(cfg)} → 409`);
|
|
119
222
|
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED },
|
|
120
223
|
"body 只准有 error 一个键——不带 memoryId / windows,调用方不会把「功能关了」读成「这条记忆没有来源」");
|
|
@@ -123,7 +226,7 @@ test("provenanceReply:开关没开 → 409 distill-records-disabled,一个
|
|
|
123
226
|
|
|
124
227
|
test("provenanceReply:409 赢 503——「用户关了」比「装配坏了」更具体", () => {
|
|
125
228
|
// 关着 + service 上连方法都没有:报 503 会让人去查一个没坏的部署。
|
|
126
|
-
const r = provenanceReply({ service: {},
|
|
229
|
+
const r = provenanceReply({ service: {}, selector: MEM, config: { distillRecords: { enabled: false } } });
|
|
127
230
|
assert.equal(r.status, 409);
|
|
128
231
|
assert.equal(r.body.error, DISTILL_RECORDS_DISABLED);
|
|
129
232
|
});
|
|
@@ -135,20 +238,100 @@ test("provenanceReply:开关关着时一次 SQL 都不发(不碰 service)"
|
|
|
135
238
|
provenanceFor: () => { touched++; return []; },
|
|
136
239
|
distillRecordsFor: () => { touched++; return []; }
|
|
137
240
|
} });
|
|
138
|
-
provenanceReply({ service,
|
|
241
|
+
provenanceReply({ service, selector: MEM, config: null });
|
|
139
242
|
assert.equal(touched, 0);
|
|
140
243
|
});
|
|
141
244
|
|
|
142
245
|
test("provenanceReply:开关开着才读得到——200 与 409 是同一把门的两个方向", () => {
|
|
143
246
|
const service = fakeService({ links: [{ session_id: "s1", seq_start: 1, seq_end: 1, created_at: "t" }], records: [{ seq: 1 }] });
|
|
144
|
-
const on = provenanceReply({ service,
|
|
247
|
+
const on = provenanceReply({ service, selector: MEM, config: RECORDS_ON });
|
|
145
248
|
assert.equal(on.status, 200);
|
|
146
249
|
assert.equal(on.body.windows.length, 1);
|
|
147
|
-
const off = provenanceReply({ service,
|
|
250
|
+
const off = provenanceReply({ service, selector: MEM, config: { distillRecords: { enabled: false } } });
|
|
148
251
|
assert.equal(off.status, 409);
|
|
149
252
|
assert.deepEqual(off.body, { error: DISTILL_RECORDS_DISABLED });
|
|
150
253
|
});
|
|
151
254
|
|
|
255
|
+
// --- 方向 2:会话 → 窗口(#524)--------------------------------------------
|
|
256
|
+
|
|
257
|
+
test("provenanceReply(session 方向):一行一窗口,原文按区间取,答复带 sessionId", () => {
|
|
258
|
+
const asked = [];
|
|
259
|
+
const service = {
|
|
260
|
+
distillWindowsFor: (sid, limit) => {
|
|
261
|
+
asked.push([sid, limit]);
|
|
262
|
+
return [
|
|
263
|
+
{ session_id: "s1", seq_start: 5, seq_end: 9, created_at: "t2", link_count: 2 },
|
|
264
|
+
{ session_id: "s1", seq_start: 1, seq_end: 4, created_at: "t1", link_count: 1 }
|
|
265
|
+
];
|
|
266
|
+
},
|
|
267
|
+
distillRecordsFor: (sid, from, to) => [{ seq: from }, { seq: to }]
|
|
268
|
+
};
|
|
269
|
+
const r = provenanceReply({ service, selector: SES, config: RECORDS_ON });
|
|
270
|
+
assert.equal(r.status, 200);
|
|
271
|
+
assert.equal(r.body.sessionId, "s1", "session 方向的答复用 sessionId 认领,不冒充 memoryId");
|
|
272
|
+
assert.equal(r.body.truncated, false);
|
|
273
|
+
assert.deepEqual(r.body.windows.map((w) => [w.seq_start, w.seq_end]), [[5, 9], [1, 4]],
|
|
274
|
+
"次序由 store 的 ORDER BY seq_start 保证,答复层不改序");
|
|
275
|
+
assert.deepEqual(r.body.windows[0].records.map((x) => x.seq), [5, 9], "每个窗口装自己区间的原文");
|
|
276
|
+
assert.equal(r.body.windows[0].link_count, 2, "同一窗口派生了 2 条记忆——这个计数是「按窗口看」的第二维");
|
|
277
|
+
// 多取一行判截断:store 侧只照 limit 取,不替调用方判断「还有没有」。
|
|
278
|
+
assert.deepEqual(asked, [["s1", PROVENANCE_DEFAULT_WINDOWS + 1]]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("provenanceReply:撞上 limit 时 truncated=true,且多出来的那条不许漏出去", () => {
|
|
282
|
+
// 少了这个读数,被截断的答复与「本来就只有这么多」逐字节相同——调用方会把半份
|
|
283
|
+
// 转录当成全部,这正是这张脸最容易骗人的地方。
|
|
284
|
+
const service = {
|
|
285
|
+
distillWindowsFor: () => [1, 2, 3].map((n) => ({
|
|
286
|
+
session_id: "s1", seq_start: n, seq_end: n, created_at: "t", link_count: 1
|
|
287
|
+
})),
|
|
288
|
+
distillRecordsFor: () => []
|
|
289
|
+
};
|
|
290
|
+
const r = provenanceReply({ service, selector: { ...SES, limit: 2 }, config: RECORDS_ON });
|
|
291
|
+
assert.equal(r.status, 200);
|
|
292
|
+
assert.equal(r.body.truncated, true);
|
|
293
|
+
assert.equal(r.body.windows.length, 2, "limit=2 就只许回 2 个窗口,探针那一条不能混进答复");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("provenanceReply:memory 方向也带 truncated(两个方向共一个读数)", () => {
|
|
297
|
+
const service = {
|
|
298
|
+
provenanceFor: () => [1, 2].map((n) => ({
|
|
299
|
+
session_id: "s1", seq_start: n, seq_end: n, created_at: "t"
|
|
300
|
+
})),
|
|
301
|
+
distillRecordsFor: () => []
|
|
302
|
+
};
|
|
303
|
+
const r = provenanceReply({ service, selector: { ...MEM, limit: 1 }, config: RECORDS_ON });
|
|
304
|
+
assert.equal(r.status, 200);
|
|
305
|
+
assert.equal(r.body.truncated, true);
|
|
306
|
+
assert.equal(r.body.windows.length, 1);
|
|
307
|
+
assert.equal(r.body.memoryId, "m1");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("provenanceReply:503 按方向判——只缺 session 那侧的方法时,memory 查询照旧 200", () => {
|
|
311
|
+
// 把一个方向的问题报成整张脸坏了,会让人去查一个没坏的部署。
|
|
312
|
+
const onlyMemory = {
|
|
313
|
+
provenanceFor: () => [],
|
|
314
|
+
distillRecordsFor: () => []
|
|
315
|
+
};
|
|
316
|
+
const ses = provenanceReply({ service: onlyMemory, selector: SES, config: RECORDS_ON });
|
|
317
|
+
assert.equal(ses.status, 503);
|
|
318
|
+
assert.equal(ses.body.error, PROVENANCE_UNAVAILABLE);
|
|
319
|
+
const mem = provenanceReply({ service: onlyMemory, selector: MEM, config: RECORDS_ON });
|
|
320
|
+
assert.equal(mem.status, 200, "缺的只是方向 2 的方法,方向 1 不该跟着坏");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("provenanceReply:开关关着时 session 方向也 409,且一次 SQL 都不发", () => {
|
|
324
|
+
let touched = 0;
|
|
325
|
+
const service = {
|
|
326
|
+
distillWindowsFor: () => { touched++; return []; },
|
|
327
|
+
distillRecordsFor: () => { touched++; return []; }
|
|
328
|
+
};
|
|
329
|
+
const r = provenanceReply({ service, selector: SES, config: { distillRecords: { enabled: false } } });
|
|
330
|
+
assert.equal(r.status, 409);
|
|
331
|
+
assert.deepEqual(r.body, { error: DISTILL_RECORDS_DISABLED }, "方向不同,门是同一把");
|
|
332
|
+
assert.equal(touched, 0);
|
|
333
|
+
});
|
|
334
|
+
|
|
152
335
|
test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,两个入口都要真的走它", () => {
|
|
153
336
|
// #509 第一版把整段复制到了两张皮各一份——本仓 #411 之后不收这个形状。
|
|
154
337
|
for (const name of ["api.js", "api-standalone.js"]) {
|
|
@@ -162,7 +345,7 @@ test("🔴 静态锁:口径字面量与共用答复只许出现在本模块,
|
|
|
162
345
|
assert.ok(!src.includes("distillRecords?.enabled === true"),
|
|
163
346
|
`src/${name} 不准自己写开关判据——只许把 config 转交给共用模块`);
|
|
164
347
|
// 而且要真的转交了:壳里调 provenanceReply 时必须带上 config。
|
|
165
|
-
assert.match(src, /provenanceReply\(\{ service,
|
|
348
|
+
assert.match(src, /provenanceReply\(\{ service, selector: parsed\.selector, config,/,
|
|
166
349
|
`src/${name} 要把本入口的 config 传给共用模块,否则门永远读不到用户的开关`);
|
|
167
350
|
}
|
|
168
351
|
});
|
|
@@ -51,7 +51,13 @@ test("根 README 的两段式路线图能插进新 minor 占位行(旧实现
|
|
|
51
51
|
test("真实根 README 的中英两张路线图各插一行(同一版本号,不能只插一张)", () => {
|
|
52
52
|
// 两张表版本行完全一样,旧实现把全文件版本行合成一个数组判断「已存在」,
|
|
53
53
|
// 于是先插的中文表会让英文表被跳过——英文读者永远等不到那一行。
|
|
54
|
-
|
|
54
|
+
//
|
|
55
|
+
// 目标版本**不能写死**:写死当次要发的版本,这条用例在 release-prep 分支上必红——那一行
|
|
56
|
+
// 恰好已经由发版准备脚本插进去了,「各插一行」于是退化成零插入。取比现存的最高 major 再高一档,
|
|
57
|
+
// 断言就与「这次发的是哪一版」彻底无关,也不会在下一个 minor 上重新长成定时炸弹。
|
|
58
|
+
const maxMajor = Math.max(...scanReleaseRows(rootReadme).two.map((r) => Number(r.ver.split(".")[0])));
|
|
59
|
+
const target = `${maxMajor + 1}.0`;
|
|
60
|
+
const { actions } = syncReadmeTables(rootReadme, target, "2026-11-01");
|
|
55
61
|
const inserts = actions.filter((a) => a.kind === "insert");
|
|
56
62
|
assert.equal(inserts.length, 2, "中英路线图各一行,实际:" + JSON.stringify(actions));
|
|
57
63
|
});
|
|
@@ -104,10 +104,22 @@ function routesInBullet(line) {
|
|
|
104
104
|
return out;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
// 清单行必须**恰好一条**。原先用 .find() 只读第一条命中,于是「同一条目出现两份」
|
|
108
|
+
// 这件事守卫完全无感:#509 的 merge 把两份 Open reads / Gated routes 一起解进来,
|
|
109
|
+
// 其中一份 stale(少 /memory/provenance),CI 却全绿——因为 .find() 读的是第一份。
|
|
110
|
+
// 「文档与代码平价」的全部信心都来自这个文件,一旦它对重复条目零感知,第二份就是没有守卫的谎话。
|
|
111
|
+
// 所以这里先收全部命中,再判唯一;多于一条时把行号报出来,避免变成「哪份算数」的新谜题。
|
|
112
|
+
// source 参数只为可测:守卫本身是「唯一性」,喂一份合成的重复文档就能验,不必真去改 SECURITY.md。
|
|
113
|
+
function bullet(prefix, source = doc) {
|
|
114
|
+
const hits = [];
|
|
115
|
+
source.split(/\r?\n/).forEach((line, i) => {
|
|
116
|
+
if (line.startsWith(prefix)) hits.push({ line, no: i + 1 });
|
|
117
|
+
});
|
|
118
|
+
assert.ok(hits.length > 0, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
|
|
119
|
+
assert.equal(hits.length, 1,
|
|
120
|
+
`SECURITY.md 里以「${prefix}」开头的清单行必须恰好一条,实际 ${hits.length} 条(行号 ${hits.map((h) => h.no).join(", ")})。` +
|
|
121
|
+
"清单只许有一份:多出来的那份不会被本文件校验,改它不会红,只能靠删掉来保持平价。");
|
|
122
|
+
return routesInBullet(hits[0].line);
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
// 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。
|
|
@@ -137,3 +149,30 @@ test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () =>
|
|
|
137
149
|
assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`);
|
|
138
150
|
}
|
|
139
151
|
});
|
|
152
|
+
|
|
153
|
+
// 上面那条唯一性断言本身要有回归:把 SECURITY.md 的清单行复制成两份必须红。
|
|
154
|
+
// 这条用例喂合成文档,不碰真文件——真文件的平价由上面两条双向比对负责。
|
|
155
|
+
// 变异自证(人工做过一次,记录在 PR 里):把 Gated routes 那一行原样复制一份 → 红;
|
|
156
|
+
// 复制成两份且第二份把 /memory/provenance 换成别的 → 也红。两种都只在「恰好一条」上红。
|
|
157
|
+
test("重复的清单行会被守卫抓住(#523)", () => {
|
|
158
|
+
const gated = doc.split(/\r?\n/).find((l) => l.startsWith("- **Gated routes"));
|
|
159
|
+
assert.ok(gated, "SECURITY.md 里应有 Gated routes 清单行");
|
|
160
|
+
|
|
161
|
+
const withDup = ["# synthetic", gated, gated].join("\n");
|
|
162
|
+
assert.throws(
|
|
163
|
+
() => bullet("- **Gated routes", withDup),
|
|
164
|
+
/必须恰好一条,实际 2 条(行号 2, 3)/,
|
|
165
|
+
"同一清单行出现两份时必须报出重复的行号——这正是 #509 merge 时守卫看不见的形状"
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
// 内容不同的两份同样要红:危险的不是写错了什么,而是那份副本对守卫隐形。
|
|
169
|
+
const staleDup = ["# synthetic", gated, gated.replace("/memory/provenance", "/memory/provenance-OLD")].join("\n");
|
|
170
|
+
assert.throws(
|
|
171
|
+
() => bullet("- **Gated routes", staleDup),
|
|
172
|
+
/必须恰好一条,实际 2 条/,
|
|
173
|
+
"两份内容不同的清单同样必须红,否则陈旧副本可以一直过期"
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
// 只有一份时不能误伤。
|
|
177
|
+
assert.ok(bullet("- **Gated routes", gated).length > 0);
|
|
178
|
+
});
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { Config } from "../src/config.js";
|
|
4
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
5
|
+
import { createStore } from "../src/store.js";
|
|
6
|
+
import { createService } from "../src/service.js";
|
|
7
|
+
import { createSettings } from "../src/settings.js";
|
|
8
|
+
import { createInjector } from "../src/inject.js";
|
|
9
|
+
import { MEMORY_GUIDE_SECTION } from "../src/guide.js";
|
|
10
|
+
import { createTools } from "../src/tools.js";
|
|
11
|
+
import { createSummarizer } from "../src/summarize.js";
|
|
12
|
+
|
|
13
|
+
test("session gate defaults off and gates subagents by the host's delegation marks", () => {
|
|
14
|
+
const defaults = Config({});
|
|
15
|
+
assert.equal(defaults.sessionGateEnabled, false);
|
|
16
|
+
assert.equal(defaults.sessionGateSubagents, true);
|
|
17
|
+
assert.deepEqual(defaults.sessionGateSessions, []);
|
|
18
|
+
|
|
19
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
20
|
+
const sessions = [
|
|
21
|
+
{ id: "header-origin", header: { origin: "subagent" } },
|
|
22
|
+
{ id: "header-depth", header: { delegationDepth: 2 } },
|
|
23
|
+
{ id: "header-both", header: { parentSession: "captain", origin: "subagent", delegationDepth: 1 } },
|
|
24
|
+
{ id: "request-origin", requestHeader: () => ({ config: { origin: "subagent", delegationDepth: 1 } }) }
|
|
25
|
+
];
|
|
26
|
+
for (const session of sessions) {
|
|
27
|
+
assert.equal(gate.isOff(session), true, session.id);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const firstEvent = { id: "first-event", header: { parentSession: "captain", origin: "subagent" } };
|
|
31
|
+
assert.equal(gate.isOff(firstEvent), true, "header match works before any session/event was observed");
|
|
32
|
+
gate.observe(firstEvent);
|
|
33
|
+
assert.equal(gate.isOff("first-event"), true, "observed child ids remain recognizable in log scans");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 形状锁:`parentSession` 是「有父会话」,不是「是子代理」。dsh-session 的
|
|
37
|
+
// sessions.fork() 给**主会话分支**也写 parentSession(且不写 origin / 不带
|
|
38
|
+
// delegationDepth),把它当判据就会让用户 fork 出来的普通会话整体失忆。
|
|
39
|
+
// 本机 641 个真实会话头实测:origin/delegationDepth 命中 455 个子代理、零漏检,
|
|
40
|
+
// parentSession 对子代理识别零贡献、只多判出 3 个 fork 主会话(各有 74–136 条
|
|
41
|
+
// 用户消息、694 次工具调用)。这条用例锁的就是那次误判不许回来。
|
|
42
|
+
test("parentSession alone never gates: forked captain sessions stay fully enabled", () => {
|
|
43
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
44
|
+
const forked = { id: "forked-captain", header: { parentSession: "captain", isSeeded: true, delegationDepth: 0 } };
|
|
45
|
+
assert.equal(gate.isOff(forked), false, "a fork of a captain session is still a captain session");
|
|
46
|
+
assert.equal(gate.isOff({ id: "flat-parent", parentSession: "captain" }), false);
|
|
47
|
+
assert.equal(gate.isOff({ id: "alias-parent", parentSessionId: "captain" }), false);
|
|
48
|
+
assert.equal(gate.isOff({ id: "request-parent", requestHeader: () => ({ config: { parentSession: "captain" } }) }), false);
|
|
49
|
+
gate.observe(forked);
|
|
50
|
+
assert.equal(gate.isOff("forked-captain"), false, "observing a non-child must not record it as one");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("manual ids survive the subagent switch, master-off is inert, and config is read live", () => {
|
|
54
|
+
const cfg = { sessionGateEnabled: false, sessionGateSubagents: false, sessionGateSessions: ["manual"] };
|
|
55
|
+
const gate = createSessionGate(cfg);
|
|
56
|
+
const manual = { id: "manual" };
|
|
57
|
+
const child = { id: "child", header: { origin: "subagent" } };
|
|
58
|
+
|
|
59
|
+
assert.equal(gate.isOff(manual), false, "the master is the only opt-in");
|
|
60
|
+
cfg.sessionGateEnabled = true;
|
|
61
|
+
assert.equal(gate.isOff(manual), true, "manual id matches even when automatic child detection is off");
|
|
62
|
+
assert.equal(gate.isOff(child), false, "subagent switch independently controls automatic matching");
|
|
63
|
+
cfg.sessionGateSubagents = true;
|
|
64
|
+
assert.equal(gate.isOff(child), true, "the next call sees a live config change");
|
|
65
|
+
cfg.sessionGateEnabled = false;
|
|
66
|
+
assert.equal(gate.isOff(manual), false, "turning the master off restores normal behavior immediately");
|
|
67
|
+
|
|
68
|
+
// 复现 comment 6541389(#541):上面刚把总开关关掉,若在这里直接断言
|
|
69
|
+
// doesNotThrow,isEnabled() 就先把调用挡回去了,id getter 那条保护路径
|
|
70
|
+
// 一次都不会执行——测的是「关掉时不出事」,不是「读不到 id 时不出事」。
|
|
71
|
+
// 所以先开总开关,再断言两件事:不抛,且被当成普通会话(false),
|
|
72
|
+
// 而不是被当成「id 为空所以放行」的巧合。
|
|
73
|
+
cfg.sessionGateEnabled = true;
|
|
74
|
+
const inaccessible = {};
|
|
75
|
+
Object.defineProperty(inaccessible, "id", { get() { throw new Error("host getter"); } });
|
|
76
|
+
assert.doesNotThrow(() => gate.isOff(inaccessible));
|
|
77
|
+
assert.equal(gate.isOff(inaccessible), false, "an unreadable id is an ordinary session, not a gated one");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// #539 原文:「子会话在首次 session/event 之前就可能组装上下文,故必须直接检查
|
|
81
|
+
// 传入 session,不能只靠事件后记录 ID」。所以「拿不到 id」不能当作「不是无痕
|
|
82
|
+
// 会话」——那一行早退正是这条要求最容易失守的地方(注入面在上下文组装期跑的
|
|
83
|
+
// 就是这条路径)。手动名单仍然依赖 id,那是名单本身的形状,不是缺陷。
|
|
84
|
+
test("a subagent session without a usable id is still gated", () => {
|
|
85
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
86
|
+
assert.equal(gate.isOff({ header: { origin: "subagent" } }), true, "id-less subagent header");
|
|
87
|
+
assert.equal(gate.isOff({ header: { delegationDepth: 1 } }), true, "id-less delegation depth");
|
|
88
|
+
assert.equal(gate.isOff({ id: "", header: { origin: "subagent" } }), true, "empty id is the same as missing");
|
|
89
|
+
assert.equal(gate.isOff({}), false, "no id and no child mark is an ordinary session");
|
|
90
|
+
assert.equal(gate.isOff(null), false);
|
|
91
|
+
assert.equal(gate.isOff(undefined), false);
|
|
92
|
+
const offSwitch = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: false });
|
|
93
|
+
assert.equal(offSwitch.isOff({ header: { origin: "subagent" } }), false, "the subagent switch still wins");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 记账表只服务「日志扫描拿得到 id、却解析不出头」这一条退路,所以它必须有界:
|
|
97
|
+
// 长驻宿主跑上几百个子代理后只涨不落会把每个 id 永久留在内存里,而淘汰最旧的
|
|
98
|
+
// 代价只是那一次日志过滤少认一个会话,活路径永远直接用会话对象判定。
|
|
99
|
+
test("tracked child ids stay bounded and eviction only costs the log-scan fallback", () => {
|
|
100
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSubagents: true });
|
|
101
|
+
for (let i = 0; i < 600; i += 1) gate.observe({ id: `child-${i}`, header: { origin: "subagent" } });
|
|
102
|
+
assert.equal(gate.isOff("child-599"), true, "the newest child is still tracked");
|
|
103
|
+
assert.equal(gate.isOff("child-0"), false, "the oldest id was evicted instead of kept forever");
|
|
104
|
+
gate.observe({ id: "child-599", header: { origin: "subagent" } });
|
|
105
|
+
assert.equal(gate.isOff("child-599"), true, "re-observing the same id stays stable");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function injectionSetup() {
|
|
109
|
+
const store = createStore(":memory:");
|
|
110
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
111
|
+
const settings = createSettings(store.db);
|
|
112
|
+
service.saveWithDedupe({ type: "preference", title: "语言", content: "中文", importance: 5 });
|
|
113
|
+
settings.setProfile("用户画像正文");
|
|
114
|
+
settings.setRules(["先给结论"]);
|
|
115
|
+
const contexts = [];
|
|
116
|
+
const sections = [];
|
|
117
|
+
const ctx = {
|
|
118
|
+
systemPrompt: {
|
|
119
|
+
context(definition) { contexts.push(definition); return () => {}; },
|
|
120
|
+
section(definition) { sections.push(definition); return () => {}; }
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
const gate = createSessionGate({
|
|
124
|
+
sessionGateEnabled: true,
|
|
125
|
+
sessionGateSubagents: true,
|
|
126
|
+
sessionGateSessions: ["private"]
|
|
127
|
+
});
|
|
128
|
+
createInjector(ctx, service, settings, { injectGuidanceEnabled: true }, null, gate);
|
|
129
|
+
return { store, contexts, sections, gate };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
test("all system-prompt surfaces are empty for incognito and remain available to captain", () => {
|
|
133
|
+
const { contexts, sections, gate } = injectionSetup();
|
|
134
|
+
const memory = contexts.find((item) => item.name === "memory");
|
|
135
|
+
const userSettings = contexts.find((item) => item.name === "user-settings");
|
|
136
|
+
const guide = sections.find((item) => item.name === "memory-guide");
|
|
137
|
+
const privateContext = { agent: { session: { id: "private" } } };
|
|
138
|
+
const childContext = { agent: { session: { id: "worker", header: { parentSession: "captain", origin: "subagent" } } } };
|
|
139
|
+
const captainContext = { agent: { session: { id: "captain", header: { origin: "main" } } } };
|
|
140
|
+
|
|
141
|
+
assert.equal(memory.text(privateContext), "");
|
|
142
|
+
assert.equal(userSettings.text(privateContext), "");
|
|
143
|
+
assert.equal(guide.text(privateContext), "");
|
|
144
|
+
assert.equal(memory.text(childContext), "");
|
|
145
|
+
assert.equal(userSettings.text(childContext), "");
|
|
146
|
+
assert.equal(guide.text(childContext), "");
|
|
147
|
+
|
|
148
|
+
assert.match(memory.text(captainContext), /\[记忆库\]/);
|
|
149
|
+
assert.match(userSettings.text(captainContext), /用户画像正文/);
|
|
150
|
+
assert.equal(guide.text(captainContext), MEMORY_GUIDE_SECTION);
|
|
151
|
+
gate.observe(childContext.agent.session);
|
|
152
|
+
assert.equal(gate.isOff("worker"), true);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("every registered memory tool rejects before side effects for an incognito session", async () => {
|
|
156
|
+
const store = createStore(":memory:");
|
|
157
|
+
const baseService = createService({ store, mirror: null, config: {} });
|
|
158
|
+
let serviceCalls = 0;
|
|
159
|
+
const service = new Proxy(baseService, {
|
|
160
|
+
get(target, key, receiver) {
|
|
161
|
+
const value = Reflect.get(target, key, receiver);
|
|
162
|
+
if (typeof value !== "function") return value;
|
|
163
|
+
return (...args) => {
|
|
164
|
+
serviceCalls += 1;
|
|
165
|
+
return value.apply(target, args);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
const registered = [];
|
|
170
|
+
const registry = { register(tool) { registered.push(tool); return () => {}; } };
|
|
171
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
172
|
+
createTools({ tools: registry }, service, {}, null, gate);
|
|
173
|
+
const exec = { agent: { session: { id: "private" } } };
|
|
174
|
+
|
|
175
|
+
assert.ok(registered.length >= 11);
|
|
176
|
+
for (const tool of registered) {
|
|
177
|
+
await assert.rejects(tool.execute({}, exec), /incognito session/, tool.name);
|
|
178
|
+
}
|
|
179
|
+
assert.equal(serviceCalls, 0, "rejected calls do not reach memory service reads or writes");
|
|
180
|
+
assert.equal(store.count(), 0, "no memory rows were written through an executor");
|
|
181
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS n FROM scope_state").get().n, 0, "set_scope_state also rejects before writing");
|
|
182
|
+
|
|
183
|
+
const captainSave = registered.find((tool) => tool.name === "memory_save");
|
|
184
|
+
const result = await captainSave.execute(
|
|
185
|
+
{ type: "preference", title: "captain", content: "可用", importance: 4 },
|
|
186
|
+
{ agent: { session: { id: "captain", header: { origin: "main" } } } }
|
|
187
|
+
);
|
|
188
|
+
assert.equal(result.action, "created", "normal captain tool use is unaffected");
|
|
189
|
+
assert.equal(store.count(), 1);
|
|
190
|
+
store.close();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("retained tool executors use the latest gate after live registry reapply", async () => {
|
|
194
|
+
const store = createStore(":memory:");
|
|
195
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
196
|
+
const registered = [];
|
|
197
|
+
const registry = { register(tool) { registered.push(tool); return () => {}; } };
|
|
198
|
+
const initialGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
199
|
+
createTools({ tools: registry }, service, {}, null, initialGate);
|
|
200
|
+
const retainedSave = registered.find((tool) => tool.name === "memory_save");
|
|
201
|
+
const privateExec = { agent: { session: { id: "private" } } };
|
|
202
|
+
const args = { type: "preference", title: "live gate", content: "check", importance: 4 };
|
|
203
|
+
|
|
204
|
+
await assert.rejects(retainedSave.execute(args, privateExec), /incognito session/);
|
|
205
|
+
const openGate = createSessionGate({ sessionGateEnabled: false });
|
|
206
|
+
createTools({ tools: registry }, service, {}, null, openGate);
|
|
207
|
+
assert.equal(registered.length, 11, "live reapply keeps the host's existing tool registrations");
|
|
208
|
+
assert.equal((await retainedSave.execute(args, privateExec)).action, "created", "off restores the original tool behavior");
|
|
209
|
+
const closedGate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
210
|
+
createTools({ tools: registry }, service, {}, null, closedGate);
|
|
211
|
+
await assert.rejects(
|
|
212
|
+
retainedSave.execute({ ...args, content: "blocked" }, privateExec),
|
|
213
|
+
/incognito session/,
|
|
214
|
+
"a retained tool uses the gate from the latest live reapply"
|
|
215
|
+
);
|
|
216
|
+
assert.equal(store.count(), 1, "the re-applied gate still runs before any write");
|
|
217
|
+
store.close();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
function summarySession(id, header = {}) {
|
|
221
|
+
return {
|
|
222
|
+
id,
|
|
223
|
+
header,
|
|
224
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
225
|
+
events: [
|
|
226
|
+
{ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "选型" }] } },
|
|
227
|
+
{ type: "turn/end", seq: 2 }
|
|
228
|
+
]
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
test("distillation skips incognito sessions before model or storage work", async () => {
|
|
233
|
+
const store = createStore(":memory:");
|
|
234
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
235
|
+
const listeners = [];
|
|
236
|
+
const calls = [];
|
|
237
|
+
const ctx = {
|
|
238
|
+
on(name, handler) { listeners.push({ name, handler }); return () => {}; },
|
|
239
|
+
llm: {
|
|
240
|
+
stream() {
|
|
241
|
+
calls.push(true);
|
|
242
|
+
const output = JSON.stringify([{ type: "decision", title: "选择", content: "落库", importance: 4 }]);
|
|
243
|
+
return (async function* () {
|
|
244
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
245
|
+
yield { type: "text-delta", delta: output };
|
|
246
|
+
yield { type: "block-end", block: { type: "text" } };
|
|
247
|
+
yield { type: "finish", kind: "ok" };
|
|
248
|
+
})();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
const gate = createSessionGate({ sessionGateEnabled: true, sessionGateSessions: ["private"] });
|
|
253
|
+
const summarizer = createSummarizer(ctx, service, { autoSummarize: true, distillRateLimitIntervalMs: 0 }, {}, gate);
|
|
254
|
+
const handler = listeners.find((item) => item.name === "session/event").handler;
|
|
255
|
+
|
|
256
|
+
await handler(summarySession("private"), { type: "turn/end", seq: 2 });
|
|
257
|
+
assert.equal(calls.length, 0);
|
|
258
|
+
assert.equal(store.count(), 0);
|
|
259
|
+
await handler(summarySession("captain", { origin: "main" }), { type: "turn/end", seq: 2 });
|
|
260
|
+
assert.equal(calls.length, 1);
|
|
261
|
+
assert.equal(store.count(), 1, "captain still distills normally");
|
|
262
|
+
summarizer.dispose();
|
|
263
|
+
store.close();
|
|
264
|
+
});
|