@modusensus/dsh-mneme 0.7.14 → 0.7.16
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.en.md +2 -2
- package/README.md +6 -4
- package/lib/api.js +338 -7
- package/lib/client.js +1238 -204
- package/lib/dream/sleep.js +17 -15
- package/lib/dream.js +94 -48
- package/lib/index.js +25 -2
- package/lib/mirror.js +93 -59
- package/lib/service.js +2 -0
- package/lib/settings.js +174 -0
- package/lib/store.js +77 -4
- package/package.json +12 -1
- package/src/api.js +338 -7
- package/src/dream/sleep.js +17 -15
- package/src/dream.js +94 -48
- package/src/index.js +25 -2
- package/src/mirror.js +93 -59
- package/src/service.js +2 -0
- package/src/settings.js +174 -0
- package/src/store.js +77 -4
- package/test/api.test.js +485 -2
- package/test/client.test.js +82 -52
- package/test/dream.test.js +1 -1
- package/test/helpers/peer-worker.mjs +17 -2
- package/test/llm-audit.test.js +34 -4
- package/test/peer-blockers.test.js +8 -2
- package/test/reasoning-effort.test.js +64 -0
- package/test/settings.test.js +136 -0
package/test/client.test.js
CHANGED
|
@@ -41,71 +41,71 @@ test("memory entry registers into the sidebar foot slot", () => {
|
|
|
41
41
|
);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
|
-
// The
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
test("tab-first activation with hero-screen overlay fallback", () => {
|
|
44
|
+
// The tab era is over: the in-conversation memory tab fought the floating
|
|
45
|
+
// composer and squeezed the session layout, so the library no longer
|
|
46
|
+
// registers as a conversation view. The sidebar entry opens the centered
|
|
47
|
+
// sheet directly — from any state, including the new-chat hero screen. A
|
|
48
|
+
// dialog role stays banned either way.
|
|
49
|
+
test("sidebar entry opens the sheet; conversation-view tab stays retired", () => {
|
|
51
50
|
assert.equal(
|
|
52
51
|
clientSource.includes("role: \"dialog\""),
|
|
53
52
|
false,
|
|
54
53
|
"no dialog surface should remain"
|
|
55
54
|
);
|
|
56
55
|
assert.ok(
|
|
57
|
-
|
|
58
|
-
"the
|
|
56
|
+
/function openLibrary\(\) \{\s*setOverlayOpen\(true\);\s*\}/.test(clientSource),
|
|
57
|
+
"the entry must open the overlay directly (no tab activation)"
|
|
59
58
|
);
|
|
60
|
-
assert.
|
|
61
|
-
clientSource.includes(
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
assert.ok(
|
|
65
|
-
/if \(candidates\.length === 0\) \{ resolve\(false\); return; \}/.test(clientSource),
|
|
66
|
-
"activation must resolve false when the tab ring is absent (hero screen)"
|
|
59
|
+
assert.equal(
|
|
60
|
+
clientSource.includes('ctx.slots.inject("conversation.view"'),
|
|
61
|
+
false,
|
|
62
|
+
"the conversation.view tab must stay retired"
|
|
67
63
|
);
|
|
68
|
-
assert.
|
|
69
|
-
|
|
70
|
-
|
|
64
|
+
assert.equal(
|
|
65
|
+
clientSource.includes("activateExplorerTab"),
|
|
66
|
+
false,
|
|
67
|
+
"the tab-click activation machinery must be gone"
|
|
71
68
|
);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// The sheet is deliberately NOT fullscreen: a dimmed backdrop plus a rounded
|
|
72
|
+
// panel capped at 1180×880 keeps the conversation visible behind it. It
|
|
73
|
+
// reuses the full MemoryExplorer and closes on Esc, the close button, or a
|
|
74
|
+
// backdrop click.
|
|
75
|
+
test("memory library opens as a centered sheet with backdrop", () => {
|
|
75
76
|
assert.ok(
|
|
76
|
-
|
|
77
|
-
"
|
|
77
|
+
clientSource.includes(".mneme-backdrop{position:fixed;inset:0"),
|
|
78
|
+
"a dimmed backdrop must sit behind the sheet"
|
|
78
79
|
);
|
|
79
80
|
assert.ok(
|
|
80
|
-
|
|
81
|
-
"
|
|
81
|
+
/\.mneme-overlay\{position:fixed;z-index:1000;left:50%;top:50%;transform:translate\(-50%,-50%\)/.test(clientSource),
|
|
82
|
+
"the sheet must be centered, not viewport-filling"
|
|
82
83
|
);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
// The fallback overlay reuses the full MemoryExplorer (three columns, graph,
|
|
86
|
-
// settings) at viewport size — not the old side drawer — and closes on Esc
|
|
87
|
-
// or the close button.
|
|
88
|
-
test("hero fallback overlay is a full-viewport MemoryExplorer with a close affordance", () => {
|
|
89
84
|
assert.ok(
|
|
90
|
-
clientSource.includes(
|
|
91
|
-
"the
|
|
85
|
+
clientSource.includes("width:min(1240px,calc(100vw - 88px))"),
|
|
86
|
+
"the sheet must not occupy the full width and keeps friendly margins"
|
|
92
87
|
);
|
|
93
88
|
assert.ok(
|
|
94
89
|
clientSource.includes('h(MemoryExplorer, { t })'),
|
|
95
|
-
"the
|
|
90
|
+
"the sheet renders the same MemoryExplorer component"
|
|
96
91
|
);
|
|
97
92
|
assert.ok(
|
|
98
93
|
clientSource.includes('e.key === "Escape"'),
|
|
99
|
-
"Esc must close the
|
|
94
|
+
"Esc must close the sheet"
|
|
95
|
+
);
|
|
96
|
+
assert.ok(
|
|
97
|
+
clientSource.includes('className: "mneme-backdrop"'),
|
|
98
|
+
"the backdrop is a clickable close surface"
|
|
100
99
|
);
|
|
101
100
|
assert.ok(
|
|
102
101
|
clientSource.includes('"memory.overlay.close"'),
|
|
103
|
-
"the
|
|
102
|
+
"the close affordance keeps its localized label"
|
|
104
103
|
);
|
|
105
104
|
});
|
|
106
105
|
|
|
107
|
-
// The sidebar hands each
|
|
108
|
-
//
|
|
106
|
+
// The sidebar hands each entry only its column state: a wide row (icon +
|
|
107
|
+
// label) when expanded, a bare rail icon when collapsed — for both the
|
|
108
|
+
// portalled top button and the footer fallback.
|
|
109
109
|
test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
110
110
|
assert.ok(
|
|
111
111
|
/wide \? "mneme-trigger" : "mneme-trigger mneme-rail"/.test(clientSource),
|
|
@@ -115,23 +115,48 @@ test("trigger renders a wide row or a rail icon from the wide flag", () => {
|
|
|
115
115
|
/wide && h\("span", \{ className: "mneme-trigger-label" \}/.test(clientSource),
|
|
116
116
|
"the label span must render only when wide"
|
|
117
117
|
);
|
|
118
|
+
assert.ok(
|
|
119
|
+
/size: wide \? 15 : 18/.test(clientSource),
|
|
120
|
+
"the portalled icon must follow the native rail sizing convention"
|
|
121
|
+
);
|
|
118
122
|
});
|
|
119
123
|
|
|
120
|
-
// The
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
+
// The entry lives ABOVE the workspaces region: the real button is portalled
|
|
125
|
+
// just before the host's regionArea container (below New Session, above the
|
|
126
|
+
// workspace list), while the sidebar.footer.action registration remains as
|
|
127
|
+
// the React anchor and the in-place fallback when the host markup changes.
|
|
128
|
+
test("sidebar entry portals above the workspaces region with footer fallback", () => {
|
|
124
129
|
assert.ok(
|
|
125
|
-
clientSource.includes('ctx.slots.inject("
|
|
126
|
-
"
|
|
130
|
+
clientSource.includes('ctx.slots.inject("sidebar.footer.action"'),
|
|
131
|
+
"the footer slot registration must stay as anchor + fallback"
|
|
132
|
+
);
|
|
133
|
+
assert.ok(
|
|
134
|
+
clientSource.includes("SidebarTopEntry"),
|
|
135
|
+
"the portalled top entry must exist"
|
|
136
|
+
);
|
|
137
|
+
assert.ok(
|
|
138
|
+
clientSource.includes(`'[class*="regionArea"]'`),
|
|
139
|
+
"the portal must anchor at the host's regionArea container"
|
|
127
140
|
);
|
|
128
141
|
assert.ok(
|
|
129
|
-
clientSource.includes(
|
|
130
|
-
"the
|
|
142
|
+
clientSource.includes("insertBefore(created, region)"),
|
|
143
|
+
"the entry must sit immediately above the workspaces region"
|
|
144
|
+
);
|
|
145
|
+
assert.ok(
|
|
146
|
+
clientSource.includes("if (!host) return fallback;"),
|
|
147
|
+
"the portal entry persists across collapse (no footer jump); footer fallback only covers portal failure"
|
|
148
|
+
);
|
|
149
|
+
assert.ok(
|
|
150
|
+
clientSource.includes('className: `${nativeCls} mneme-topentry-native`'.replace("${nativeCls} mneme-topentry-native", "${nativeCls} mneme-topentry-native")),
|
|
151
|
+
"the entry must reuse the host New-Session button class for native geometry alignment"
|
|
152
|
+
);
|
|
153
|
+
assert.ok(
|
|
154
|
+
/querySelector\('\[class\*="newSession"\]'\)/.test(clientSource),
|
|
155
|
+
"the native class must be read from the live New-Session button, not hardcoded"
|
|
131
156
|
);
|
|
132
157
|
assert.ok(
|
|
133
158
|
clientSource.includes('"memory.view.label"'),
|
|
134
|
-
"the
|
|
159
|
+
"the sheet aria-label must come from the memory.view.label dictionary key"
|
|
135
160
|
);
|
|
136
161
|
});
|
|
137
162
|
|
|
@@ -199,16 +224,21 @@ test("entity: search grammar offers a graph jump", () => {
|
|
|
199
224
|
);
|
|
200
225
|
});
|
|
201
226
|
|
|
202
|
-
// The explorer is a
|
|
203
|
-
//
|
|
227
|
+
// The explorer is a card-grid / timeline browse with a right-hand detail
|
|
228
|
+
// drawer: types with counts, a month→day time tree (timeline mode), and the
|
|
229
|
+
// drawer rendering the untruncated content.
|
|
204
230
|
test("explorer lays out types, timeline, and full-text detail", () => {
|
|
205
231
|
assert.ok(
|
|
206
232
|
clientSource.includes('className: "mneme-xmain"'),
|
|
207
233
|
"the three-column grid must be present"
|
|
208
234
|
);
|
|
209
235
|
assert.ok(
|
|
210
|
-
clientSource.includes('className: "mneme-
|
|
211
|
-
"the detail
|
|
236
|
+
clientSource.includes('className: "mneme-dcontent"'),
|
|
237
|
+
"the detail drawer must render the full content"
|
|
238
|
+
);
|
|
239
|
+
assert.ok(
|
|
240
|
+
clientSource.includes('className: "mneme-cards"'),
|
|
241
|
+
"the card-grid view must exist alongside the timeline"
|
|
212
242
|
);
|
|
213
243
|
assert.ok(
|
|
214
244
|
/toLocaleDateString\(undefined, \{ year: "numeric", month: "long" \}\)/.test(clientSource),
|
package/test/dream.test.js
CHANGED
|
@@ -870,7 +870,7 @@ test("Bug8: runDream records llm_audit_logs rows for consolidation and summary",
|
|
|
870
870
|
assert.deepEqual(summarize.related_memory_ids, [], "summary audit has no related ids");
|
|
871
871
|
for (const row of rows) {
|
|
872
872
|
assert.equal(row.status, "success");
|
|
873
|
-
assert.equal(row.model_id, "
|
|
873
|
+
assert.equal(row.model_id, "deepseek:deepseek-chat", "config-first route (Issue #25): dreamProvider/dreamModel wins");
|
|
874
874
|
assert.ok(Number.isInteger(row.duration_ms) && row.duration_ms >= 0, "duration recorded");
|
|
875
875
|
assert.equal(row.input_tokens, 0);
|
|
876
876
|
assert.equal(row.output_tokens, 0);
|
|
@@ -5,6 +5,21 @@
|
|
|
5
5
|
import { createStore } from "../../src/store.js";
|
|
6
6
|
|
|
7
7
|
const [, , dbPath, iterations] = process.argv;
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
|
|
9
|
+
// Windows CI 上 8 进程并发写同一 generation 行时,busy_timeout(5s) 到期仍偶发
|
|
10
|
+
// SQLITE_BUSY("database is locked")。锁是瞬时资源竞争,线性重试即可收敛;
|
|
11
|
+
// incrementGeneration 是单条原子 UPDATE(失败即未执行),重试不会重复计数。
|
|
12
|
+
async function withBusyRetry(fn) {
|
|
13
|
+
for (let attempt = 0; ; attempt++) {
|
|
14
|
+
try {
|
|
15
|
+
return fn();
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (!/database is locked|SQLITE_BUSY/i.test(String(err?.message ?? err)) || attempt >= 40) throw err;
|
|
18
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const store = await withBusyRetry(() => createStore(dbPath));
|
|
24
|
+
for (let i = 0; i < Number(iterations); i++) await withBusyRetry(() => store.incrementGeneration());
|
|
10
25
|
store.close();
|
package/test/llm-audit.test.js
CHANGED
|
@@ -50,10 +50,11 @@ test("autoDream writes llm_audit_logs rows for consolidation and summary", async
|
|
|
50
50
|
assert.equal(summarize.trigger_source, "autoDream");
|
|
51
51
|
assert.equal(consolidate.status, "success");
|
|
52
52
|
assert.equal(summarize.status, "success");
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
assert.equal(
|
|
53
|
+
// config-first (Issue #25): dreamSetup sets dreamProvider/dreamModel, so it
|
|
54
|
+
// wins over mockCtx's agentDefaultModel (mock:stress-model) — assert the
|
|
55
|
+
// actually-used config route.
|
|
56
|
+
assert.equal(consolidate.model_id, "deepseek:deepseek-chat");
|
|
57
|
+
assert.equal(summarize.model_id, "deepseek:deepseek-chat");
|
|
57
58
|
assert.ok(Array.isArray(consolidate.related_memory_ids) && consolidate.related_memory_ids.length === 2,
|
|
58
59
|
"consolidation audit records the snapshot ids");
|
|
59
60
|
assert.ok(consolidate.total_tokens >= 0 && summarize.total_tokens >= 0);
|
|
@@ -110,6 +111,35 @@ test("autoDream throwing LLM is audited as status=error", async () => {
|
|
|
110
111
|
store.close();
|
|
111
112
|
});
|
|
112
113
|
|
|
114
|
+
test("autoDream parse failure is audited as status=error, not fake success", async () => {
|
|
115
|
+
const { store, service, config } = dreamSetup();
|
|
116
|
+
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
117
|
+
// Stream completes fine but returns no JSON array — the run fails, and the
|
|
118
|
+
// audit must NOT claim success (the old bug: llm_audit said dream_consolidate
|
|
119
|
+
// success while the run recorded failed).
|
|
120
|
+
const warnings = [];
|
|
121
|
+
const ctx = {
|
|
122
|
+
logger: { warn: (m) => warnings.push(m) },
|
|
123
|
+
llm: {
|
|
124
|
+
async *stream() {
|
|
125
|
+
yield { type: "text-delta", index: 0, text: "抱歉,我无法解析成 JSON。" };
|
|
126
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const dream = createDreamScheduler({ thresholdCount: 1, thresholdChars: 0, delayMs: 0 });
|
|
131
|
+
const result = await dream.runDream(ctx, service, config);
|
|
132
|
+
assert.equal(result.ok, false);
|
|
133
|
+
assert.equal(result.error, "no json array in llm output");
|
|
134
|
+
const rows = service.listLlmAudits();
|
|
135
|
+
assert.equal(rows.length, 1, "failed consolidation still audited");
|
|
136
|
+
assert.equal(rows[0].operation_type, "dream_consolidate");
|
|
137
|
+
assert.equal(rows[0].status, "error", "stream succeeded but output unusable → audit error");
|
|
138
|
+
assert.match(rows[0].error_message, /no json array in llm output/);
|
|
139
|
+
assert.ok(warnings.some((m) => m.includes("head: 抱歉,我无法解析成")), "raw output head logged for diagnosis");
|
|
140
|
+
store.close();
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
test("autoDream audit is skipped when llmAudit.enabled === false", async () => {
|
|
114
144
|
const { store, service, config } = dreamSetup({ llmAudit: { enabled: false } });
|
|
115
145
|
service.saveWithDedupe({ type: "project", title: "主题", content: "内容" });
|
|
@@ -115,8 +115,14 @@ test("peer-C: 多进程并发原子递增——8 进程×10 次 incrementGenerat
|
|
|
115
115
|
}
|
|
116
116
|
} finally {
|
|
117
117
|
// Windows:8 个 SQLite 子进程退出后 -shm/-wal 句柄延迟释放,删目录偶发 EPERM;
|
|
118
|
-
//
|
|
119
|
-
|
|
118
|
+
// rm 带 maxRetries 自动重试(30×100ms 共 3s),仍失败则放行——清理只是收尾,
|
|
119
|
+
// 并发断言正确性由上方 generation 断言保证,残留临时目录交给 OS/CI 回收,
|
|
120
|
+
// 绝不让句柄释放慢把 flaky 标红。
|
|
121
|
+
try {
|
|
122
|
+
rmSync(dir, { recursive: true, force: true, maxRetries: 30, retryDelay: 100 });
|
|
123
|
+
} catch (err) {
|
|
124
|
+
if (!["EPERM", "EBUSY", "EACCES"].includes(err.code)) throw err;
|
|
125
|
+
}
|
|
120
126
|
}
|
|
121
127
|
});
|
|
122
128
|
|
|
@@ -107,6 +107,70 @@ test("issue#9: dream forwards dreamReasoningEffort on both LLM calls", async ()
|
|
|
107
107
|
store.close();
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
+
test("issue#25: dreamProvider/dreamModel config wins over the agentDefaultModel route", async () => {
|
|
111
|
+
const store = createStore(":memory:");
|
|
112
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
113
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
114
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
115
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
116
|
+
const captured = [];
|
|
117
|
+
const ctx = dreamCtx({
|
|
118
|
+
captured,
|
|
119
|
+
onConsolidation: () => JSON.stringify([
|
|
120
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "插件总览", content: "合并内容", importance: 4 }
|
|
121
|
+
])
|
|
122
|
+
});
|
|
123
|
+
// dreamCtx's agentDefaultModel resolves (mock:mock-model), but the explicit
|
|
124
|
+
// config route must win — otherwise dreamProvider/dreamModel is dead code in
|
|
125
|
+
// a standard DSH install and the dream can never be moved off a thinking model.
|
|
126
|
+
const result = await dream.runDream(ctx, service, { dreamProvider: "volcano", dreamModel: "deepseek-v3" });
|
|
127
|
+
assert.equal(result.ok, true);
|
|
128
|
+
assert.ok(captured.length >= 2, "consolidation + summary both hit the LLM");
|
|
129
|
+
for (const options of captured) {
|
|
130
|
+
assert.equal(options.provider, "volcano");
|
|
131
|
+
assert.equal(options.model, "deepseek-v3", "config route wins over agentDefaultModel (mock:mock-model)");
|
|
132
|
+
}
|
|
133
|
+
store.close();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("issue#9: rejected reasoningEffort retries once without it and still consolidates", async () => {
|
|
137
|
+
const store = createStore(":memory:");
|
|
138
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
139
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true, skipped: true }) });
|
|
140
|
+
const { memory: a } = service.saveWithDedupe({ type: "project", title: "插件", content: "旧", importance: 3 });
|
|
141
|
+
const { memory: b } = service.saveWithDedupe({ type: "project", title: "插件2", content: "新细节", importance: 4 });
|
|
142
|
+
const calls = [];
|
|
143
|
+
const ctx = {
|
|
144
|
+
logger: { warn: () => {} },
|
|
145
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
146
|
+
llm: {
|
|
147
|
+
async *stream(options) {
|
|
148
|
+
calls.push(options);
|
|
149
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
150
|
+
if (userText.startsWith("id=")) {
|
|
151
|
+
// First attempt forwards reasoningEffort: the provider rejects it.
|
|
152
|
+
if (options.reasoningEffort) {
|
|
153
|
+
throw new Error("UNSUPPORTED_REASONING_EFFORT: DeepSeek does not support reasoning effort \"low\"");
|
|
154
|
+
}
|
|
155
|
+
yield { type: "text-delta", index: 0, text: JSON.stringify([
|
|
156
|
+
{ action: "merge", ids: [a.id, b.id], keepSource: b.id, title: "合并标题", content: "合并内容", importance: 4 }
|
|
157
|
+
]) };
|
|
158
|
+
} else {
|
|
159
|
+
yield { type: "text-delta", index: 0, text: "记忆库总览:用户偏好中文。" };
|
|
160
|
+
}
|
|
161
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const result = await dream.runDream(ctx, service, { dreamReasoningEffort: "low" });
|
|
166
|
+
assert.equal(result.ok, true, "run survives the effort rejection via the fallback retry");
|
|
167
|
+
assert.ok(result.applied > 0, "consolidation still lands changes");
|
|
168
|
+
assert.equal(calls.length, 3, "consolidation tried (rejected) + retried without effort + summary");
|
|
169
|
+
assert.equal(calls[0].reasoningEffort, "low", "first consolidation attempt forwards the effort");
|
|
170
|
+
assert.equal("reasoningEffort" in calls[1], false, "retry omits the rejected effort field");
|
|
171
|
+
store.close();
|
|
172
|
+
});
|
|
173
|
+
|
|
110
174
|
// ---------------------------------------------------------------- sleep passthrough
|
|
111
175
|
|
|
112
176
|
function sleepSetup() {
|
package/test/settings.test.js
CHANGED
|
@@ -123,3 +123,139 @@ test("external api settings persist token and merge partial writes", () => {
|
|
|
123
123
|
assert.deepEqual(settings.getExternalApi(), { enabled: true, port: 9000, host: "0.0.0.0", token: "tok-2" });
|
|
124
124
|
store.close();
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
// --- feature flags ---
|
|
128
|
+
|
|
129
|
+
test("feature flags default to an empty object", () => {
|
|
130
|
+
const { store, settings } = setup();
|
|
131
|
+
assert.deepEqual(settings.getFeatureFlags(), {});
|
|
132
|
+
store.close();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("setFeatureFlags rejects unknown keys and persists nothing", () => {
|
|
136
|
+
const { store, settings } = setup();
|
|
137
|
+
settings.setFeatureFlags({ autoDream: false });
|
|
138
|
+
assert.throws(() => settings.setFeatureFlags({ noSuchFlag: true }), TypeError);
|
|
139
|
+
assert.throws(() => settings.setFeatureFlags({ noSuchFlag: true }), /noSuchFlag/);
|
|
140
|
+
// 被拒的 patch 不落库,已存的合法值原样保留
|
|
141
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoDream: false });
|
|
142
|
+
store.close();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("setFeatureFlags enforces boolean types and integer ranges", () => {
|
|
146
|
+
const { store, settings } = setup();
|
|
147
|
+
assert.throws(() => settings.setFeatureFlags({ autoInject: "yes" }), /autoInject/);
|
|
148
|
+
assert.throws(() => settings.setFeatureFlags({ distillMaxChars: 500 }), /distillMaxChars/);
|
|
149
|
+
assert.throws(() => settings.setFeatureFlags({ distillRateLimitRetries: 1.5 }), /distillRateLimitRetries/);
|
|
150
|
+
assert.throws(() => settings.setFeatureFlags({ codingBoostFactor: 9 }), /codingBoostFactor/);
|
|
151
|
+
assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
|
|
152
|
+
store.close();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("setFeatureFlags merges partial patches and persists across instances", () => {
|
|
156
|
+
const { store, settings } = setup();
|
|
157
|
+
settings.setFeatureFlags({ autoDream: false, distillMaxChars: 48000 });
|
|
158
|
+
settings.setFeatureFlags({ rerankEnabled: true });
|
|
159
|
+
// 同一 kv 上的新实例读回合并结果,证明已真正持久化
|
|
160
|
+
const settings2 = createSettings(store.db);
|
|
161
|
+
assert.deepEqual(settings2.getFeatureFlags(), { autoDream: false, distillMaxChars: 48000, rerankEnabled: true });
|
|
162
|
+
store.close();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("getFeatureFlags drops unknown and corrupted keys from the kv", () => {
|
|
166
|
+
const { store, settings } = setup();
|
|
167
|
+
settings.setFeatureFlags({ autoInject: false });
|
|
168
|
+
// 模拟旧版本/手改残留:绕过校验直接写脏 kv
|
|
169
|
+
store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run(
|
|
170
|
+
JSON.stringify({ autoInject: false, legacyFlag: true, autoDream: "yes", distillMaxChars: 999999 }),
|
|
171
|
+
"feature_flags"
|
|
172
|
+
);
|
|
173
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoInject: false });
|
|
174
|
+
// 整个 kv 损坏(非 JSON)→ 读回空对象而不是抛错
|
|
175
|
+
store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run("not-json", "feature_flags");
|
|
176
|
+
assert.deepEqual(settings.getFeatureFlags(), {});
|
|
177
|
+
store.close();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("feature flags accept the new boolean, string, url and enum keys", () => {
|
|
181
|
+
const { store, settings } = setup();
|
|
182
|
+
settings.setFeatureFlags({
|
|
183
|
+
"memoryQualityFilter.enabled": false,
|
|
184
|
+
"llmAudit.enabled": false,
|
|
185
|
+
bm25SearchEnabled: false,
|
|
186
|
+
conflictFreezeEnabled: true,
|
|
187
|
+
trustEpistemicWeighting: true,
|
|
188
|
+
reflectionFailureTracking: false,
|
|
189
|
+
dreamProvider: " siliconflow ",
|
|
190
|
+
dreamModel: "deepseek-chat",
|
|
191
|
+
localEmbedModel: "Xenova/bge-small-zh-v1.5",
|
|
192
|
+
ollamaModel: "nomic-embed-text",
|
|
193
|
+
ollamaBaseUrl: "http://127.0.0.1:11434/",
|
|
194
|
+
embedProvider: "ollama"
|
|
195
|
+
});
|
|
196
|
+
const flags = settings.getFeatureFlags();
|
|
197
|
+
assert.equal(flags["memoryQualityFilter.enabled"], false, "nested keys stored flat");
|
|
198
|
+
assert.equal(flags["llmAudit.enabled"], false);
|
|
199
|
+
assert.equal(flags.bm25SearchEnabled, false);
|
|
200
|
+
assert.equal(flags.conflictFreezeEnabled, true);
|
|
201
|
+
assert.equal(flags.trustEpistemicWeighting, true);
|
|
202
|
+
assert.equal(flags.reflectionFailureTracking, false);
|
|
203
|
+
assert.equal(flags.dreamProvider, "siliconflow", "string values are trimmed");
|
|
204
|
+
assert.equal(flags.dreamModel, "deepseek-chat");
|
|
205
|
+
assert.equal(flags.ollamaBaseUrl, "http://127.0.0.1:11434/");
|
|
206
|
+
assert.equal(flags.embedProvider, "ollama");
|
|
207
|
+
// 同一 kv 上的新实例读回合并结果:点号键按平铺键持久化
|
|
208
|
+
const settings2 = createSettings(store.db);
|
|
209
|
+
assert.equal(settings2.getFeatureFlags()["memoryQualityFilter.enabled"], false);
|
|
210
|
+
assert.equal(settings2.getFeatureFlags().embedProvider, "ollama");
|
|
211
|
+
store.close();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("nested feature flag keys are validated as booleans and rejected otherwise", () => {
|
|
215
|
+
const { store, settings } = setup();
|
|
216
|
+
assert.throws(() => settings.setFeatureFlags({ "memoryQualityFilter.enabled": "yes" }), /memoryQualityFilter\.enabled/);
|
|
217
|
+
assert.throws(() => settings.setFeatureFlags({ "llmAudit.enabled": 1 }), /llmAudit\.enabled/);
|
|
218
|
+
assert.deepEqual(settings.getFeatureFlags(), {}, "failed writes persist nothing");
|
|
219
|
+
store.close();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("string feature flags: empty is legal, over-length and enum violations rejected", () => {
|
|
223
|
+
const { store, settings } = setup();
|
|
224
|
+
// 空字符串合法 = 跟随主对话模型(面板显示 placeholder)
|
|
225
|
+
settings.setFeatureFlags({ dreamProvider: "", dreamModel: " " });
|
|
226
|
+
assert.equal(settings.getFeatureFlags().dreamProvider, "");
|
|
227
|
+
assert.equal(settings.getFeatureFlags().dreamModel, "", "whitespace-only trims to empty");
|
|
228
|
+
assert.throws(() => settings.setFeatureFlags({ dreamModel: "x".repeat(201) }), /dreamModel/);
|
|
229
|
+
assert.throws(() => settings.setFeatureFlags({ localEmbedModel: 42 }), /localEmbedModel/);
|
|
230
|
+
assert.throws(() => settings.setFeatureFlags({ embedProvider: "bogus" }), /embedProvider/);
|
|
231
|
+
assert.throws(() => settings.setFeatureFlags({ embedProvider: "OpenAI" }), /embedProvider/, "enum is case-sensitive");
|
|
232
|
+
assert.deepEqual(settings.getFeatureFlags(), { dreamProvider: "", dreamModel: "" }, "failed writes persist nothing");
|
|
233
|
+
store.close();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("ollamaBaseUrl accepts empty and http(s) URLs, rejects other protocols", () => {
|
|
237
|
+
const { store, settings } = setup();
|
|
238
|
+
settings.setFeatureFlags({ ollamaBaseUrl: "" });
|
|
239
|
+
assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "", "empty = follow default");
|
|
240
|
+
settings.setFeatureFlags({ ollamaBaseUrl: " https://embed.example.com/v1 " });
|
|
241
|
+
assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "https://embed.example.com/v1");
|
|
242
|
+
assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "ftp://localhost:11434" }), /ollamaBaseUrl/);
|
|
243
|
+
assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "file:///etc/passwd" }), /ollamaBaseUrl/);
|
|
244
|
+
assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "javascript:alert(1)" }), /ollamaBaseUrl/);
|
|
245
|
+
assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "localhost:11434" }), /ollamaBaseUrl/, "protocol is required");
|
|
246
|
+
assert.throws(() => settings.setFeatureFlags({ ollamaBaseUrl: "not a url" }), /ollamaBaseUrl/);
|
|
247
|
+
assert.equal(settings.getFeatureFlags().ollamaBaseUrl, "https://embed.example.com/v1", "rejected writes persist nothing");
|
|
248
|
+
store.close();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("getFeatureFlags drops corrupted string, url and enum keys from the kv", () => {
|
|
252
|
+
const { store, settings } = setup();
|
|
253
|
+
settings.setFeatureFlags({ dreamModel: "m", ollamaBaseUrl: "http://localhost:11434", embedProvider: "local" });
|
|
254
|
+
// 模拟旧版本/手改残留:字符串键混入非字符串、URL 键混入非法协议、枚举越界
|
|
255
|
+
store.db.prepare("UPDATE user_settings SET value = ? WHERE key = ?").run(
|
|
256
|
+
JSON.stringify({ dreamModel: 42, ollamaBaseUrl: "ftp://bad", embedProvider: "bogus", autoInject: false }),
|
|
257
|
+
"feature_flags"
|
|
258
|
+
);
|
|
259
|
+
assert.deepEqual(settings.getFeatureFlags(), { autoInject: false });
|
|
260
|
+
store.close();
|
|
261
|
+
});
|