@modusensus/dsh-mneme 0.11.0 → 0.13.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 +54 -21
- package/docs/CONFIGURATION.md +45 -4
- package/docs/DIRECTORIES.md +114 -0
- package/docs/DISTILL.md +64 -0
- package/docs/DSH-GO.md +97 -0
- package/docs/ENTITIES.md +5 -3
- package/docs/FORGET.md +77 -0
- package/docs/KB.md +110 -0
- package/docs/SLEEP.md +28 -3
- package/docs/STORAGE.md +23 -0
- package/lib/api-standalone.js +4 -1
- package/lib/api.js +744 -26
- package/lib/attachment-text.js +122 -0
- package/lib/choro-command.js +9 -2
- package/lib/client.js +3752 -819
- package/lib/config.js +171 -2
- package/lib/continuity.js +7 -6
- package/lib/diary/auto.js +190 -0
- package/lib/diary/compose.js +440 -0
- package/lib/diary/dir.js +59 -0
- package/lib/diary/export.js +97 -0
- package/lib/diary/index.js +26 -0
- package/lib/diary/library.js +189 -0
- package/lib/diary/qq-inbox.js +142 -0
- package/lib/diary/rollover.js +61 -0
- package/lib/diary/session-provider.js +122 -0
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/dream/decisions.js +56 -5
- package/lib/dream/sleep.js +616 -15
- package/lib/dream.js +45 -13
- package/lib/dsh-go/host.js +162 -0
- package/lib/dsh-go/panel.js +2091 -0
- package/lib/dsh-go/text.js +342 -0
- package/lib/dsh-go/views/common.js +68 -0
- package/lib/dsh-go/views/entity.js +48 -0
- package/lib/dsh-go/views/memory.js +198 -0
- package/lib/dsh-go/views/root.js +30 -0
- package/lib/dsh-go/views/settings.js +398 -0
- package/lib/dsh-go/views/status.js +51 -0
- package/lib/embedding.js +42 -8
- package/lib/entities/extractor.js +36 -7
- package/lib/hot-memory.js +8 -9
- package/lib/index.js +131 -5
- package/lib/inject.js +192 -19
- package/lib/kb/index.js +309 -0
- package/lib/kb/routes.js +220 -0
- package/lib/kb/tools.js +148 -0
- package/lib/lang.js +115 -4
- package/lib/peak-hours.js +57 -2
- package/lib/profile-facts.js +356 -0
- package/lib/semantic.js +347 -60
- package/lib/service.js +302 -70
- package/lib/settings.js +117 -14
- package/lib/store.js +830 -55
- package/lib/summarize.js +38 -11
- package/lib/tools.js +173 -25
- package/lib/user-settings.js +175 -23
- package/lib/vector-index.js +76 -6
- package/lib/wiki/cli-hint.js +90 -0
- package/lib/wiki/queries.js +264 -10
- package/lib/wiki/t1-jobs.js +232 -0
- package/lib/wiki/t1.js +7 -1
- package/package.json +25 -1
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +4 -1
- package/src/api.js +744 -26
- package/src/attachment-text.js +122 -0
- package/src/choro-command.js +9 -2
- package/src/config.js +171 -2
- package/src/continuity.js +7 -6
- package/src/diary/auto.js +190 -0
- package/src/diary/compose.js +440 -0
- package/src/diary/dir.js +59 -0
- package/src/diary/export.js +97 -0
- package/src/diary/index.js +26 -0
- package/src/diary/library.js +189 -0
- package/src/diary/qq-inbox.js +142 -0
- package/src/diary/rollover.js +61 -0
- package/src/diary/session-provider.js +122 -0
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/dream/decisions.js +56 -5
- package/src/dream/sleep.js +616 -15
- package/src/dream.js +45 -13
- package/src/dsh-go/host.js +162 -0
- package/src/dsh-go/panel.js +2091 -0
- package/src/dsh-go/text.js +342 -0
- package/src/dsh-go/views/common.js +68 -0
- package/src/dsh-go/views/entity.js +48 -0
- package/src/dsh-go/views/memory.js +198 -0
- package/src/dsh-go/views/root.js +30 -0
- package/src/dsh-go/views/settings.js +398 -0
- package/src/dsh-go/views/status.js +51 -0
- package/src/embedding.js +42 -8
- package/src/entities/extractor.js +36 -7
- package/src/hot-memory.js +8 -9
- package/src/index.js +131 -5
- package/src/inject.js +192 -19
- package/src/kb/index.js +309 -0
- package/src/kb/routes.js +220 -0
- package/src/kb/tools.js +148 -0
- package/src/lang.js +115 -4
- package/src/peak-hours.js +57 -2
- package/src/profile-facts.js +356 -0
- package/src/semantic.js +347 -60
- package/src/service.js +302 -70
- package/src/settings.js +117 -14
- package/src/store.js +830 -55
- package/src/summarize.js +38 -11
- package/src/tools.js +173 -25
- package/src/user-settings.js +175 -23
- package/src/vector-index.js +76 -6
- package/src/wiki/cli-hint.js +90 -0
- package/src/wiki/queries.js +264 -10
- package/src/wiki/t1-jobs.js +232 -0
- package/src/wiki/t1.js +7 -1
- package/test/api-routes-gaps.test.js +63 -0
- package/test/api.test.js +377 -4
- package/test/attachment-text.test.js +140 -0
- package/test/bm25-corpus-cache.test.js +145 -0
- package/test/capacity-warning.test.js +262 -0
- package/test/client.test.js +908 -138
- package/test/config.test.js +79 -0
- package/test/conflict-freeze.test.js +45 -1
- package/test/continuity.test.js +8 -1
- package/test/diary-library.test.js +187 -0
- package/test/diary.test.js +576 -0
- package/test/document.test.js +32 -0
- package/test/dream-maxtokens-fallback.test.js +3 -3
- package/test/dream.test.js +62 -1
- package/test/dsh-go-host.test.js +134 -0
- package/test/dsh-go-panel-actions.test.js +854 -0
- package/test/dsh-go-panel.test.js +251 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/embedding-ready.test.js +33 -3
- package/test/entities.test.js +73 -0
- package/test/forget-corrections.test.js +70 -0
- package/test/image-memory.test.js +171 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/issue-617-step1-query-and-fusion.test.js +361 -0
- package/test/kb-api-wiring.test.js +128 -0
- package/test/kb-collections.test.js +272 -0
- package/test/kb-routes.test.js +379 -0
- package/test/mint-source.test.js +26 -0
- package/test/peak-hours-observability.test.js +70 -0
- package/test/persona-api.test.js +3 -3
- package/test/persona-by-session.test.js +568 -0
- package/test/persona-settings.test.js +38 -7
- package/test/profile-facts.test.js +492 -0
- package/test/reflection-update-gate.test.js +129 -0
- package/test/reindex-backfill.test.js +334 -0
- package/test/reindex-scheduler.test.js +369 -0
- package/test/scope-candidates.test.js +249 -0
- package/test/session-gate.test.js +1 -1
- package/test/sleep-action-set.test.js +14 -0
- package/test/sleep-heat.test.js +49 -0
- package/test/sleep.test.js +598 -0
- package/test/standalone-api.test.js +24 -0
- package/test/store.test.js +20 -0
- package/test/summarize.test.js +26 -0
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/user-settings-segments.test.js +528 -0
- package/test/vector-index.test.js +120 -6
- package/test/wiki-api.test.js +476 -1
- package/test/wiki-cli-hint.test.js +118 -0
- package/test/wiki-queries.test.js +91 -1
- package/test/wiki-t1-jobs.test.js +125 -0
- package/test/wiki-tools.test.js +131 -76
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
6
|
+
|
|
7
|
+
// 回归(pin 池被向量路封顶饿死):`injectCandidates` 里 hybrid 分支先把
|
|
8
|
+
// `vectorIndex.search(qv, {limit: poolSize})` 的结果全量 push 进 merged,随后
|
|
9
|
+
// `if (merged.length >= poolSize) break;` 在**pin 筛选之前**就封顶——生产
|
|
10
|
+
// maxItems=5 → poolSize=10,向量路一次正好返回 10 条即 break,规则窗口里那些
|
|
11
|
+
// constraint/preference 一条都进不了 candidates,pin 池只能从这 10 条里筛,
|
|
12
|
+
// 命中几条纯看它们碰巧是什么类型。
|
|
13
|
+
//
|
|
14
|
+
// 代码注释向来自称「pin 不占 maxItems 名额」,实现里却把名额占掉了。实测真库
|
|
15
|
+
// maxItems=5 时 pin shown 只有 2/5,补回后恒 5/5;maxItems=10(poolSize=20)
|
|
16
|
+
// 时前后都是 5/5——所以修法不是多给名额,是把被向量路吃掉的 pin 候选补回来。
|
|
17
|
+
//
|
|
18
|
+
// 修法位置:不在 hybrid 分支之前提前摘(那样会绕过下面 strictScope 与
|
|
19
|
+
// scopeState 两道闸门,而 :1907 的注释明确写着「pin 池 eligible 取自过滤之后的
|
|
20
|
+
// 候选,顺带保证越权行进不了 pin」),而是在合并之后、闸门之前把被甩掉的 pin
|
|
21
|
+
// 类行补回候选池尾部——闸门照旧作用其上。
|
|
22
|
+
//
|
|
23
|
+
// 本文件锁三件事:
|
|
24
|
+
// ① 向量路把 poolSize 填满时,pin 池照旧拿满预算(缺口本体)
|
|
25
|
+
// ② 补回的是规则窗口(items)里的 pin,与向量命中集无关——即使查询向量与
|
|
26
|
+
// pin 条目毫不相干,它们也能进块(这是 pin 的定义:每轮必进)
|
|
27
|
+
// ③ 预算关时行为逐字节不变
|
|
28
|
+
|
|
29
|
+
function setup(config = {}) {
|
|
30
|
+
const store = createStore(":memory:");
|
|
31
|
+
const service = createService({ store, mirror: null, config });
|
|
32
|
+
const vectorIndex = createVectorIndex({ store });
|
|
33
|
+
service.setVectorIndex(vectorIndex);
|
|
34
|
+
return { store, service, vectorIndex };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 向量路上与查询完全同向的普通条目:正好填满 poolSize 个名额,且一条都不是 pin 类型。
|
|
38
|
+
function seedVectorCrowd(service, vectorIndex, n) {
|
|
39
|
+
const ids = [];
|
|
40
|
+
for (let i = 0; i < n; i++) {
|
|
41
|
+
const m = service.saveWithDedupe({
|
|
42
|
+
type: "decision",
|
|
43
|
+
title: `决定条目${i}`,
|
|
44
|
+
content: `第 ${i} 条与查询同向的普通决定,不该占掉 pin 的名额`,
|
|
45
|
+
importance: 5
|
|
46
|
+
}).memory;
|
|
47
|
+
vectorIndex.saveEmbedding(m.id, [1, 0, 0]);
|
|
48
|
+
ids.push(m.id);
|
|
49
|
+
}
|
|
50
|
+
return ids;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 与查询向量正交的 pin 条目:向量路永远检索不到它们,只能靠规则窗口进块。
|
|
54
|
+
function seedPinnedPreferences(service, vectorIndex, n) {
|
|
55
|
+
const ids = [];
|
|
56
|
+
for (let i = 0; i < n; i++) {
|
|
57
|
+
const m = service.saveWithDedupe({
|
|
58
|
+
type: "preference",
|
|
59
|
+
title: `硬偏好${i}`,
|
|
60
|
+
content: `第 ${i} 条约束/偏好,每轮必进,与查询向量正交`,
|
|
61
|
+
importance: 5
|
|
62
|
+
}).memory;
|
|
63
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
64
|
+
ids.push(m.id);
|
|
65
|
+
}
|
|
66
|
+
return ids;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const titlesOf = (list) => list.map((m) => m.title);
|
|
70
|
+
|
|
71
|
+
test("pin 池不被向量路封顶:poolSize 被填满时仍拿满 pinnedInjectBudget", () => {
|
|
72
|
+
// maxItems=5 → poolSize=10,与生产默认值一致;预算是 5,满额。
|
|
73
|
+
const { service, vectorIndex } = setup({
|
|
74
|
+
maxInjectedItems: 5,
|
|
75
|
+
pinnedInjectBudget: 5,
|
|
76
|
+
importanceThreshold: 3
|
|
77
|
+
});
|
|
78
|
+
seedVectorCrowd(service, vectorIndex, 10); // 正好 poolSize 条,全是 decision
|
|
79
|
+
seedPinnedPreferences(service, vectorIndex, 5);
|
|
80
|
+
|
|
81
|
+
const pinnedStats = {};
|
|
82
|
+
const selected = service.injectCandidates({
|
|
83
|
+
query: "随便问一个与偏好无关的问题",
|
|
84
|
+
maxItems: 5,
|
|
85
|
+
queryVector: [1, 0, 0],
|
|
86
|
+
pinnedStats
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 缺口本体的断言:修前这里是 0(向量路 10 条里一条 preference 都没有)。
|
|
90
|
+
assert.equal(pinnedStats.shown, 5, "pin 池必须拿满预算,不能被向量路的 poolSize 截断");
|
|
91
|
+
const pinnedTitles = titlesOf(selected).filter((t) => t.startsWith("硬偏好"));
|
|
92
|
+
assert.equal(pinnedTitles.length, 5, "5 条 pin 必须真的进块(不只是计数对)");
|
|
93
|
+
// pin 前置在块内:前 5 位必须全是 pin 条目(同 importance/updated_at 的
|
|
94
|
+
// 同批条目内部次序由 id 决定,不是本用例要锁的东西,故只断言集合)。
|
|
95
|
+
const head = titlesOf(selected).slice(0, 5);
|
|
96
|
+
assert.ok(head.every((t) => t.startsWith("硬偏好")), `pin 前置到块内排序之前:${head.join(" / ")}`);
|
|
97
|
+
assert.deepEqual([...head].sort(), [...pinnedTitles].sort(), "前 5 位就是那 5 条 pin");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("pin 池取自规则窗口,不取自被封顶的 candidates", () => {
|
|
101
|
+
// 这条锁的是「怎么修」而不是「修没修」:向量路返回的 10 条与 pin 类型完全
|
|
102
|
+
// 不相交,若 eligible 仍从 candidates 过滤,pin 恒为 0——无论 poolSize 多大。
|
|
103
|
+
const { service, vectorIndex } = setup({
|
|
104
|
+
maxInjectedItems: 2,
|
|
105
|
+
pinnedInjectBudget: 2,
|
|
106
|
+
importanceThreshold: 3
|
|
107
|
+
});
|
|
108
|
+
seedVectorCrowd(service, vectorIndex, 4); // poolSize = maxItems*2 = 4,正好填满
|
|
109
|
+
seedPinnedPreferences(service, vectorIndex, 2);
|
|
110
|
+
|
|
111
|
+
const pinnedStats = {};
|
|
112
|
+
const selected = service.injectCandidates({
|
|
113
|
+
query: "另一个与偏好无关的问题",
|
|
114
|
+
maxItems: 2,
|
|
115
|
+
queryVector: [1, 0, 0],
|
|
116
|
+
pinnedStats
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
assert.equal(pinnedStats.shown, 2, "maxItems=2 时 pin 也要拿满 2 条");
|
|
120
|
+
const head = titlesOf(selected).slice(0, 2);
|
|
121
|
+
assert.ok(
|
|
122
|
+
head.every((t) => t.startsWith("硬偏好")),
|
|
123
|
+
`pin 从规则窗口取,与向量命中集无关:${head.join(" / ")}`
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("pin 池关闭时行为不变:pinnedInjectBudget=0 不得凭空引入偏好", () => {
|
|
128
|
+
// 关闭态必须与改动前逐字节一致——补回的 pin 只在预算 > 0 时发生,
|
|
129
|
+
// 预算关时 candidates 与改前完全一样。
|
|
130
|
+
const { service, vectorIndex } = setup({
|
|
131
|
+
maxInjectedItems: 5,
|
|
132
|
+
pinnedInjectBudget: 0,
|
|
133
|
+
importanceThreshold: 3
|
|
134
|
+
});
|
|
135
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
136
|
+
seedPinnedPreferences(service, vectorIndex, 5);
|
|
137
|
+
|
|
138
|
+
const pinnedStats = {};
|
|
139
|
+
const selected = service.injectCandidates({
|
|
140
|
+
query: "又一个与偏好无关的问题",
|
|
141
|
+
maxItems: 5,
|
|
142
|
+
queryVector: [1, 0, 0],
|
|
143
|
+
pinnedStats
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
assert.equal(pinnedStats.shown, 0, "预算 0 → pin 池关闭");
|
|
147
|
+
assert.equal(selected.length, 5, "槽位数不变");
|
|
148
|
+
assert.ok(
|
|
149
|
+
titlesOf(selected).every((t) => t.startsWith("决定条目")),
|
|
150
|
+
"关闭态下 preference 不得被前置(顺序与改动前一致)"
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// 安全底线:补回 pin 是在 strictScope 与 scopeState 两道闸门**之前**做的,
|
|
155
|
+
// 所以这两道闸必须照旧能拦住被补回来的 pin 行。service.js 里那条位置纪律
|
|
156
|
+
// (「pin 池 eligible 取自过滤之后的候选,顺带保证越权行进不了 pin」)就是靠
|
|
157
|
+
// 这两条用例守住的——不然「补回」会顺带开一个越权口子。
|
|
158
|
+
test("补回的 pin 仍受 strictScope 硬过滤:越权偏好不得因补回而进块", () => {
|
|
159
|
+
const { store, service, vectorIndex } = setup({
|
|
160
|
+
maxInjectedItems: 5,
|
|
161
|
+
pinnedInjectBudget: 5,
|
|
162
|
+
importanceThreshold: 3,
|
|
163
|
+
strictScope: true
|
|
164
|
+
});
|
|
165
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
166
|
+
// 一条属于 proj-a 的偏好:调用方 scope 是 proj-b 时它必须进不来。
|
|
167
|
+
const m = service.saveWithDedupe({
|
|
168
|
+
type: "preference",
|
|
169
|
+
title: "越权偏好",
|
|
170
|
+
content: "只属于 proj-a 的偏好,不该泄进 proj-b 的注入块",
|
|
171
|
+
importance: 5,
|
|
172
|
+
workspace_scope: "proj-a",
|
|
173
|
+
workspace_scope_source: "explicit"
|
|
174
|
+
}).memory;
|
|
175
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
176
|
+
|
|
177
|
+
const pinnedStats = {};
|
|
178
|
+
const selected = service.injectCandidates({
|
|
179
|
+
query: "与偏好无关的问题",
|
|
180
|
+
maxItems: 5,
|
|
181
|
+
queryVector: [1, 0, 0],
|
|
182
|
+
scope: { agent_scope: null, workspace_scope: "proj-b" },
|
|
183
|
+
pinnedStats
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
assert.ok(
|
|
187
|
+
!titlesOf(selected).includes("越权偏好"),
|
|
188
|
+
`strictScope 必须挡住被补回的越权 pin:${titlesOf(selected).join(" / ")}`
|
|
189
|
+
);
|
|
190
|
+
assert.equal(pinnedStats.shown, 0, "被 scope 挡下的行不能算成已 pin");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("补回的 pin 仍受 scopeState 拦截档约束:暂停作用域的偏好不得进块", () => {
|
|
194
|
+
const { service, vectorIndex } = setup({
|
|
195
|
+
maxInjectedItems: 5,
|
|
196
|
+
pinnedInjectBudget: 5,
|
|
197
|
+
importanceThreshold: 3,
|
|
198
|
+
scopeState: { enabled: true, enforce: true }
|
|
199
|
+
});
|
|
200
|
+
seedVectorCrowd(service, vectorIndex, 10);
|
|
201
|
+
const m = service.saveWithDedupe({
|
|
202
|
+
type: "preference",
|
|
203
|
+
title: "暂停域偏好",
|
|
204
|
+
content: "属于 proj-a 的偏好,而 proj-a 此刻被按下",
|
|
205
|
+
importance: 5,
|
|
206
|
+
workspace_scope: "proj-a",
|
|
207
|
+
workspace_scope_source: "explicit"
|
|
208
|
+
}).memory;
|
|
209
|
+
vectorIndex.saveEmbedding(m.id, [0, 1, 0]);
|
|
210
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
211
|
+
|
|
212
|
+
const pinnedStats = {};
|
|
213
|
+
const selected = service.injectCandidates({
|
|
214
|
+
query: "与偏好无关的问题",
|
|
215
|
+
maxItems: 5,
|
|
216
|
+
queryVector: [1, 0, 0],
|
|
217
|
+
pinnedStats
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
assert.ok(
|
|
221
|
+
!titlesOf(selected).includes("暂停域偏好"),
|
|
222
|
+
`scopeState 拦截档必须挡住被补回的 pin:${titlesOf(selected).join(" / ")}`
|
|
223
|
+
);
|
|
224
|
+
assert.equal(pinnedStats.shown, 0, "被闸门拦下的行不能算成已 pin");
|
|
225
|
+
});
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createInjector } from "../src/inject.js";
|
|
6
|
+
import { createSettings } from "../src/settings.js";
|
|
7
|
+
import { createVectorIndex } from "../src/vector-index.js";
|
|
8
|
+
import { createSessionGate } from "../src/session-gate.js";
|
|
9
|
+
|
|
10
|
+
// #617 回归。三条缺陷各自独立成组,组内都用「一正一反」两把尺子:正向锁
|
|
11
|
+
// 「改对了」,反向(关掉对应开关 / 换成对照配方)锁「这条断言真的是在测那条
|
|
12
|
+
// 通路」,免得断言退化成常量真。
|
|
13
|
+
|
|
14
|
+
// --- 组 1:Step 1 的当轮查询(根因 A)-------------------------------------
|
|
15
|
+
//
|
|
16
|
+
// 宿主 agent-loop 的每个 step 是 claim() → assemble() → step(),而 user/message
|
|
17
|
+
// 要到 step() 末尾才 append。注入的 text 回调在 assemble 里求值,所以只看事件流
|
|
18
|
+
// 必然慢一拍:Step 1 拿到 "",第 N 轮的 Step 1 拿到第 N-1 轮的旧输入。
|
|
19
|
+
// 票据(agent/inbox/claimed)在 claim 那一刻记原文,渲染时事件流还没长就用它。
|
|
20
|
+
function setupInjector(over = {}) {
|
|
21
|
+
const store = createStore(":memory:");
|
|
22
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
23
|
+
const settings = createSettings(store.db);
|
|
24
|
+
const contexts = [];
|
|
25
|
+
const handlers = new Map();
|
|
26
|
+
const ctx = {
|
|
27
|
+
// 真实宿主 ctx 有 on();旧夹具没有,所以旧夹具永远测不到这条路径。
|
|
28
|
+
on(event, fn) {
|
|
29
|
+
handlers.set(event, fn);
|
|
30
|
+
return () => handlers.delete(event);
|
|
31
|
+
},
|
|
32
|
+
systemPrompt: {
|
|
33
|
+
context(def) {
|
|
34
|
+
contexts.push(def);
|
|
35
|
+
return () => {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const config = { maxInjectedItems: 3, importanceThreshold: 3, ...over };
|
|
40
|
+
const injector = createInjector(ctx, service, settings, config);
|
|
41
|
+
// 查询词是注入链路里唯一能直接观测的输入:包一层记下每次渲染实际传了什么。
|
|
42
|
+
const queries = [];
|
|
43
|
+
const orig = service.injectCandidates.bind(service);
|
|
44
|
+
service.injectCandidates = (args) => {
|
|
45
|
+
queries.push(args.query);
|
|
46
|
+
return orig(args);
|
|
47
|
+
};
|
|
48
|
+
const fireClaim = (message, session) => handlers.get("agent/inbox/claimed")?.({ message, agent: { session } });
|
|
49
|
+
return { store, service, contexts, injector, queries, fireClaim, handlers };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const userMessage = (text) => ({ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text }] } });
|
|
53
|
+
const claimed = (text) => ({ content: [{ type: "text", text }], source: { kind: "user" }, id: "m1" });
|
|
54
|
+
|
|
55
|
+
test("#617-A: Step 1 在 user/message 落盘之前,用认领票据拿到当轮输入", () => {
|
|
56
|
+
const { contexts, queries, fireClaim } = setupInjector();
|
|
57
|
+
const session = { id: "s-617", events: [], snapshotEvents() { return this.events; } };
|
|
58
|
+
// claim 发生在 assemble 之前,此刻事件流里还没有本轮消息
|
|
59
|
+
fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
|
|
60
|
+
assert.equal(session.events.length, 0, "夹具前提:认领时 user/message 尚未落盘");
|
|
61
|
+
contexts[0].text({ agent: { session } });
|
|
62
|
+
assert.equal(queries.at(-1), "夜航App令牌桶限流方案怎么设计", "Step 1 必须拿到当轮输入,而不是空串");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("#617-A: claim 与 assemble 之间插入无关事件,票据不得提前退休", () => {
|
|
66
|
+
// 宿主 preStep 的顺序是 claim() → await tools.resolve() → assemble()。中间那个
|
|
67
|
+
// await 给了别的插件追加事件的机会;若按「事件条数变多」判票据失效,Step 1 就
|
|
68
|
+
// 又退回空串——正是本 issue 要修的那一拍。判据必须是文本,不是条数。
|
|
69
|
+
const { contexts, queries, fireClaim } = setupInjector();
|
|
70
|
+
const session = { id: "s-617i", events: [], snapshotEvents() { return this.events; } };
|
|
71
|
+
session.events.push(userMessage("上一轮的问题"));
|
|
72
|
+
fireClaim(claimed("这一轮的新问题"), session);
|
|
73
|
+
// tools.resolve 期间,别的插件追加了一个与用户消息无关的事件
|
|
74
|
+
session.events.push({ type: "system/message", data: { content: [{ type: "text", text: "插件上下文" }] } });
|
|
75
|
+
contexts[0].text({ agent: { session } });
|
|
76
|
+
assert.equal(queries.at(-1), "这一轮的新问题", "无关事件不得让票据提前失效");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("#617-A: 事件流长过票据记录后,票据退休、以事件流为准", () => {
|
|
80
|
+
const { contexts, queries, fireClaim } = setupInjector();
|
|
81
|
+
const session = { id: "s-617b", events: [], snapshotEvents() { return this.events; } };
|
|
82
|
+
fireClaim(claimed("第一个问题"), session);
|
|
83
|
+
contexts[0].text({ agent: { session } });
|
|
84
|
+
assert.equal(queries.at(-1), "第一个问题");
|
|
85
|
+
// step() 末尾 append → 事件流变长
|
|
86
|
+
session.events.push(userMessage("第一个问题"));
|
|
87
|
+
contexts[0].text({ agent: { session } });
|
|
88
|
+
assert.equal(queries.at(-1), "第一个问题", "落盘后两者一致(此轮无差异)");
|
|
89
|
+
// 下一轮:新的 claim 覆盖票据,事件流里仍是上一轮的文本
|
|
90
|
+
fireClaim(claimed("第二个问题"), session);
|
|
91
|
+
contexts[0].text({ agent: { session } });
|
|
92
|
+
assert.equal(queries.at(-1), "第二个问题", "新一轮的 Step 1 不得拿到上一轮的旧输入");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("#617-A: 非人类消息(source.kind 非 user)不产生票据", () => {
|
|
96
|
+
const { contexts, queries, fireClaim } = setupInjector();
|
|
97
|
+
const session = { id: "s-617c", events: [], snapshotEvents() { return this.events; } };
|
|
98
|
+
fireClaim({ content: [{ type: "text", text: "插件注入的上下文" }], source: { kind: "system" } }, session);
|
|
99
|
+
contexts[0].text({ agent: { session } });
|
|
100
|
+
assert.equal(queries.at(-1), "", "插件自己的话不得被当成用户问题");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("#617-A: 卸载后摘掉 claimed 监听,不再写票据", () => {
|
|
104
|
+
const { contexts, injector, queries, fireClaim, handlers } = setupInjector();
|
|
105
|
+
const session = { id: "s-617d", events: [], snapshotEvents() { return this.events; } };
|
|
106
|
+
assert.ok(handlers.has("agent/inbox/claimed"), "挂载时注册监听");
|
|
107
|
+
injector();
|
|
108
|
+
assert.equal(handlers.has("agent/inbox/claimed"), false, "卸载即摘,不留孤儿监听");
|
|
109
|
+
fireClaim(claimed("卸载后不该记"), session);
|
|
110
|
+
// 注入器已卸载,context 也不再生效;这里只确认没有票据泄漏到下一次装配
|
|
111
|
+
assert.equal(queries.length, 0);
|
|
112
|
+
void contexts;
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// --- 组 2:prefetch 去重与失败冷却(根因 B)-------------------------------
|
|
116
|
+
//
|
|
117
|
+
// 旧实现用 lastPrefetched 字符串去重,且置位在 await 之前 —— 同一条查询第一次
|
|
118
|
+
// 失败(模型没下好、超时)之后永不重试,而那恰好是最需要重试的一次。
|
|
119
|
+
function setupPrefetch(embedImpl) {
|
|
120
|
+
const store = createStore(":memory:");
|
|
121
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
122
|
+
const settings = createSettings(store.db);
|
|
123
|
+
const contexts = [];
|
|
124
|
+
const ctx = { systemPrompt: { context(def) { contexts.push(def); return () => {}; } } };
|
|
125
|
+
const calls = [];
|
|
126
|
+
service.setEmbedder({
|
|
127
|
+
embedSingle: async (text) => {
|
|
128
|
+
calls.push(text);
|
|
129
|
+
return embedImpl(text);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
const injector = createInjector(ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3 });
|
|
133
|
+
return { store, service, contexts, injector, calls };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const renderWith = (contexts, session) => contexts[0].text({ agent: { session } });
|
|
137
|
+
const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
138
|
+
|
|
139
|
+
test("#617-B: 同一步的两次求值(assemble + refreshContext)只嵌一次", async () => {
|
|
140
|
+
const { contexts, calls } = setupPrefetch(async () => [1, 0, 0]);
|
|
141
|
+
const session = { id: "p1", events: [userMessage("查询文本")], snapshotEvents() { return this.events; } };
|
|
142
|
+
renderWith(contexts, session); // 第一次求值:发起 inflight
|
|
143
|
+
renderWith(contexts, session); // 第二次求值:必须复用 inflight,不再嵌
|
|
144
|
+
await tick();
|
|
145
|
+
renderWith(contexts, session); // 第三次:命中缓存
|
|
146
|
+
assert.equal(calls.length, 1, "同一条查询只应嵌入一次");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("#617-B: 失败后进入冷却期,不在同一帧反复撞模型", async () => {
|
|
150
|
+
const { contexts, calls } = setupPrefetch(async () => { throw new Error("model not ready"); });
|
|
151
|
+
const session = { id: "p2", events: [userMessage("失败查询")], snapshotEvents() { return this.events; } };
|
|
152
|
+
renderWith(contexts, session);
|
|
153
|
+
await tick();
|
|
154
|
+
renderWith(contexts, session);
|
|
155
|
+
await tick();
|
|
156
|
+
renderWith(contexts, session);
|
|
157
|
+
assert.equal(calls.length, 1, "冷却期内不得重试(15s 内)");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("#617-B: 冷却按查询分别计,别的查询照常嵌入", async () => {
|
|
161
|
+
const { contexts, calls } = setupPrefetch(async (text) => {
|
|
162
|
+
if (text.includes("失败")) throw new Error("boom");
|
|
163
|
+
return [1, 0, 0];
|
|
164
|
+
});
|
|
165
|
+
const s1 = { id: "p3", events: [userMessage("失败查询")], snapshotEvents() { return this.events; } };
|
|
166
|
+
renderWith(contexts, s1);
|
|
167
|
+
await tick();
|
|
168
|
+
renderWith(contexts, s1);
|
|
169
|
+
const s2 = { id: "p4", events: [userMessage("另一条查询")], snapshotEvents() { return this.events; } };
|
|
170
|
+
renderWith(contexts, s2);
|
|
171
|
+
await tick();
|
|
172
|
+
assert.equal(calls.length, 2, "一条查询的失败冷却不应连坐其它查询");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// --- 组 3:向量路与 BM25 路并存(根因 C,前半)----------------------------
|
|
176
|
+
function seedStalePreferences(service, n = 8) {
|
|
177
|
+
const ids = [];
|
|
178
|
+
for (let i = 0; i < n; i++) {
|
|
179
|
+
ids.push(service.saveWithDedupe({
|
|
180
|
+
type: "preference",
|
|
181
|
+
title: `旧偏好条目${i}`,
|
|
182
|
+
content: `用户喜欢深色界面与紧凑排版,这是第${i}条历史偏好存档`,
|
|
183
|
+
importance: 5
|
|
184
|
+
}).memory.id);
|
|
185
|
+
}
|
|
186
|
+
return ids;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function setupDualPath(config = {}) {
|
|
190
|
+
const store = createStore(":memory:");
|
|
191
|
+
const service = createService({ store, mirror: null, config });
|
|
192
|
+
return { store, service };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
test("#617-C: 有向量命中时,BM25 的词法精确命中不再整批消失", () => {
|
|
196
|
+
const { service, store } = setupDualPath();
|
|
197
|
+
seedStalePreferences(service, 8);
|
|
198
|
+
// 与查询词法完全不相交,但向量最近
|
|
199
|
+
const vecOnly = service.saveWithDedupe({
|
|
200
|
+
type: "project", title: "星图渲染重构", content: "绘制管线迁移与图层合并", importance: 3
|
|
201
|
+
}).memory;
|
|
202
|
+
// 词法精确命中,但没有向量(真实库里大量老行就是这样)
|
|
203
|
+
const lexHit = service.saveWithDedupe({
|
|
204
|
+
type: "decision", title: "夜航限流选型", content: "决定采用令牌桶做夜航App的限流方案", importance: 3
|
|
205
|
+
}).memory;
|
|
206
|
+
const vectorIndex = createVectorIndex({ store });
|
|
207
|
+
service.setVectorIndex(vectorIndex);
|
|
208
|
+
vectorIndex.saveEmbedding(vecOnly.id, [1, 0, 0]);
|
|
209
|
+
|
|
210
|
+
const picked = service.injectCandidates({
|
|
211
|
+
query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0]
|
|
212
|
+
});
|
|
213
|
+
assert.equal(picked[0].id, vecOnly.id, "纯余弦命中仍是首位");
|
|
214
|
+
assert.ok(picked.some((m) => m.id === lexHit.id), "BM25 命中不得被向量路挤掉(旧代码:有向量则 BM25 整段跳过)");
|
|
215
|
+
assert.ok(picked.length <= 5, "池子仍按 maxItems 封顶");
|
|
216
|
+
store.close();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("#617-C: 关掉 BM25 后同一条词法命中确实消失(反向对照)", () => {
|
|
220
|
+
const { service, store } = setupDualPath({ bm25SearchEnabled: false });
|
|
221
|
+
seedStalePreferences(service, 8);
|
|
222
|
+
const vecOnly = service.saveWithDedupe({
|
|
223
|
+
type: "project", title: "星图渲染重构", content: "绘制管线迁移与图层合并", importance: 3
|
|
224
|
+
}).memory;
|
|
225
|
+
const lexHit = service.saveWithDedupe({
|
|
226
|
+
type: "decision", title: "夜航限流选型", content: "决定采用令牌桶做夜航App的限流方案", importance: 3
|
|
227
|
+
}).memory;
|
|
228
|
+
const vectorIndex = createVectorIndex({ store });
|
|
229
|
+
service.setVectorIndex(vectorIndex);
|
|
230
|
+
vectorIndex.saveEmbedding(vecOnly.id, [1, 0, 0]);
|
|
231
|
+
|
|
232
|
+
const picked = service.injectCandidates({
|
|
233
|
+
query: "夜航App令牌桶限流方案怎么设计的", maxItems: 5, threshold: 3, queryVector: [1, 0, 0]
|
|
234
|
+
});
|
|
235
|
+
assert.equal(picked[0].id, vecOnly.id);
|
|
236
|
+
assert.equal(picked.some((m) => m.id === lexHit.id), false, "BM25 关 = 词法路不参与,正向用例才不是在测别的东西");
|
|
237
|
+
store.close();
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// --- 组 4:auto 不再被 keyword 覆盖(根因 C,后半)------------------------
|
|
241
|
+
//
|
|
242
|
+
// 旧代码在 fuseRecall 收尾处,只要 recipe !== "blend" 且 mode === "auto",就用
|
|
243
|
+
// keyword.slice(0,lim) 覆盖 merged —— 于是配了 rrf 却拿到 keyword 的顺序,配方
|
|
244
|
+
// 白选。auto 的「关键词领位」语义只属于 blend。
|
|
245
|
+
function setupFusion(recipe) {
|
|
246
|
+
const store = createStore(":memory:");
|
|
247
|
+
const service = createService({ store, mirror: null, config: { recallFusion: recipe } });
|
|
248
|
+
// 标题命中(keyword 第一),但没有向量
|
|
249
|
+
const kwTitle = service.saveWithDedupe({
|
|
250
|
+
type: "preference", title: "令牌桶限流笔记", content: "随手记的限流笔记", importance: 2
|
|
251
|
+
}).memory;
|
|
252
|
+
// 正文命中(keyword 第二)+ 向量第一 —— 两路都靠前
|
|
253
|
+
const both = service.saveWithDedupe({
|
|
254
|
+
type: "decision", title: "夜航限流选型", content: "决定采用令牌桶限流方案,阈值按等级分层", importance: 5
|
|
255
|
+
}).memory;
|
|
256
|
+
const vectorIndex = createVectorIndex({ store });
|
|
257
|
+
service.setVectorIndex(vectorIndex);
|
|
258
|
+
vectorIndex.saveEmbedding(both.id, [1, 0, 0]);
|
|
259
|
+
service.setEmbedder({ embedSingle: async () => [1, 0, 0] });
|
|
260
|
+
return { service, store, kwTitle, both };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
test("#617-C: recallFusion=rrf 时 auto 用 RRF 自己的排序,不再被 keyword 覆盖", async () => {
|
|
264
|
+
const { service, store, kwTitle, both } = setupFusion("rrf");
|
|
265
|
+
const rows = await service.searchMemories("令牌桶限流", { mode: "auto", topK: 2, useRerank: false, recordRecall: false });
|
|
266
|
+
assert.equal(rows.length, 2);
|
|
267
|
+
assert.equal(rows[0].id, both.id, "两路都命中的行必须排第一(keyword 只命中标题的行不该领位)");
|
|
268
|
+
assert.equal(rows[1].id, kwTitle.id);
|
|
269
|
+
store.close();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("#617-C: blend 的 auto 关键词领位契约保持不变(反向对照)", async () => {
|
|
273
|
+
const { service, store, kwTitle, both } = setupFusion("blend");
|
|
274
|
+
const rows = await service.searchMemories("令牌桶限流", { mode: "auto", topK: 2, useRerank: false, recordRecall: false });
|
|
275
|
+
assert.equal(rows.length, 2);
|
|
276
|
+
assert.equal(rows[0].id, kwTitle.id, "blend 下 keyword 领位是既有契约,本次改动不该碰它");
|
|
277
|
+
assert.equal(rows[1].id, both.id);
|
|
278
|
+
store.close();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// --- 组 5:无痕会话的认领路径不得外发(#539 × #617)-----------------------
|
|
282
|
+
//
|
|
283
|
+
// claim 在宿主 pre-step 里同步触发,早于 assemble,因此也早于渲染侧的闸门判断。
|
|
284
|
+
// 这条路径既写票据(票据会成为注入查询文本)又预取向量(embedQuery 直接 POST
|
|
285
|
+
// 外部 /embeddings),闸门命中时两者都是该会话数据的外流;嵌入层自身没有任何
|
|
286
|
+
// 会话维度判断(embedding.js / service.js 里 sessionGate 零命中),这里是唯一
|
|
287
|
+
// 拦截点。两条反向对照(未命中名单 / 总开关关闭)保证断言不是退化成「嵌入器
|
|
288
|
+
// 恰好没跑」。
|
|
289
|
+
function setupGated(over = {}) {
|
|
290
|
+
const store = createStore(":memory:");
|
|
291
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
292
|
+
const settings = createSettings(store.db);
|
|
293
|
+
const contexts = [];
|
|
294
|
+
const handlers = new Map();
|
|
295
|
+
const ctx = {
|
|
296
|
+
on(event, fn) {
|
|
297
|
+
handlers.set(event, fn);
|
|
298
|
+
return () => handlers.delete(event);
|
|
299
|
+
},
|
|
300
|
+
systemPrompt: { context(def) { contexts.push(def); return () => {}; } }
|
|
301
|
+
};
|
|
302
|
+
const calls = [];
|
|
303
|
+
service.setEmbedder({
|
|
304
|
+
embedSingle: async (text) => {
|
|
305
|
+
calls.push(text);
|
|
306
|
+
return [1, 0, 0];
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
const queries = [];
|
|
310
|
+
const orig = service.injectCandidates.bind(service);
|
|
311
|
+
service.injectCandidates = (args) => {
|
|
312
|
+
queries.push(args.query);
|
|
313
|
+
return orig(args);
|
|
314
|
+
};
|
|
315
|
+
const sessionGate = createSessionGate({
|
|
316
|
+
sessionGateEnabled: true,
|
|
317
|
+
sessionGateSessions: ["s-gated"],
|
|
318
|
+
sessionGateSubagents: true,
|
|
319
|
+
...over
|
|
320
|
+
});
|
|
321
|
+
const injector = createInjector(
|
|
322
|
+
ctx, service, settings, { maxInjectedItems: 3, importanceThreshold: 3 }, null, sessionGate
|
|
323
|
+
);
|
|
324
|
+
const fireClaim = (message, session) => handlers.get("agent/inbox/claimed")?.({ message, agent: { session } });
|
|
325
|
+
return { store, service, contexts, injector, calls, queries, fireClaim, sessionGate };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
test("#617-D: 无痕会话的认领消息既不预取向量,也不留票据", async () => {
|
|
329
|
+
const { contexts, calls, queries, fireClaim, sessionGate } = setupGated();
|
|
330
|
+
const session = { id: "s-gated", events: [], snapshotEvents() { return this.events; } };
|
|
331
|
+
assert.equal(sessionGate.isOff(session), true, "夹具前提:闸门认得出这个会话");
|
|
332
|
+
fireClaim(claimed("桉桉的私人日记:我今天去了协和医院复查"), session);
|
|
333
|
+
await tick();
|
|
334
|
+
assert.equal(calls.length, 0, "闸门命中时不得把原文发往嵌入端点");
|
|
335
|
+
// 票据若被写下,渲染时事件流还没长,Step 1 就会拿到这段原文
|
|
336
|
+
contexts[0].text({ agent: { session } });
|
|
337
|
+
assert.deepEqual(queries, [], "闸门命中时连 injectCandidates 都不该被调到");
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("#617-D: 未命中名单的会话照常预取(反向对照)", async () => {
|
|
341
|
+
const { contexts, calls, queries, fireClaim, sessionGate } = setupGated();
|
|
342
|
+
const session = { id: "s-normal", events: [], snapshotEvents() { return this.events; } };
|
|
343
|
+
assert.equal(sessionGate.isOff(session), false);
|
|
344
|
+
fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
|
|
345
|
+
await tick();
|
|
346
|
+
assert.equal(calls.length, 1, "普通会话的预取行为不变");
|
|
347
|
+
assert.equal(calls[0], "夜航App令牌桶限流方案怎么设计");
|
|
348
|
+
// 票据确实写下了:渲染拿到的就是认领的原文
|
|
349
|
+
contexts[0].text({ agent: { session } });
|
|
350
|
+
assert.deepEqual(queries, ["夜航App令牌桶限流方案怎么设计"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("#617-D: 总开关关闭时闸门不生效(反向对照)", async () => {
|
|
354
|
+
const { calls, fireClaim, sessionGate } = setupGated({ sessionGateEnabled: false });
|
|
355
|
+
const session = { id: "s-gated", events: [], snapshotEvents() { return this.events; } };
|
|
356
|
+
assert.equal(sessionGate.isEnabled(), false);
|
|
357
|
+
assert.equal(sessionGate.isOff(session), false, "总开关关时名单不得生效");
|
|
358
|
+
fireClaim(claimed("夜航App令牌桶限流方案怎么设计"), session);
|
|
359
|
+
await tick();
|
|
360
|
+
assert.equal(calls.length, 1, "未开启 #539 时不得凭空拦截(默认行为须与基线一致)");
|
|
361
|
+
});
|