dsh-memu-memory 1.1.2 → 1.2.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.
Files changed (199) hide show
  1. package/AGENTS.md +8 -6
  2. package/README.md +50 -5
  3. package/README.zh.md +49 -5
  4. package/docs/active-memory-ab.md +385 -0
  5. package/docs/binding-lifecycle.md +99 -0
  6. package/docs/contributing.md +6 -4
  7. package/docs/emotion-model-spec.md +281 -0
  8. package/docs/env-and-commands.md +13 -0
  9. package/docs/host-api.md +35 -3
  10. package/docs/memory-semantics.md +40 -11
  11. package/docs/recall-benchmark.md +238 -0
  12. package/docs/troubleshooting.md +395 -366
  13. package/lib/active-memory.js +165 -0
  14. package/lib/activity-log.js +190 -0
  15. package/lib/backup.js +1103 -0
  16. package/lib/binding-diagnostics.js +635 -0
  17. package/lib/client.js +728 -58
  18. package/lib/consolidation-ledger.js +347 -0
  19. package/lib/consolidation-receipt.js +284 -0
  20. package/lib/consolidation-snapshot.js +274 -0
  21. package/lib/consolidation-worker.js +115 -0
  22. package/lib/curator.js +81 -37
  23. package/lib/cycle.js +43 -6
  24. package/lib/embedding-catalog.js +763 -0
  25. package/lib/embedding-download.js +407 -0
  26. package/lib/embedding-runtime.js +710 -0
  27. package/lib/engine.js +2 -2
  28. package/lib/index.js +36 -0
  29. package/lib/inject.js +232 -103
  30. package/lib/local-embedding-entry.js +145 -0
  31. package/lib/local-embedding-manager.js +267 -0
  32. package/lib/local-embedding-protocol.js +173 -0
  33. package/lib/local-embedding-worker.js +652 -0
  34. package/lib/local-vector-service.js +283 -0
  35. package/lib/memory.js +599 -57
  36. package/lib/memu-config.js +10 -22
  37. package/lib/memu.js +53 -19
  38. package/lib/mood.js +268 -39
  39. package/lib/paths.js +24 -0
  40. package/lib/preset-scope.js +1 -1
  41. package/lib/private.js +450 -29
  42. package/lib/prompts/private-consolidation.coding.md +1 -0
  43. package/lib/prompts/private-consolidation.md +1 -0
  44. package/lib/prompts/private-consolidation.mixed.md +1 -0
  45. package/lib/prompts/private-consolidation.rp.md +1 -0
  46. package/lib/prompts.js +4 -4
  47. package/lib/remote-summary.js +51 -25
  48. package/lib/rpc.js +54 -5
  49. package/lib/state.js +137 -172
  50. package/lib/tools.js +58 -7
  51. package/lib/vector-migration.js +878 -0
  52. package/lib/vector-profile.js +968 -0
  53. package/package.json +4 -2
  54. package/scripts/acceptance-local.mjs +7 -5
  55. package/scripts/active-memory-ab-live.mjs +655 -0
  56. package/scripts/active-memory-ab.mjs +1302 -0
  57. package/scripts/chain-matrix.mjs +537 -0
  58. package/scripts/check-all.mjs +29 -1
  59. package/scripts/check-doc-drift.mjs +353 -0
  60. package/scripts/diagnose-bindings.mjs +176 -0
  61. package/scripts/local-runtime-install.mjs +142 -0
  62. package/scripts/local-runtime-verify.mjs +56 -0
  63. package/scripts/local-vector-verify.mjs +93 -0
  64. package/scripts/recall-bench.mjs +813 -0
  65. package/scripts/repetition-ab-scenarios.mjs +93 -0
  66. package/scripts/repetition-detector-snapshot.mjs +314 -0
  67. package/scripts/repetition-eval-live.mjs +360 -0
  68. package/scripts/repetition-eval.mjs +702 -0
  69. package/tests/activation.test.mjs +89 -0
  70. package/tests/active-memory-ab.test.mjs +745 -0
  71. package/tests/active-memory.test.mjs +129 -0
  72. package/tests/activity-log.test.mjs +126 -0
  73. package/tests/activity-wiring.test.mjs +103 -0
  74. package/tests/appendix-b.test.mjs +212 -0
  75. package/tests/appendix-b2.test.mjs +166 -0
  76. package/tests/audit-regression.test.mjs +272 -0
  77. package/tests/audit-safety.test.mjs +227 -0
  78. package/tests/backend-compensation.test.mjs +272 -0
  79. package/tests/backup.test.mjs +793 -0
  80. package/tests/binding-diagnostics.test.mjs +720 -0
  81. package/tests/browser.test.mjs +366 -0
  82. package/tests/client-render.test.mjs +892 -0
  83. package/tests/coalesce.test.mjs +113 -0
  84. package/tests/config.test.mjs +159 -0
  85. package/tests/consolidation-credential.test.mjs +320 -0
  86. package/tests/consolidation-fork.test.mjs +357 -0
  87. package/tests/consolidation-ledger.test.mjs +298 -0
  88. package/tests/consolidation-snapshot.test.mjs +321 -0
  89. package/tests/consolidation-value.test.mjs +89 -0
  90. package/tests/context-scope.test.mjs +40 -0
  91. package/tests/cue.test.mjs +44 -0
  92. package/tests/curator-probe.test.mjs +433 -0
  93. package/tests/curator.test.mjs +150 -0
  94. package/tests/cycle.test.mjs +411 -0
  95. package/tests/disclosure.test.mjs +231 -0
  96. package/tests/doc-drift.test.mjs +222 -0
  97. package/tests/embedding-catalog.test.mjs +266 -0
  98. package/tests/embedding-download.test.mjs +391 -0
  99. package/tests/embedding-runtime.test.mjs +389 -0
  100. package/tests/emotion-chain.test.mjs +84 -0
  101. package/tests/emotion-contract.test.mjs +445 -0
  102. package/tests/engine.test.mjs +35 -0
  103. package/tests/fixtures/active-memory-ab/README.md +56 -0
  104. package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
  105. package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
  106. package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
  107. package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
  108. package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
  109. package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
  110. package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
  111. package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
  112. package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
  113. package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
  114. package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
  115. package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
  116. package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
  117. package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
  118. package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
  119. package/tests/fixtures/active-memory-ab/manifest.json +199 -0
  120. package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
  121. package/tests/fixtures/recall-bench/02-exact.json +153 -0
  122. package/tests/fixtures/recall-bench/03-negation.json +173 -0
  123. package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
  124. package/tests/fixtures/recall-bench/05-conflict.json +194 -0
  125. package/tests/fixtures/recall-bench/06-boundary.json +273 -0
  126. package/tests/fixtures/recall-bench/07-secret.json +191 -0
  127. package/tests/fixtures/recall-bench/08-coding.json +164 -0
  128. package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
  129. package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
  130. package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
  131. package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
  132. package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
  133. package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
  134. package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
  135. package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
  136. package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
  137. package/tests/fixtures/repetition-eval/02-en.json +97 -0
  138. package/tests/fixtures/repetition-eval/03-code.json +91 -0
  139. package/tests/fixtures/repetition-eval/04-names.json +116 -0
  140. package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
  141. package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
  142. package/tests/graph-view.test.mjs +187 -0
  143. package/tests/graph.test.mjs +77 -0
  144. package/tests/hook-order.test.mjs +72 -0
  145. package/tests/humanlike.test.mjs +232 -0
  146. package/tests/hybrid-recall.test.mjs +92 -0
  147. package/tests/inbound.test.mjs +393 -0
  148. package/tests/inject-guide.test.mjs +364 -0
  149. package/tests/inject.test.mjs +222 -0
  150. package/tests/injection-budget-settings.test.mjs +35 -0
  151. package/tests/injection-budget.test.mjs +64 -0
  152. package/tests/injection-chain.test.mjs +191 -0
  153. package/tests/loader-register.mjs +3 -0
  154. package/tests/loader.mjs +12 -0
  155. package/tests/local-embedding-entry.test.mjs +95 -0
  156. package/tests/local-embedding-manager.test.mjs +476 -0
  157. package/tests/local-embedding-protocol.test.mjs +95 -0
  158. package/tests/local-embedding-worker.test.mjs +346 -0
  159. package/tests/managed-memory.test.mjs +536 -0
  160. package/tests/managed-runtime.test.mjs +40 -0
  161. package/tests/media.test.mjs +132 -0
  162. package/tests/migration-payload.test.mjs +42 -0
  163. package/tests/mood.test.mjs +109 -0
  164. package/tests/multiprocess.test.mjs +29 -0
  165. package/tests/new-session-gate.test.mjs +361 -0
  166. package/tests/package-shipping.test.mjs +84 -0
  167. package/tests/preset-scope.test.mjs +212 -0
  168. package/tests/prompts.test.mjs +250 -0
  169. package/tests/react-snapshot.test.mjs +23 -0
  170. package/tests/recall-bench.test.mjs +306 -0
  171. package/tests/remote.test.mjs +140 -0
  172. package/tests/rename.test.mjs +30 -0
  173. package/tests/repetition-detector-snapshot.test.mjs +231 -0
  174. package/tests/repetition-eval-live.test.mjs +177 -0
  175. package/tests/repetition-eval.test.mjs +373 -0
  176. package/tests/salient-date.test.mjs +142 -0
  177. package/tests/settings-beat.test.mjs +221 -0
  178. package/tests/short-term.test.mjs +166 -0
  179. package/tests/sidebar-beat.test.mjs +208 -0
  180. package/tests/sidebar-refresh.test.mjs +123 -0
  181. package/tests/sidebar.test.mjs +70 -0
  182. package/tests/snippet.test.mjs +47 -0
  183. package/tests/source-kind.test.mjs +52 -0
  184. package/tests/state.test.mjs +150 -0
  185. package/tests/stats.test.mjs +112 -0
  186. package/tests/storage.test.mjs +125 -0
  187. package/tests/stubs/dsh-llm.js +5 -0
  188. package/tests/stubs/dsh-session.js +7 -0
  189. package/tests/stubs/dsh-tools.js +101 -0
  190. package/tests/tool-schema.test.mjs +73 -0
  191. package/tests/tools.test.mjs +184 -0
  192. package/tests/ui-defaults.test.mjs +57 -0
  193. package/tests/update-fields.test.mjs +138 -0
  194. package/tests/vector-migration.test.mjs +733 -0
  195. package/tests/vector-profile.test.mjs +425 -0
  196. package/tests/visibility-migration.test.mjs +152 -0
  197. package/tests/webui-auth.test.mjs +454 -0
  198. package/tests/webui.test.mjs +164 -0
  199. package/tests/windows-env.test.mjs +89 -0
@@ -0,0 +1,892 @@
1
+ // A settings pane that throws during render shows up as a blank panel with no
2
+ // error anywhere — exactly what happened when an effect referenced a callback
3
+ // declared further down (temporal dead zone). This harness runs the real client
4
+ // component with a minimal hook runtime, so a render-time throw fails the build
5
+ // instead of silently blanking the UI. Every page is rendered, because a block
6
+ // only runs when its page is selected.
7
+ import test from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ const registrations = []
11
+
12
+ function makeElement(type, props, ...children) {
13
+ return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
14
+ }
15
+
16
+ let hooks = []
17
+ let cursor = 0
18
+ let effects = []
19
+ let dirty = false
20
+ let forced = null
21
+ let generation = 0
22
+
23
+ const React = {
24
+ createElement: makeElement,
25
+ Fragment: Symbol('Fragment'),
26
+ useState(initial) {
27
+ const index = cursor++
28
+ // A setter captured by a component from an earlier test must not write into
29
+ // the hook array this test is using: async effects from a previous render can
30
+ // resolve late, and without this the second test renders with the first one's
31
+ // state.
32
+ const generation_ = generation
33
+ if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
34
+ if (forced && forced.index === index) hooks[index] = forced.value
35
+ return [hooks[index], (next) => {
36
+ if (generation_ !== generation) return
37
+ hooks[index] = typeof next === 'function' ? next(hooks[index]) : next
38
+ dirty = true
39
+ }]
40
+ },
41
+ useCallback: (fn) => fn,
42
+ useMemo: (fn) => fn(),
43
+ useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
44
+ useLayoutEffect() {},
45
+ useRef: (value) => ({ current: value }),
46
+ useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
47
+ }
48
+
49
+ globalThis.window = {
50
+ __ModuleLoader__: {
51
+ load({ factory }) {
52
+ globalThis.__dshMemuExports = factory((name) => {
53
+ if (name === 'react') return React
54
+ throw new Error(`unexpected require: ${name}`)
55
+ })
56
+ },
57
+ },
58
+ // The overlay listens for its close message and for Escape.
59
+ addEventListener() {},
60
+ removeEventListener() {},
61
+ }
62
+ // The WebUI button falls back to an in-app overlay when popups are blocked; the
63
+ // stub records what got attached so the test can assert the current tab is never
64
+ // navigated (that would unload the app and look like a forced refresh).
65
+ const appendedNodes = []
66
+ globalThis.document = {
67
+ createElement: (tag) => {
68
+ const node = {
69
+ tag,
70
+ style: {},
71
+ attrs: {},
72
+ children: [],
73
+ href: '',
74
+ textContent: '',
75
+ setAttribute(name, value) { node.attrs[name] = value },
76
+ addEventListener() {},
77
+ appendChild(child) { node.children.push(child); return child },
78
+ remove() { node.removed = true },
79
+ click() { node.clicked = true },
80
+ }
81
+ return node
82
+ },
83
+ querySelector: () => null,
84
+ addEventListener() {},
85
+ removeEventListener() {},
86
+ head: { appendChild() {} },
87
+ body: { appendChild(node) { appendedNodes.push(node); return node } },
88
+ }
89
+
90
+ await import('../lib/client.js')
91
+
92
+ const EMPTY_STATE = {
93
+ engine: { status: 'ready', versions: [], currentVersion: '0.11.0b3', recommendedVersion: '0.11.0b3', installed: true, state: {} },
94
+ settings: { autoInject: 'smart', mustRememberCap: 16, unpinBudgetPerSession: 2 },
95
+ libraries: [],
96
+ archivedLibraries: [],
97
+ memuConfig: { provider: 'openai' },
98
+ summaryConfig: {},
99
+ binding: null,
100
+ privateCount: 0,
101
+ pinnedCount: 0,
102
+ globalCount: 0,
103
+ jobs: [],
104
+ }
105
+
106
+ const RPC_ANSWERS = {
107
+ getState: EMPTY_STATE,
108
+ getSettingsState: EMPTY_STATE,
109
+ getPrompts: { prompts: [
110
+ { key: 'system-guide', title: 'guide', description: '', variables: ['mustRememberCap'], source: 'default', text: 'a', scope: 'private', domain: null },
111
+ { key: 'system-guide.rp', title: 'guide rp', description: '', variables: [], source: 'default', text: 'b', scope: 'private', domain: 'rp' },
112
+ { key: 'global-curator', title: 'curator', description: '', variables: ['input'], source: 'default', text: 'c', scope: 'global', domain: null },
113
+ ] },
114
+ listVersions: { ok: true, versions: [] },
115
+ listBackups: { backups: [] },
116
+ listFolders: { folders: [] },
117
+ listLibraries: { libraries: [] },
118
+ listSessions: { sessions: [] },
119
+ getSidebarState: {
120
+ binding: { sessionId: 'probe-session', libraryId: 'lib-1', useLibrary: true, useGlobal: true, scopeKey: '/proj/demo', domain: 'coding' },
121
+ domain: 'coding',
122
+ folder: { scopeKey: '/proj/demo', useLibrary: true, useGlobal: true },
123
+ effective: { useLibrary: true, useGlobal: true, enabled: true, folder: { scopeKey: '/proj/demo', useLibrary: true, useGlobal: true } },
124
+ private: { items: [], active: 0, pinned: 0, total: 0 },
125
+ global: { items: [], active: 0, total: 0 },
126
+ domains: ['rp', 'coding'],
127
+ },
128
+ jobStatus: null,
129
+ }
130
+
131
+ function fakeCtx({ answers = {}, rpcCalls = [] } = {}) {
132
+ return {
133
+ effect: (fn) => { try { fn() } catch {} },
134
+ locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
135
+ connection: {
136
+ rpc: {
137
+ call: async (path, endpoint, payload) => {
138
+ rpcCalls.push({ path, endpoint, payload })
139
+ if (typeof answers[endpoint] === 'function') return answers[endpoint](payload)
140
+ return { ok: true, value: answers[endpoint] ?? RPC_ANSWERS[endpoint] ?? {} }
141
+ },
142
+ },
143
+ },
144
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
145
+ // The sidebar tab asks for the current conversation through one of these.
146
+ get: (name) => (name === 'uiSession'
147
+ ? { adapter: { current: { getSnapshot: () => ({ key: 'probe-session' }) } } }
148
+ : undefined),
149
+ slots: {
150
+ inject: (_name, callback) => callback(scope),
151
+ register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
152
+ },
153
+ inject: (_deps, callback) => callback({
154
+ // Run the effect body: that is where the tab body registers itself.
155
+ effect: (fn) => { try { fn() } catch {} },
156
+ sidebarRightTabs: { register: () => () => {} },
157
+ get: () => undefined,
158
+ }),
159
+ }
160
+ }
161
+
162
+ const scope = {
163
+ effect: (fn) => { try { fn() } catch {} },
164
+ register: (spec, component) => { registrations.push({ slot: spec.name, id: spec.id, component }); return () => {} },
165
+ inject: (_deps, callback) => callback(scope),
166
+ }
167
+
168
+ async function renderPage(component, props, pageValue, { remount = false } = {}) {
169
+ if (remount) { hooks = []; generation++ }
170
+ effects = []
171
+ cursor = 0
172
+ forced = pageValue === undefined ? null : { index: pageHookIndex, value: pageValue }
173
+ const tree = component(props)
174
+ assert.ok(tree, 'component rendered nothing')
175
+ return { tree, effects }
176
+ }
177
+
178
+ let pageHookIndex = -1
179
+
180
+ function collectPageHook() {
181
+ // The page state is the only one initialised to the number 0.
182
+ const zeroes = hooks.map((value, index) => (value === 0 ? index : -1)).filter((index) => index >= 0)
183
+ assert.equal(zeroes.length, 1, 'expected exactly one numeric-zero state (the page index)')
184
+ pageHookIndex = zeroes[0]
185
+ }
186
+
187
+ function treeText(node, depth = 0) {
188
+ if (node === null || node === undefined || depth > 400) return ''
189
+ if (typeof node === 'string' || typeof node === 'number') return String(node)
190
+ if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
191
+ // Function components are expanded so their bodies are exercised too — a throw
192
+ // inside Section/Button counts the same as one inside the page.
193
+ if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
194
+ return `${node.props?.className || ''} ${treeText(node.props?.children, depth + 1)}`
195
+ }
196
+
197
+ test('the settings page renders every page instead of a blank panel', async () => {
198
+ const ctx = fakeCtx()
199
+ globalThis.__dshMemuExports.apply(ctx)
200
+ const settings = registrations.find((r) => r.slot === 'settings.section')
201
+ assert.ok(settings?.component, 'settings.section was not registered')
202
+
203
+ // First render: state is still null, which is the loading branch.
204
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
205
+ collectPageHook()
206
+ assert.match(treeText(first.tree), /loading|dshm-settings/)
207
+
208
+ // Let the mount effects fetch and settle, then render with data. Hooks survive
209
+ // between renders, which is how the fetched state reaches the next pass.
210
+ for (const effect of first.effects) effect.fn()
211
+ await new Promise((resolve) => setTimeout(resolve, 0))
212
+ await new Promise((resolve) => setTimeout(resolve, 0))
213
+
214
+ for (let page = 0; page < 11; page++) {
215
+ const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
216
+ const text = treeText(tree)
217
+ assert.match(String(tree.props?.className), /dshm-root/, `page ${page} did not render the shell`)
218
+ // The rail is the nav: every page label has to be there. Counting the shared
219
+ // dshm-nav-item class instead would also pick up the in-page tab strips (memory
220
+ // backend, prompt scope, browser view), which use the same widget on purpose.
221
+ const NAV = ['pageOverview', 'pageConnection', 'pageSettings', 'pageFolders', 'pageLibraries', 'pageBrowser', 'pageGlobal', 'pagePrompts', 'pageStats', 'pageMemories', 'pageLogs']
222
+ assert.ok(NAV.every((key) => text.includes(key)), `page ${page} lost a nav entry`)
223
+ assert.equal((text.match(/dshm-nav-ind/g) || []).length, 1, `page ${page} lost or duplicated its rail indicator`)
224
+ assert.match(text, /dshm-page/, `page ${page} lost the content pane`)
225
+ // Passing a labels map where an id was expected stringifies to this, which is
226
+ // exactly how the sidebar domain picker broke.
227
+ assert.ok(!text.includes('[object Object]'), `page ${page} rendered an object as text`)
228
+ }
229
+ })
230
+
231
+ test('the sidebar tab renders with the two-layer header', async () => {
232
+ const ctx = fakeCtx()
233
+ const tab = registrations.find((r) => r.slot === 'sidebar.right.pane.tab')
234
+ assert.ok(tab?.component, 'sidebar.right.pane.tab was not registered')
235
+
236
+ // The registered body is a thin wrapper around MemoryTab; hooks only run in the
237
+ // inner component, so unwrap it before rendering.
238
+ const props = { ctx, t: (key) => key, sessionId: 'probe-session' }
239
+ const wrapper = tab.component(props)
240
+ const component = typeof wrapper.type === 'function' ? wrapper.type : tab.component
241
+ const innerProps = typeof wrapper.type === 'function' ? wrapper.props : props
242
+
243
+ hooks = []
244
+ pageHookIndex = -1
245
+ const first = await renderPage(component, innerProps, undefined, { remount: true })
246
+ assert.ok(first.tree, 'sidebar tab rendered nothing')
247
+
248
+ // Feed the fetched state back in, the same way the host would.
249
+ for (const effect of first.effects) effect.fn()
250
+ await new Promise((resolve) => setTimeout(resolve, 0))
251
+ await new Promise((resolve) => setTimeout(resolve, 0))
252
+
253
+ const { tree } = await renderPage(component, innerProps)
254
+ const text = treeText(tree)
255
+ assert.match(text, /layerFolder|layerConversation|noBinding|loading/, 'sidebar lost its two-layer header')
256
+ assert.ok(!text.includes('[object Object]'), 'sidebar rendered an object as text')
257
+ // The domain picker lists ids, translated through the label map.
258
+ assert.match(text, /dmCoding/, 'domain picker did not render its options')
259
+ })
260
+
261
+ // The browser row is the only place a human can see that a memory is marked
262
+ // "never say this out loud" or "secret" without opening the JSON. The badges are
263
+ // driven by fields that used to be stored and ignored, so a regression here is
264
+ // silent: the data stays correct and the warning disappears.
265
+ function walkNodes(node, visit, depth = 0) {
266
+ if (node === null || node === undefined || depth > 400) return
267
+ if (Array.isArray(node)) {
268
+ for (const child of node) walkNodes(child, visit, depth + 1)
269
+ return
270
+ }
271
+ if (typeof node !== 'object') return
272
+ if (visit(node, depth) === false) return
273
+ if (typeof node.type === 'function') {
274
+ walkNodes(node.type(node.props), visit, depth + 1)
275
+ return
276
+ }
277
+ walkNodes(node.props?.children, visit, depth + 1)
278
+ }
279
+
280
+ test('the memory browser badges what a memory is allowed to say', async () => {
281
+ const ctx = fakeCtx()
282
+ const originalGetState = RPC_ANSWERS.getState
283
+ const originalSettingsState = RPC_ANSWERS.getSettingsState
284
+ const originalLibraries = RPC_ANSWERS.listLibraries
285
+ const loaded = {
286
+ ...EMPTY_STATE,
287
+ libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo', privateCount: 2 }],
288
+ }
289
+ // The pane reads the library list through both endpoints depending on the page.
290
+ RPC_ANSWERS.getState = loaded
291
+ RPC_ANSWERS.getSettingsState = loaded
292
+ RPC_ANSWERS.browseLibrary = {
293
+ source: 'engine',
294
+ files: [
295
+ {
296
+ id: 'mem-quiet',
297
+ title: 'the attic',
298
+ content: 'he never told his brother',
299
+ layer: 'short_term',
300
+ status: 'active',
301
+ decay: 0.5,
302
+ disclosure: 'never_say',
303
+ visibility: 'secret',
304
+ owner: 'about_user',
305
+ expiresAt: '2026-11-01T09:30:00.000Z',
306
+ },
307
+ { id: 'mem-loud', title: 'shared fact', content: 'anything', layer: 'long_term', status: 'active', decay: 0.9, disclosure: 'can_tell', visibility: 'shared', owner: 'me' },
308
+ ],
309
+ }
310
+ try {
311
+ globalThis.__dshMemuExports.apply(ctx)
312
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
313
+ assert.ok(settings?.component, 'settings.section was not registered')
314
+
315
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
316
+ collectPageHook()
317
+ for (const effect of first.effects) effect.fn()
318
+ await new Promise((resolve) => setTimeout(resolve, 0))
319
+ await new Promise((resolve) => setTimeout(resolve, 0))
320
+
321
+ // Open the library the way a click would: pick it, then press reload.
322
+ let tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
323
+ const picker = []
324
+ walkNodes(tree, (node) => {
325
+ // The layer picker of the new-memory form is also a <select>; only this one
326
+ // lists libraries.
327
+ const options = Array.isArray(node.props?.children) ? node.props.children : []
328
+ if (node.type === 'select' && options.some((option) => option?.props?.value === 'lib-1')) picker.push(node)
329
+ })
330
+ assert.equal(picker.length, 1, 'the library picker did not render')
331
+ picker[0].props.onChange({ target: { value: 'lib-1' } })
332
+
333
+ tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
334
+ const reload = []
335
+ walkNodes(tree, (node) => {
336
+ if (typeof node.type === 'function' && node.props?.children === 'reload') reload.push(node)
337
+ })
338
+ assert.equal(reload.length, 1, 'the reload button did not render')
339
+ reload[0].props.onClick()
340
+ await new Promise((resolve) => setTimeout(resolve, 0))
341
+ await new Promise((resolve) => setTimeout(resolve, 0))
342
+
343
+ tree = (await renderPage(settings.component, { ctx, t: (key) => key }, 5)).tree
344
+ const text = treeText(tree)
345
+ // Every row carries three editor selects, so each enum label also appears in the
346
+ // option lists: two rows = 2 occurrences. A badge is the third.
347
+ const count = (needle) => text.split(needle).length - 1
348
+ assert.match(text, /layerShortTerm/, 'a short-term entry renders its layer instead of an empty tag')
349
+ assert.equal(count('discNeverSay'), 3, 'never_say is visible without opening the entry')
350
+ assert.equal(count('discCanTell'), 2, 'the default say-ability stays quiet — options only')
351
+ assert.equal(count('visSecret'), 3, 'a secret is flagged while scanning')
352
+ assert.equal(count('visShared'), 2, 'and so does the default visibility')
353
+ assert.equal(count('ownerAboutUser'), 3, 'whose fact it is shows up too')
354
+ assert.equal(count('shortTermExpires'), 1, 'and a draft shows when it dies')
355
+ assert.ok(!text.includes('[object Object]'), 'a badge rendered an object as text')
356
+ } finally {
357
+ RPC_ANSWERS.getState = originalGetState
358
+ RPC_ANSWERS.getSettingsState = originalSettingsState
359
+ RPC_ANSWERS.listLibraries = originalLibraries
360
+ delete RPC_ANSWERS.browseLibrary
361
+ }
362
+ })
363
+
364
+ test('the WebUI buttons open a new tab and never navigate this one', async () => {
365
+ const opened = []
366
+ const navigated = []
367
+ const originalWindow = { open: globalThis.window.open, location: globalThis.window.location }
368
+ globalThis.window.location = { href: 'http://host/app', assign: (url) => navigated.push(url) }
369
+ try {
370
+ const ctx = fakeCtx()
371
+ globalThis.__dshMemuExports.apply(ctx)
372
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
373
+ assert.ok(settings?.component, 'settings.section was not registered')
374
+
375
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
376
+ collectPageHook()
377
+ for (const effect of first.effects) effect.fn()
378
+ await new Promise((resolve) => setTimeout(resolve, 0))
379
+ await new Promise((resolve) => setTimeout(resolve, 0))
380
+
381
+ const buttons = []
382
+ walkNodes((await renderPage(settings.component, { ctx, t: (key) => key }, 0)).tree, (node) => {
383
+ if (node.type === 'button' && node.props?.children === 'openMemu') buttons.push(node)
384
+ })
385
+ assert.equal(buttons.length, 1, 'the WebUI button did not render')
386
+
387
+ globalThis.window.open = (url) => { opened.push(url); return { focus() {}, opener: null } }
388
+ buttons[0].props.onClick()
389
+ assert.equal(opened.length, 1, 'a popup was requested')
390
+ assert.ok(opened[0].endsWith('/dsh-memu-memory/ui#/settings/engine'), `unexpected url: ${opened[0]}`)
391
+ assert.deepEqual(navigated, [], 'no fallback navigation while the popup works')
392
+
393
+ // Blocked popups return null: fall back to an in-app overlay. Navigating this
394
+ // tab would unload the app and look like a forced refresh on the way back.
395
+ appendedNodes.length = 0
396
+ globalThis.window.open = () => null
397
+ buttons[0].props.onClick()
398
+ assert.equal(appendedNodes.length, 1, 'blocked popups open an in-app overlay')
399
+ const overlay = appendedNodes[0]
400
+ assert.equal(overlay.attrs['data-dsh-memu-memory-webui'], '', 'the overlay is marked')
401
+ const frame = overlay.children.find((child) => child.tag === 'iframe')
402
+ assert.ok(frame, 'the overlay hosts an iframe')
403
+ assert.ok(String(frame.src).endsWith('/dsh-memu-memory/ui#/settings/engine'), `unexpected url: ${frame.src}`)
404
+ assert.deepEqual(navigated, [], 'the current tab is never navigated')
405
+ // The close control lives inside the page; the overlay only waits for its message.
406
+ assert.equal(overlay.children.length, 1, 'no floating chrome outside the panel')
407
+ } finally {
408
+ globalThis.window.open = originalWindow.open
409
+ globalThis.window.location = originalWindow.location
410
+ }
411
+ })
412
+
413
+ test('the sidebar and the settings page hand out the same WebUI button', async () => {
414
+ // Two spellings of one action is how the panes drift: the sidebar used to label
415
+ // this button openSettingsShort while the settings page used openWebUi. Both
416
+ // surfaces now render the same widget with the same label, hint and handler, and
417
+ // the embedded settings page drops its own copy so the sidebar never shows two.
418
+ async function renderSurface(component, props, page) {
419
+ hooks = []
420
+ pageHookIndex = -1
421
+ const first = await renderPage(component, props, page, { remount: true })
422
+ if (page !== undefined) collectPageHook()
423
+ for (const effect of first.effects) effect.fn()
424
+ await new Promise((resolve) => setTimeout(resolve, 0))
425
+ await new Promise((resolve) => setTimeout(resolve, 0))
426
+ const tree = (await renderPage(component, props, page)).tree
427
+ const found = []
428
+ walkNodes(tree, (node) => {
429
+ if (node.type === 'button' && node.props?.children === 'openMemu') found.push(node)
430
+ })
431
+ return { tree, found }
432
+ }
433
+
434
+ const ctx = fakeCtx()
435
+ globalThis.__dshMemuExports.apply(ctx)
436
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
437
+ const tab = registrations.filter((r) => r.slot === 'sidebar.right.pane.tab').pop()
438
+ assert.ok(settings?.component && tab?.component, 'both surfaces are registered')
439
+
440
+ const inSettings = await renderSurface(settings.component, { ctx, t: (key) => key }, 0)
441
+ assert.equal(inSettings.found.length, 1, 'the settings page lost its WebUI button')
442
+
443
+ const props = { ctx, t: (key) => key, sessionId: 'probe-session' }
444
+ const wrapper = tab.component(props)
445
+ const inner = typeof wrapper.type === 'function' ? wrapper.type : tab.component
446
+ const innerProps = typeof wrapper.type === 'function' ? wrapper.props : props
447
+ const inSidebar = await renderSurface(inner, innerProps, undefined)
448
+ assert.equal(inSidebar.found.length, 1, 'the sidebar lost its WebUI button')
449
+ assert.ok(!treeText(inSidebar.tree).includes('openSettings'), 'a second settings entry must not return')
450
+
451
+ // The panel returns early while the conversation is still unknown, before its
452
+ // `pages` array is built. Effects scheduled by that render still run, and one of
453
+ // them reads `pages` — touching it then is a temporal-dead-zone error that the
454
+ // sidebar surfaces as "dsh-better-sidebar: Cannot access 'pages' before
455
+ // initialization". Rendering without a session must stay quiet.
456
+ for (const empty of [null, undefined]) {
457
+ const quiet = await renderSurface(inner, { ...innerProps, sessionId: empty }, undefined)
458
+ assert.ok(treeText(quiet.tree).includes('loading'), 'the unbound panel shows its loading state')
459
+ }
460
+
461
+ for (const node of [...inSettings.found, ...inSidebar.found]) {
462
+ assert.equal(node.props.className, 'dshm-btn', 'the button bypasses the shared widget')
463
+ assert.equal(node.props.title, 'openWebUiHint', 'the button carries a different hint')
464
+ assert.equal(typeof node.props.onClick, 'function', 'the button has no handler')
465
+ }
466
+ // The old keys are gone rather than kept around as a second spelling.
467
+ const text = `${treeText(inSettings.tree)} ${treeText(inSidebar.tree)}`
468
+ assert.ok(!text.includes('openSettingsShort'), 'the second WebUI label came back')
469
+ })
470
+
471
+ test('every pick-one-of-these control uses the same tab strip', async () => {
472
+ // The settings rail, the prompt scope, the browser's active/archived split and
473
+ // the memory backend are one widget (dshm-nav-item). They used to be three
474
+ // widgets with three different hover and focus states, which is what read as
475
+ // "the sidebar and the settings page do not match".
476
+ const ctx = fakeCtx()
477
+ globalThis.__dshMemuExports.apply(ctx)
478
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
479
+
480
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
481
+ collectPageHook()
482
+ for (const effect of first.effects) effect.fn()
483
+ await new Promise((resolve) => setTimeout(resolve, 0))
484
+ await new Promise((resolve) => setTimeout(resolve, 0))
485
+
486
+ const orphans = []
487
+ for (let page = 0; page < 11; page++) {
488
+ const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, page)
489
+ walkNodes(tree, (node) => {
490
+ if (node.type !== 'button') return
491
+ const className = String(node.props?.className || '')
492
+ if (node.props?.role === 'tab' && !className.includes('dshm-nav-item')) {
493
+ orphans.push(`page ${page}: ${JSON.stringify(node.props?.children)}`)
494
+ }
495
+ if (className.includes('dshm-nav-item')) {
496
+ assert.equal(typeof node.props.onClick, 'function', `page ${page} lost a tab handler`)
497
+ }
498
+ })
499
+ }
500
+ assert.deepEqual(orphans, [], 'a tab strip sits outside the shared widget')
501
+ })
502
+
503
+ // Task C: the statistics page is data-driven, so it only paints after two lazy
504
+ // loads (pick the first library, then read its stats). The charts are hand-written
505
+ // SVG — counting nodes is the honest check that thirty bars and one dot per memory
506
+ // actually reached the DOM, and that an empty day did not become NaN.
507
+ test('the statistics page draws the daily bars, the scatter and the shortlist', async () => {
508
+ const ctx = fakeCtx()
509
+ const originalState = RPC_ANSWERS.getSettingsState
510
+ RPC_ANSWERS.getSettingsState = {
511
+ ...EMPTY_STATE,
512
+ libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/proj/demo' }],
513
+ }
514
+ RPC_ANSWERS.getStats = {
515
+ libraryId: 'lib-1',
516
+ available: true,
517
+ keptDays: 90,
518
+ windowDays: 30,
519
+ days: Array.from({ length: 30 }, (_, i) => ({
520
+ date: `2026-09-${String(i + 1).padStart(2, '0')}`,
521
+ written: i === 29 ? 2 : 0,
522
+ injected: i % 3,
523
+ recalled: 0,
524
+ archived: 0,
525
+ })),
526
+ totals: { written: 2, injected: 30, recalled: 4, archived: 1 },
527
+ ratio: { windowDays: 7, written: 2, injected: 9 },
528
+ health: { total: 2, active: 2, marginalized: 0, archived: 0, pendingReview: 0, avgStrength: 0.71 },
529
+ distribution: [
530
+ { id: 'mem-a', title: 'often recalled', layer: 'long_term', ageDays: 3, injectCount: 4, accessCount: 2 },
531
+ { id: 'mem-b', title: 'never recalled', layer: 'long_term', ageDays: 9, injectCount: 0, accessCount: 0 },
532
+ ],
533
+ distributionTotal: 2,
534
+ stale: [{ id: 'mem-b', title: 'never recalled', daysSinceTouch: 9 }],
535
+ }
536
+ const run = async (effectsToRun) => {
537
+ for (const effect of effectsToRun) effect.fn()
538
+ await new Promise((resolve) => setTimeout(resolve, 0))
539
+ await new Promise((resolve) => setTimeout(resolve, 0))
540
+ }
541
+ try {
542
+ globalThis.__dshMemuExports.apply(ctx)
543
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
544
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
545
+ collectPageHook()
546
+ await run(first.effects)
547
+
548
+ let pass = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
549
+ await run(pass.effects)
550
+ pass = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
551
+ await run(pass.effects)
552
+ const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, 8)
553
+
554
+ const counts = { rects: 0, circles: 0 }
555
+ walkNodes(tree, (node) => {
556
+ if (node.type === 'rect') counts.rects += 1
557
+ if (node.type === 'circle') counts.circles += 1
558
+ })
559
+ assert.equal(counts.rects, 30, 'thirty daily bars, including the quiet days')
560
+ assert.equal(counts.circles, 2, 'one scatter dot per memory')
561
+ const text = treeText(tree)
562
+ assert.match(text, /statsWritten/, 'the window totals render')
563
+ assert.match(text, /statsStaleDays/, 'the archive shortlist renders')
564
+ assert.ok(!text.includes('NaN'), 'an empty day must not paint NaN')
565
+ assert.ok(!text.includes('[object Object]'), 'the page rendered an object as text')
566
+ } finally {
567
+ RPC_ANSWERS.getSettingsState = originalState
568
+ delete RPC_ANSWERS.getStats
569
+ }
570
+ })
571
+
572
+ test('the memory tab is registered exactly once, with or without better-sidebar', () => {
573
+ function appCtx(initialBetter) {
574
+ const seen = { native: [], better: [], pending: {} }
575
+ let better = initialBetter
576
+ const slotsScope = { effect: (fn) => { try { fn() } catch {} }, register: () => () => {}, inject: (_n, cb) => cb(slotsScope) }
577
+ const scope = {
578
+ effect: (fn) => { const dispose = fn(); return () => { try { dispose?.() } catch {} } },
579
+ get: (name) => (name === 'betterSidebar' ? better : undefined),
580
+ sidebarRightTabs: {
581
+ register: (spec) => {
582
+ seen.native.push(spec)
583
+ return () => { const at = seen.native.indexOf(spec); if (at >= 0) seen.native.splice(at, 1) }
584
+ },
585
+ },
586
+ slots: { inject: (_n, cb) => cb(slotsScope), register: () => () => {} },
587
+ }
588
+ const ctx = {
589
+ effect: () => {},
590
+ locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
591
+ connection: { rpc: { call: async () => ({ ok: true, value: {} }) } },
592
+ sessions: { list: { subscribe: () => () => {}, getSnapshot: () => ({ byId: {}, ids: [] }) } },
593
+ get: (name) => (name === 'betterSidebar' ? better : undefined),
594
+ slots: { inject: (_n, cb) => cb(scope), register: () => () => {} },
595
+ inject: (deps, callback) => {
596
+ const key = deps[0]
597
+ seen.pending[key] = callback
598
+ if (key === 'betterSidebar' && !better) return
599
+ callback(scope)
600
+ },
601
+ }
602
+ return {
603
+ ctx,
604
+ seen,
605
+ later(service) { better = service; seen.pending.betterSidebar(scope) },
606
+ }
607
+ }
608
+
609
+ let betterCalls = 0
610
+ const betterService = () => ({ registerTab: () => { betterCalls += 1; return () => {} } })
611
+
612
+ // No better-sidebar: the native tab is the only entry.
613
+ const a = appCtx(null)
614
+ globalThis.__dshMemuExports.apply(a.ctx)
615
+ assert.equal(a.seen.native.length, 1, 'native tab registered when better-sidebar is absent')
616
+ assert.equal(a.seen.better.length, 0)
617
+
618
+ // Installed up front: only its tab, otherwise the start list shows two.
619
+ const b = appCtx(betterService())
620
+ globalThis.__dshMemuExports.apply(b.ctx)
621
+ assert.equal(b.seen.native.length, 0, 'native tab must not register when better-sidebar exists')
622
+ assert.equal(betterCalls, 1, 'its own tab is registered instead')
623
+
624
+ // Installed after us: the native tab we already registered has to go.
625
+ const c = appCtx(null)
626
+ globalThis.__dshMemuExports.apply(c.ctx)
627
+ assert.equal(c.seen.native.length, 1)
628
+ let betterRegistrations = 0
629
+ c.later({ registerTab: () => { betterRegistrations += 1; return () => {} } })
630
+ assert.equal(c.seen.native.length, 0, 'late better-sidebar removes the native tab')
631
+ assert.equal(betterRegistrations, 1, 'and registers its own')
632
+ })
633
+
634
+ // Exercise the actual settings component, not source-text matches: stale vector
635
+ // configuration must not escape the Agent gate, and rendering must never install.
636
+ async function renderVectorSettings(t, overrides = {}, answers = {}, pageValue = 1) {
637
+ const loaded = {
638
+ ...EMPTY_STATE,
639
+ ...overrides,
640
+ settings: { ...EMPTY_STATE.settings, ...overrides.settings },
641
+ memuConfig: { ...EMPTY_STATE.memuConfig, ...overrides.memuConfig },
642
+ }
643
+ const rpcCalls = []
644
+ const ctx = fakeCtx({ answers: { getSettingsState: loaded, ...answers }, rpcCalls })
645
+ globalThis.__dshMemuExports.apply(ctx)
646
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
647
+ assert.ok(settings?.component, 'settings.section was not registered')
648
+ const props = { ctx, t: (key) => key }
649
+ const first = await renderPage(settings.component, props, undefined, { remount: true })
650
+ collectPageHook()
651
+ const cleanups = []
652
+ t.after(() => { for (const cleanup of cleanups.reverse()) cleanup() })
653
+ for (const effect of first.effects) {
654
+ const cleanup = effect.fn()
655
+ if (typeof cleanup === 'function') cleanups.push(cleanup)
656
+ }
657
+ await new Promise((resolve) => setTimeout(resolve, 0))
658
+ await new Promise((resolve) => setTimeout(resolve, 0))
659
+ const tree = (await renderPage(settings.component, props, pageValue)).tree
660
+ const nodes = []
661
+ walkNodes(tree, (node) => { nodes.push(node) })
662
+ const rerender = async () => {
663
+ const { tree, effects } = await renderPage(settings.component, props, pageValue)
664
+ const nodes = []
665
+ walkNodes(tree, node => { nodes.push(node) })
666
+ return { tree, nodes, effects }
667
+ }
668
+ return { tree, nodes, rpcCalls, rerender }
669
+ }
670
+
671
+ const VECTOR_CATALOG_FIXTURE = {
672
+ catalog: [
673
+ { id: 'fixture-download', displayName: 'Fixture Download Model', availability: 'available', dimension: 384, totalBytes: 100 },
674
+ { id: 'fixture-pending', displayName: 'Fixture Pending Model', availability: 'pending', pendingReasons: ['SHA-256 pending'], dimension: 384 },
675
+ { id: 'fixture-installed', displayName: 'Fixture Installed Model', availability: 'available', dimension: 384 },
676
+ ],
677
+ installations: { 'fixture-installed': { installed: true, fingerprintMatches: true, status: 'installed' } },
678
+ vector: {},
679
+ }
680
+
681
+ test('the default Agent backend hides vector source, catalog and API configuration', async (t) => {
682
+ const { tree, nodes, rpcCalls } = await renderVectorSettings(t, {
683
+ memuConfig: { source: 'api', model: 'stale-api-model' },
684
+ localVector: VECTOR_CATALOG_FIXTURE,
685
+ })
686
+ const text = treeText(tree)
687
+ assert.match(text, /backendAgent/)
688
+ assert.ok(nodes.some((node) => node.type === 'button' && node.props.children === 'backendAgent' && node.props['aria-selected'] === true))
689
+ for (const hidden of ['vectorSource', 'managedVector', 'Fixture Download Model', 'summaryModel']) {
690
+ assert.ok(!text.includes(hidden), hidden + ' escaped the default Agent gate')
691
+ }
692
+ assert.ok(!nodes.some((node) => node.type === 'input' && node.props.value === 'stale-api-model'))
693
+ assert.ok(!rpcCalls.some((call) => /downloadLocalVector|setEmbeddingSource|switchLocalVector/.test(call.endpoint)))
694
+ })
695
+
696
+ test('local vector settings render catalog controls and native download and migration progress', async (t) => {
697
+ const { tree, nodes, rpcCalls } = await renderVectorSettings(t, {
698
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
699
+ localVector: {
700
+ ...VECTOR_CATALOG_FIXTURE,
701
+ installations: {
702
+ ...VECTOR_CATALOG_FIXTURE.installations,
703
+ 'fixture-download': { status: 'downloading', progress: { files: 2, done: 1, bytes: 25, totalBytes: 100, current: 'model.onnx' } },
704
+ },
705
+ vector: {
706
+ active: { catalogId: 'previous-model' },
707
+ requested: { catalogId: 'fixture-installed' },
708
+ pending: { report: { progress: { completed: 2, total: 8, generation: 3, stage: 'embedding', current: { itemId: 'memory-2' } } } },
709
+ },
710
+ },
711
+ })
712
+ const text = treeText(tree)
713
+ for (const visible of ['vectorSourceLocal', 'Fixture Download Model', 'Fixture Pending Model', 'SHA-256 pending', 'managedVectorDownload', 'managedVectorSelect', 'managedVectorSwitch', 'managedVectorRollback', 'model.onnx', 'memory-2']) {
714
+ assert.ok(text.includes(visible), visible + ' did not render')
715
+ }
716
+ const download = nodes.filter((node) => node.type === 'button' && node.props.children === 'managedVectorDownload')
717
+ assert.equal(download.length, 1, 'a pending manifest must not get a download button')
718
+ const progress = nodes.filter((node) => node.type === 'progress')
719
+ assert.equal(progress.length, 2)
720
+ assert.ok(progress.some((node) => node.props['aria-label'] === 'managedVectorProgress' && node.props.value === 25 && node.props.max === 100))
721
+ assert.ok(progress.some((node) => node.props['aria-label'] === 'managedVectorRebuilding' && node.props.value === 2 && node.props.max === 8))
722
+ assert.equal(nodes.filter((node) => node.props?.role === 'status' && node.props['aria-live'] === 'polite').length, 2)
723
+ assert.ok(!nodes.some((node) => node.type === 'input' && node.props.type === 'password'), 'API key fields leaked into local source')
724
+ assert.ok(!rpcCalls.some((call) => /downloadLocalVector|selectLocalVector|switchLocalVector/.test(call.endpoint)), 'rendering performed a model operation')
725
+ })
726
+
727
+ test('migration with unknown total renders indeterminate native progress', async (t) => {
728
+ const { tree, nodes } = await renderVectorSettings(t, {
729
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
730
+ localVector: { catalog: [], installations: {}, vector: { pending: { report: { progress: { completed: 0, total: 0, generation: 4, stage: 'preparing' } } } } },
731
+ })
732
+ const progress = nodes.filter((node) => node.type === 'progress')
733
+ assert.equal(progress.length, 1)
734
+ assert.equal(progress[0].props.value, undefined, 'unknown work must not look complete')
735
+ assert.equal(progress[0].props.max, 1)
736
+ assert.match(treeText(tree), /managedVectorGeneration 4/)
737
+ assert.ok(!treeText(tree).includes('NaN'))
738
+ })
739
+
740
+ test('switch waits for two clicks, reports in-flight work, and keeps a failed operation visible', async (t) => {
741
+ let finish
742
+ const response = new Promise(resolve => { finish = resolve })
743
+ const view = await renderVectorSettings(t, {
744
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
745
+ localVector: {
746
+ ...VECTOR_CATALOG_FIXTURE,
747
+ runtime: { status: 'installed', ready: true, path: 'runtime' },
748
+ vector: { requested: { catalogId: 'fixture-installed' }, active: { catalogId: 'old-model' } },
749
+ },
750
+ }, { switchLocalVectorProfile: () => response })
751
+ const button = current => current.nodes.find(node => node.type === 'button' && node.props.title === 'managedVectorSwitch')
752
+ await button(view).props.onClick()
753
+ assert.equal(view.rpcCalls.filter(call => call.endpoint === 'switchLocalVectorProfile').length, 0)
754
+ const armed = await view.rerender()
755
+ assert.equal(button(armed).props.children, 'confirmAction')
756
+ const operation = button(armed).props.onClick()
757
+ const waiting = await view.rerender()
758
+ assert.equal(button(waiting).props.disabled, true)
759
+ assert.ok(waiting.nodes.some(node => node.props?.role === 'status' && treeText(node).includes('managedVectorStatus: managedVectorSwitch')))
760
+ assert.match(treeText(waiting.tree), /managedVectorPrerequisites/)
761
+ finish({ ok: false, error: { code: 'LOCAL_VECTOR_RUNTIME_UNAVAILABLE', message: 'a verified managed runtime is required before switching local embeddings' } })
762
+ await operation
763
+ const failed = await view.rerender()
764
+ assert.equal(button(failed).props.disabled, false)
765
+ assert.ok(failed.nodes.some(node => node.props?.role === 'alert' && treeText(node).includes('verified managed runtime')))
766
+ assert.match(treeText(failed.tree), /old-model/)
767
+ assert.match(treeText(failed.tree), /managedVectorRequested/)
768
+ assert.equal(view.rpcCalls.filter(call => call.endpoint === 'switchLocalVectorProfile').length, 1)
769
+ })
770
+
771
+ test('pending manifests show verification and size reasons; persisted migration failures remain visible', async (t) => {
772
+ const { tree, nodes } = await renderVectorSettings(t, {
773
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
774
+ localVector: {
775
+ ...VECTOR_CATALOG_FIXTURE,
776
+ installations: { ...VECTOR_CATALOG_FIXTURE.installations, 'fixture-pending': { status: 'absent' } },
777
+ vector: { pending: { status: 'failed', error: {
778
+ code: 'VECTOR_SYNC_FAILED',
779
+ message: 'cannot list shadow database',
780
+ failedItems: [{ itemId: 'mem-failed-1', code: 'VECTOR_READBACK_FAILED', message: 'committed item is missing from shadow' }],
781
+ } } },
782
+ },
783
+ })
784
+ assert.match(treeText(tree), /managedVectorSizePending/)
785
+ assert.match(treeText(tree), /managedVectorPending/)
786
+ assert.match(treeText(tree), /SHA-256 pending/)
787
+ assert.ok(treeText(tree).includes('managedVectorSizePending'), 'pending artifacts must not display a guessed byte count')
788
+ assert.equal(nodes.filter(node => node.type === 'button' && node.props.title === 'managedVectorDownload').length, 1)
789
+ assert.ok(nodes.some(node => node.props?.role === 'alert' && treeText(node).includes('cannot list shadow database')))
790
+ // 重建失败必须说清是哪一条、为什么:只报"未覆盖全部记忆"根本没法修。
791
+ const failedText = treeText(tree)
792
+ assert.match(failedText, /mem-failed-1/)
793
+ assert.match(failedText, /VECTOR_READBACK_FAILED/)
794
+ assert.match(failedText, /committed item is missing from shadow/)
795
+ })
796
+
797
+ test('a finished rebuild reads as switched, not as a running progress bar', async (t) => {
798
+ const { tree } = await renderVectorSettings(t, {
799
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
800
+ localVector: {
801
+ ...VECTOR_CATALOG_FIXTURE,
802
+ runtime: { status: 'installed', ready: true, path: 'runtime' },
803
+ vector: {
804
+ active: { catalogId: 'fixture-installed', generation: 1, fingerprint: '47511436abcdef', lastMigration: { committed: 8, total: 8, generation: 1 } },
805
+ indexed: { global: ['a', 'b'], lib: ['c', 'd', 'e'] },
806
+ },
807
+ },
808
+ })
809
+ const text = treeText(tree)
810
+ assert.match(text, /vectorStageCommitted/)
811
+ assert.match(text, /vectorSwitchDoneHint/)
812
+ assert.doesNotMatch(text, /managedVectorRebuilding/, 'a committed generation must not keep the running label')
813
+ assert.match(text, /vectorRouteLocal/, 'the header must say which route recall uses')
814
+ assert.match(text, /vectorIndexedNow/, 'the card must show the live indexed count, not only the switch snapshot')
815
+ assert.match(text, /vectorIndexedNow 5 vectorIndexedUnit/, 'two global + three library ids')
816
+ })
817
+
818
+ test('API vector settings render provider fields and hide the managed local catalog', async (t) => {
819
+ const { tree, nodes } = await renderVectorSettings(t, {
820
+ settings: { memoryBackend: 'api', vectorSource: 'api' },
821
+ memuConfig: { source: 'api', provider: 'jina', model: 'fixture-api-model', baseUrl: 'https://example.invalid/v1', hasKey: true },
822
+ summaryConfig: { model: 'fixture-summary-model' },
823
+ localVector: VECTOR_CATALOG_FIXTURE,
824
+ })
825
+ const text = treeText(tree)
826
+ for (const visible of ['vectorSourceApi', 'provider', 'model', 'baseUrl', 'apiKey', 'summaryModel']) {
827
+ assert.ok(text.includes(visible), visible + ' did not render')
828
+ }
829
+ assert.ok(nodes.some((node) => node.type === 'select' && node.props.value === 'jina'))
830
+ assert.ok(nodes.some((node) => node.type === 'input' && node.props.value === 'fixture-api-model'))
831
+ assert.ok(nodes.some((node) => node.type === 'input' && node.props.value === 'https://example.invalid/v1'))
832
+ assert.equal(nodes.filter((node) => node.type === 'input' && node.props.type === 'password').length, 2)
833
+ assert.ok(!text.includes('Fixture Download Model'))
834
+ assert.ok(!text.includes('managedVectorDownload'))
835
+ assert.ok(!nodes.some((node) => node.type === 'progress'))
836
+ })
837
+
838
+ test('clicking the API source tab sends the explicit setEmbeddingSource RPC only', async (t) => {
839
+ const { nodes, rpcCalls } = await renderVectorSettings(t, {
840
+ settings: { memoryBackend: 'api', vectorSource: 'local' },
841
+ localVector: VECTOR_CATALOG_FIXTURE,
842
+ })
843
+ const apiTab = nodes.filter((node) => node.type === 'button' && node.props.children === 'vectorSourceApi')
844
+ assert.equal(apiTab.length, 1)
845
+ assert.equal(apiTab[0].props['aria-selected'], false)
846
+ assert.equal(apiTab[0].props.disabled, false)
847
+ assert.equal(rpcCalls.filter((call) => call.endpoint === 'setEmbeddingSource').length, 0)
848
+ await apiTab[0].props.onClick()
849
+ assert.deepEqual(rpcCalls.filter((call) => call.endpoint === 'setEmbeddingSource'), [
850
+ { path: '/dsh-memu-memory', endpoint: 'setEmbeddingSource', payload: { source: 'api' } },
851
+ ])
852
+ assert.ok(!rpcCalls.some((call) => /downloadLocalVector|selectLocalVector|switchLocalVector/.test(call.endpoint)))
853
+ })
854
+
855
+ test('the log page renders rows with copy, download and locate actions', async () => {
856
+ const event = {
857
+ id: 'act-fixture', at: '2026-10-30T03:00:00.000Z', kind: 'consolidate', level: 'warn',
858
+ scope: 'private', sessionId: 'session-abcdef', libraryId: 'lib-1',
859
+ message: '整理超时:fork 已停止', detail: { timedOut: true, childId: 'memu-consolidate-x' }, refs: [], ok: false,
860
+ }
861
+ const check = {
862
+ id: 'act-check', at: '2026-10-30T03:05:00.000Z', kind: 'check', level: 'info',
863
+ scope: 'private', sessionId: 'session-abcdef', libraryId: 'lib-1',
864
+ message: '主动检查跳过 · score 0.12 · large-transcript-thin · 空闲 40 分钟 · 会话 5913 事件',
865
+ detail: { reason: 'large-transcript-thin', score: 0.12, parts: { human: 0.33, content: 0.01, tool: 0 } }, refs: [], ok: false,
866
+ }
867
+ const ctx = fakeCtx({
868
+ answers: { getActivityLog: { events: [event, check], stats: { total: 2, capped: 2000, bytes: 1024 } } },
869
+ })
870
+ globalThis.__dshMemuExports.apply(ctx)
871
+ const settings = registrations.filter((r) => r.slot === 'settings.section').pop()
872
+ const first = await renderPage(settings.component, { ctx, t: (key) => key }, undefined, { remount: true })
873
+ collectPageHook()
874
+ for (const effect of first.effects) effect.fn()
875
+ await new Promise((resolve) => setTimeout(resolve, 0))
876
+ await new Promise((resolve) => setTimeout(resolve, 0))
877
+ // The log page's own effect only exists once that page renders, so it needs one
878
+ // more render + effect pass before the data is in the hook.
879
+ const mid = await renderPage(settings.component, { ctx, t: (key) => key }, 10)
880
+ for (const effect of mid.effects) effect.fn()
881
+ await new Promise((resolve) => setTimeout(resolve, 0))
882
+ await new Promise((resolve) => setTimeout(resolve, 0))
883
+ const { tree } = await renderPage(settings.component, { ctx, t: (key) => key }, 10)
884
+ const text = treeText(tree)
885
+ assert.match(text, /整理超时/)
886
+ assert.match(text, /logCopyAll/)
887
+ assert.match(text, /logDownload/)
888
+ assert.match(text, /logLocate/, 'a row with a library id offers the correction entry point')
889
+ assert.match(text, /pageLogs/)
890
+ assert.match(text, /logKindCheck/, 'the activity log labels wake-up checks')
891
+ assert.match(text, /large-transcript-thin/, 'the check row keeps its reason and score visible')
892
+ })