dsh-memu-memory 1.1.3 → 1.2.1
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/AGENTS.md +1 -1
- package/README.md +39 -6
- package/README.zh.md +39 -7
- package/docs/active-memory-ab.md +385 -0
- package/docs/binding-lifecycle.md +99 -0
- package/docs/contributing.md +3 -3
- package/docs/emotion-model-spec.md +281 -0
- package/docs/env-and-commands.md +8 -0
- package/docs/host-api.md +2 -2
- package/docs/memory-semantics.md +12 -10
- package/docs/recall-benchmark.md +238 -0
- package/docs/troubleshooting.md +396 -395
- package/lib/active-memory.js +165 -0
- package/lib/activity-log.js +23 -6
- package/lib/binding-diagnostics.js +8 -3
- package/lib/client.js +335 -35
- package/lib/consolidation-ledger.js +347 -0
- package/lib/consolidation-receipt.js +284 -0
- package/lib/consolidation-snapshot.js +274 -0
- package/lib/consolidation-worker.js +144 -0
- package/lib/curator.js +86 -31
- package/lib/cycle.js +40 -6
- package/lib/graph.js +139 -5
- package/lib/index.js +33 -1
- package/lib/inject.js +104 -76
- package/lib/local-embedding-manager.js +24 -6
- package/lib/memory.js +534 -88
- package/lib/mood.js +268 -39
- package/lib/preset-scope.js +1 -1
- package/lib/private.js +430 -43
- package/lib/prompts/private-consolidation.coding.md +1 -0
- package/lib/prompts/private-consolidation.md +1 -0
- package/lib/prompts/private-consolidation.mixed.md +1 -0
- package/lib/prompts/private-consolidation.rp.md +1 -0
- package/lib/prompts.js +4 -4
- package/lib/remote-summary.js +51 -25
- package/lib/rpc.js +54 -5
- package/lib/state.js +29 -12
- package/lib/text-similarity.js +106 -0
- package/lib/tools.js +113 -10
- package/lib/vector-migration.js +28 -3
- package/lib/vector-profile.js +51 -0
- package/package.json +4 -2
- package/scripts/acceptance-local.mjs +2 -1
- package/scripts/active-memory-ab-live.mjs +655 -0
- package/scripts/active-memory-ab.mjs +1302 -0
- package/scripts/chain-matrix.mjs +11 -1
- package/scripts/check-all.mjs +10 -1
- package/scripts/replay.mjs +573 -0
- package/tests/activation.test.mjs +89 -0
- package/tests/active-memory-ab.test.mjs +745 -0
- package/tests/active-memory.test.mjs +129 -0
- package/tests/activity-log.test.mjs +126 -0
- package/tests/activity-wiring.test.mjs +103 -0
- package/tests/appendix-b.test.mjs +216 -0
- package/tests/appendix-b2.test.mjs +166 -0
- package/tests/audit-regression.test.mjs +272 -0
- package/tests/audit-safety.test.mjs +227 -0
- package/tests/backend-compensation.test.mjs +272 -0
- package/tests/backup.test.mjs +793 -0
- package/tests/binding-diagnostics.test.mjs +720 -0
- package/tests/browser.test.mjs +366 -0
- package/tests/client-render.test.mjs +892 -0
- package/tests/coalesce.test.mjs +113 -0
- package/tests/config.test.mjs +159 -0
- package/tests/consolidation-credential.test.mjs +331 -0
- package/tests/consolidation-feed.test.mjs +96 -0
- package/tests/consolidation-fork.test.mjs +489 -0
- package/tests/consolidation-ledger.test.mjs +298 -0
- package/tests/consolidation-snapshot.test.mjs +360 -0
- package/tests/consolidation-value.test.mjs +89 -0
- package/tests/context-scope.test.mjs +40 -0
- package/tests/cue.test.mjs +44 -0
- package/tests/curator-commit.test.mjs +277 -0
- package/tests/curator-probe.test.mjs +433 -0
- package/tests/curator.test.mjs +150 -0
- package/tests/cycle.test.mjs +411 -0
- package/tests/disclosure.test.mjs +231 -0
- package/tests/doc-drift.test.mjs +222 -0
- package/tests/embedding-catalog.test.mjs +266 -0
- package/tests/embedding-download.test.mjs +391 -0
- package/tests/embedding-runtime.test.mjs +389 -0
- package/tests/emotion-chain.test.mjs +84 -0
- package/tests/emotion-contract.test.mjs +445 -0
- package/tests/engine.test.mjs +35 -0
- package/tests/fixtures/active-memory-ab/README.md +56 -0
- package/tests/fixtures/active-memory-ab/cases/01-clean-dense-turn.json +351 -0
- package/tests/fixtures/active-memory-ab/cases/02-clean-thin-turn.json +122 -0
- package/tests/fixtures/active-memory-ab/cases/03-clean-starved-small.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/04-clean-large-thin.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/05-clean-half-step.json +166 -0
- package/tests/fixtures/active-memory-ab/cases/06-clean-duplicate-reread.json +197 -0
- package/tests/fixtures/active-memory-ab/cases/07-clean-recent-recall.json +125 -0
- package/tests/fixtures/active-memory-ab/cases/08-global-relevant.json +111 -0
- package/tests/fixtures/active-memory-ab/cases/09-global-irrelevant-conflict.json +158 -0
- package/tests/fixtures/active-memory-ab/cases/10-no-human-turn.json +97 -0
- package/tests/fixtures/active-memory-ab/cases/11-clean-scale-100.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/12-clean-scale-1000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/13-clean-scale-5000.json +137 -0
- package/tests/fixtures/active-memory-ab/cases/14-clean-scale-20000.json +137 -0
- package/tests/fixtures/active-memory-ab/lead-ai-reviewed-reference.json +191 -0
- package/tests/fixtures/active-memory-ab/manifest.json +199 -0
- package/tests/fixtures/recall-bench/01-paraphrase.json +184 -0
- package/tests/fixtures/recall-bench/02-exact.json +153 -0
- package/tests/fixtures/recall-bench/03-negation.json +173 -0
- package/tests/fixtures/recall-bench/04-no-answer.json +162 -0
- package/tests/fixtures/recall-bench/05-conflict.json +194 -0
- package/tests/fixtures/recall-bench/06-boundary.json +273 -0
- package/tests/fixtures/recall-bench/07-secret.json +191 -0
- package/tests/fixtures/recall-bench/08-coding.json +164 -0
- package/tests/fixtures/recall-bench-gold-candidates/01-paraphrase.json +193 -0
- package/tests/fixtures/recall-bench-gold-candidates/02-exact.json +197 -0
- package/tests/fixtures/recall-bench-gold-candidates/03-negation.json +244 -0
- package/tests/fixtures/recall-bench-gold-candidates/04-no-answer.json +189 -0
- package/tests/fixtures/recall-bench-gold-candidates/05-conflict.json +245 -0
- package/tests/fixtures/recall-bench-gold-candidates/06-boundary.json +279 -0
- package/tests/fixtures/recall-bench-gold-candidates/07-secret.json +224 -0
- package/tests/fixtures/recall-bench-gold-candidates/08-coding.json +218 -0
- package/tests/fixtures/repetition-eval/01-zh-rp.json +93 -0
- package/tests/fixtures/repetition-eval/02-en.json +97 -0
- package/tests/fixtures/repetition-eval/03-code.json +91 -0
- package/tests/fixtures/repetition-eval/04-names.json +116 -0
- package/tests/fixtures/repetition-eval/05-user-quote.json +96 -0
- package/tests/fixtures/repetition-eval/06-agreement.json +86 -0
- package/tests/graph-cascade.test.mjs +190 -0
- package/tests/graph-view.test.mjs +187 -0
- package/tests/graph.test.mjs +94 -0
- package/tests/hook-order.test.mjs +72 -0
- package/tests/humanlike.test.mjs +232 -0
- package/tests/hybrid-recall.test.mjs +122 -0
- package/tests/inbound.test.mjs +393 -0
- package/tests/inject-guide.test.mjs +364 -0
- package/tests/inject.test.mjs +222 -0
- package/tests/injection-budget-settings.test.mjs +35 -0
- package/tests/injection-budget.test.mjs +64 -0
- package/tests/injection-chain.test.mjs +191 -0
- package/tests/loader-register.mjs +3 -0
- package/tests/loader.mjs +12 -0
- package/tests/local-embedding-entry.test.mjs +95 -0
- package/tests/local-embedding-manager.test.mjs +476 -0
- package/tests/local-embedding-protocol.test.mjs +95 -0
- package/tests/local-embedding-worker.test.mjs +346 -0
- package/tests/managed-memory.test.mjs +591 -0
- package/tests/managed-runtime.test.mjs +40 -0
- package/tests/media.test.mjs +132 -0
- package/tests/migration-payload.test.mjs +42 -0
- package/tests/mood.test.mjs +109 -0
- package/tests/multiprocess.test.mjs +29 -0
- package/tests/new-session-gate.test.mjs +361 -0
- package/tests/package-shipping.test.mjs +84 -0
- package/tests/preset-scope.test.mjs +212 -0
- package/tests/prompts.test.mjs +250 -0
- package/tests/react-snapshot.test.mjs +23 -0
- package/tests/recall-bench.test.mjs +306 -0
- package/tests/recall-dedup.test.mjs +417 -0
- package/tests/remote.test.mjs +140 -0
- package/tests/rename.test.mjs +30 -0
- package/tests/repetition-detector-snapshot.test.mjs +231 -0
- package/tests/repetition-eval-live.test.mjs +177 -0
- package/tests/repetition-eval.test.mjs +373 -0
- package/tests/salient-date.test.mjs +142 -0
- package/tests/settings-beat.test.mjs +221 -0
- package/tests/short-term.test.mjs +166 -0
- package/tests/sidebar-beat.test.mjs +212 -0
- package/tests/sidebar-refresh.test.mjs +123 -0
- package/tests/sidebar.test.mjs +70 -0
- package/tests/snippet.test.mjs +47 -0
- package/tests/source-kind.test.mjs +52 -0
- package/tests/state.test.mjs +150 -0
- package/tests/stats.test.mjs +112 -0
- package/tests/storage.test.mjs +125 -0
- package/tests/stubs/dsh-llm.js +5 -0
- package/tests/stubs/dsh-session.js +7 -0
- package/tests/stubs/dsh-tools.js +101 -0
- package/tests/tool-schema.test.mjs +73 -0
- package/tests/tools.test.mjs +184 -0
- package/tests/ui-defaults.test.mjs +57 -0
- package/tests/update-fields.test.mjs +138 -0
- package/tests/vector-migration.test.mjs +790 -0
- package/tests/vector-profile.test.mjs +425 -0
- package/tests/visibility-migration.test.mjs +152 -0
- package/tests/webui-auth.test.mjs +454 -0
- package/tests/webui.test.mjs +165 -0
- package/tests/windows-env.test.mjs +89 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// The new-session prompt is mounted through ReactDOM.createRoot, which the settings
|
|
2
|
+
// render harness leaves stubbed out — so its cards were never exercised by any test.
|
|
3
|
+
// It is also the one screen where a wrong default silently decides whether a whole
|
|
4
|
+
// conversation remembers anything, so it gets its own harness.
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
const roots = []
|
|
9
|
+
|
|
10
|
+
function makeElement(type, props, ...children) {
|
|
11
|
+
return { type, props: { ...(props || {}), children: children.length <= 1 ? children[0] : children } }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let hooks = []
|
|
15
|
+
let cursor = 0
|
|
16
|
+
let effects = []
|
|
17
|
+
|
|
18
|
+
const React = {
|
|
19
|
+
createElement: makeElement,
|
|
20
|
+
Fragment: Symbol('Fragment'),
|
|
21
|
+
useState(initial) {
|
|
22
|
+
const index = cursor++
|
|
23
|
+
if (hooks[index] === undefined) hooks[index] = typeof initial === 'function' ? initial() : initial
|
|
24
|
+
return [hooks[index], (next) => { hooks[index] = typeof next === 'function' ? next(hooks[index]) : next }]
|
|
25
|
+
},
|
|
26
|
+
useCallback: (fn) => fn,
|
|
27
|
+
useMemo: (fn) => fn(),
|
|
28
|
+
useEffect(fn, deps) { effects.push({ fn, deps, index: cursor++ }) },
|
|
29
|
+
useLayoutEffect() {},
|
|
30
|
+
useRef: (value) => ({ current: value }),
|
|
31
|
+
useSyncExternalStore: (_subscribe, getSnapshot) => getSnapshot(),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const reactDom = {
|
|
35
|
+
createRoot(container) {
|
|
36
|
+
const root = { container, element: null }
|
|
37
|
+
root.render = (element) => { root.element = element; roots.push(root) }
|
|
38
|
+
root.unmount = () => {}
|
|
39
|
+
return root
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
globalThis.window = {
|
|
44
|
+
__ModuleLoader__: {
|
|
45
|
+
load({ factory }) {
|
|
46
|
+
globalThis.__dshMemuExports = factory((name) => {
|
|
47
|
+
if (name === 'react') return React
|
|
48
|
+
if (name === 'react-dom/client') return reactDom
|
|
49
|
+
throw new Error('unexpected require: ' + name)
|
|
50
|
+
})
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
addEventListener() {},
|
|
54
|
+
removeEventListener() {},
|
|
55
|
+
}
|
|
56
|
+
globalThis.document = {
|
|
57
|
+
createElement: () => ({ style: {}, attrs: {}, setAttribute(n, v) { this.attrs[n] = v }, appendChild() {}, remove() {} }),
|
|
58
|
+
querySelector: () => null,
|
|
59
|
+
addEventListener() {},
|
|
60
|
+
removeEventListener() {},
|
|
61
|
+
head: { appendChild() {} },
|
|
62
|
+
body: { append(node) { return node } },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await import('../lib/client.js')
|
|
66
|
+
// A folder that keeps both layers, with a library already sitting in it.
|
|
67
|
+
const OPEN_FOLDER = {
|
|
68
|
+
binding: null,
|
|
69
|
+
shouldPrompt: true,
|
|
70
|
+
libraries: [{ id: 'lib-1', name: 'demo', scopeKey: '/ws/demo' }],
|
|
71
|
+
domains: ['rp', 'coding', 'general', 'mixed'],
|
|
72
|
+
folder: { scopeKey: '/ws/demo', useLibrary: true, useGlobal: true },
|
|
73
|
+
settings: { newSessionPromptDismissed: false, newSessionDefault: 'both' },
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildCtx(promptState) {
|
|
77
|
+
const snapshot = { byId: { 'sess-1': { cwd: '/ws/demo', blank: true, origin: 'user' } }, ids: ['sess-1'] }
|
|
78
|
+
const connection = {
|
|
79
|
+
rpc: {
|
|
80
|
+
call: async (_path, endpoint, payload) => {
|
|
81
|
+
if (endpoint === 'getSessionPromptState') return { ok: true, value: promptState }
|
|
82
|
+
if (endpoint === 'initSession' || endpoint === 'setSessionScope' || endpoint === 'setDomain') {
|
|
83
|
+
calls.push({ endpoint, payload })
|
|
84
|
+
return { ok: true, value: {} }
|
|
85
|
+
}
|
|
86
|
+
throw new Error('unexpected rpc: ' + endpoint)
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
// This is the scope the gate is mounted with; the host passes an injected
|
|
91
|
+
// sub-scope that can still reach the session and the RPC connection.
|
|
92
|
+
const gateScope = {
|
|
93
|
+
get: (name) => (name === 'uiSession'
|
|
94
|
+
? { adapter: { current: { subscribe: () => () => {}, getSnapshot: () => ({ key: 'sess-1' }) } } }
|
|
95
|
+
: undefined),
|
|
96
|
+
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => snapshot } },
|
|
97
|
+
connection,
|
|
98
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
effect: (fn) => { try { fn() } catch {} },
|
|
102
|
+
locale: { register: () => () => {}, bind: () => (key) => key, get: () => 'zh' },
|
|
103
|
+
connection,
|
|
104
|
+
sessions: gateScope.sessions,
|
|
105
|
+
get: gateScope.get,
|
|
106
|
+
slots: {
|
|
107
|
+
inject: (_name, callback) => { callback && callback(gateScope) },
|
|
108
|
+
register: () => () => {},
|
|
109
|
+
},
|
|
110
|
+
inject: (deps, callback) => { if (Array.isArray(deps) && deps.includes('sessions')) callback(gateScope) },
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const calls = []
|
|
115
|
+
|
|
116
|
+
function renderGate(promptState) {
|
|
117
|
+
hooks = []
|
|
118
|
+
calls.length = 0
|
|
119
|
+
cursor = 0
|
|
120
|
+
effects = []
|
|
121
|
+
roots.length = 0
|
|
122
|
+
globalThis.__dshMemuExports.apply(buildCtx(promptState))
|
|
123
|
+
assert.equal(roots.length, 1, 'the new-session gate was not mounted at all')
|
|
124
|
+
return roots[0].element
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// First pass renders nothing (no prompt yet), the mount effect fetches the state,
|
|
128
|
+
// the second pass renders the dialog with whatever the effect decided.
|
|
129
|
+
async function settle(element) {
|
|
130
|
+
effects = []
|
|
131
|
+
cursor = 0
|
|
132
|
+
const first = element.type(element.props)
|
|
133
|
+
for (const effect of effects) effect.fn()
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
135
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
136
|
+
effects = []
|
|
137
|
+
cursor = 0
|
|
138
|
+
return { first, tree: element.type(element.props) }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function treeText(node, depth = 0) {
|
|
142
|
+
if (node === null || node === undefined || depth > 400) return ''
|
|
143
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
144
|
+
if (Array.isArray(node)) return node.map((child) => treeText(child, depth + 1)).join(' ')
|
|
145
|
+
if (typeof node.type === 'function') return treeText(node.type(node.props), depth + 1)
|
|
146
|
+
return treeText(node.props?.children, depth + 1)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function walk(node, visit, depth = 0) {
|
|
150
|
+
if (node === null || node === undefined || depth > 400) return
|
|
151
|
+
if (Array.isArray(node)) { for (const child of node) walk(child, visit, depth + 1); return }
|
|
152
|
+
if (typeof node !== 'object') return
|
|
153
|
+
visit(node)
|
|
154
|
+
if (typeof node.type === 'function') { walk(node.type(node.props), visit, depth + 1); return }
|
|
155
|
+
walk(node.props?.children, visit, depth + 1)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function cardsOf(tree) {
|
|
159
|
+
const cards = []
|
|
160
|
+
walk(tree, (node) => { if (node.props?.role === 'radio') cards.push(node) })
|
|
161
|
+
return cards
|
|
162
|
+
}
|
|
163
|
+
test('the new-session prompt is four cards with consequences, and waits before showing', async () => {
|
|
164
|
+
const element = renderGate(OPEN_FOLDER)
|
|
165
|
+
const { first, tree } = await settle(element)
|
|
166
|
+
// Nothing is known yet on the first pass, so there must be no empty dialog
|
|
167
|
+
// flashing on every new conversation.
|
|
168
|
+
assert.equal(first, null, 'the gate painted before the session state arrived')
|
|
169
|
+
|
|
170
|
+
const cards = cardsOf(tree)
|
|
171
|
+
assert.equal(cards.length, 4, 'the prompt is four cards, not four checkboxes')
|
|
172
|
+
const titles = cards.map((card) => treeText(card.props.children).trim())
|
|
173
|
+
assert.ok(titles[0].includes('memOffTitle'), 'the no-memory card lost its title')
|
|
174
|
+
assert.ok(titles[1].includes('memPrivateTitle'), 'the private card lost its title')
|
|
175
|
+
assert.ok(titles[2].includes('memGlobalTitle'), 'the global card lost its title')
|
|
176
|
+
assert.ok(titles[3].includes('memBothTitle'), 'the both card lost its title')
|
|
177
|
+
// Every card carries what happens next, not just a label.
|
|
178
|
+
assert.ok(titles[0].includes('memOffDesc') && titles[1].includes('memPrivateDesc'))
|
|
179
|
+
assert.ok(titles[2].includes('memGlobalDesc') && titles[3].includes('memBothDesc'))
|
|
180
|
+
assert.ok(!cards.some((card) => card.props.disabled), 'a permissive folder disabled a card')
|
|
181
|
+
|
|
182
|
+
let dialog = null
|
|
183
|
+
walk(tree, (node) => { if (node.props?.role === 'dialog') dialog = node })
|
|
184
|
+
assert.ok(dialog, 'the prompt has no dialog semantics')
|
|
185
|
+
assert.equal(dialog.props['aria-modal'], 'true')
|
|
186
|
+
|
|
187
|
+
const text = treeText(tree)
|
|
188
|
+
for (const key of ['memoryPromptBody', 'domain', 'domainPromptHint', 'advanced', 'chooseLibrary', 'createLibrary', 'rememberChoice', 'later', 'confirm']) {
|
|
189
|
+
assert.ok(text.includes(key), 'the prompt lost ' + key)
|
|
190
|
+
}
|
|
191
|
+
// The dismissed flag is not set yet, so nothing was committed behind the user.
|
|
192
|
+
assert.equal(calls.length, 0, 'the prompt wrote a decision before the user answered')
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
test('a folder that switched a layer off greys the cards that need it', async () => {
|
|
196
|
+
const blocked = {
|
|
197
|
+
...OPEN_FOLDER,
|
|
198
|
+
folder: { scopeKey: '/ws/demo', useLibrary: false, useGlobal: false },
|
|
199
|
+
}
|
|
200
|
+
const { tree } = await settle(renderGate(blocked))
|
|
201
|
+
const cards = cardsOf(tree)
|
|
202
|
+
assert.equal(cards.length, 4)
|
|
203
|
+
assert.ok(!cards[0].props.disabled, 'the no-memory card was disabled by the folder')
|
|
204
|
+
for (const card of cards.slice(1)) {
|
|
205
|
+
assert.equal(card.props.disabled, true, 'a blocked layer stayed clickable')
|
|
206
|
+
assert.ok(treeText(card.props.children).includes('memBlockedByFolder'), 'a disabled card did not say why')
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('a folder with memory switched off binds silently instead of asking', async () => {
|
|
211
|
+
const off = {
|
|
212
|
+
...OPEN_FOLDER,
|
|
213
|
+
shouldPrompt: false,
|
|
214
|
+
folder: { scopeKey: '/ws/demo', useLibrary: false, useGlobal: false },
|
|
215
|
+
}
|
|
216
|
+
const { tree } = await settle(renderGate(off))
|
|
217
|
+
assert.equal(tree, null, 'the prompt appeared for a folder whose memory is off')
|
|
218
|
+
assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope'])
|
|
219
|
+
assert.equal(calls[0].payload.createLibrary, false, 'a library was created for a folder with memory off')
|
|
220
|
+
const scope = calls[1].payload
|
|
221
|
+
assert.equal(scope.useLibrary, false)
|
|
222
|
+
assert.equal(scope.useGlobal, false)
|
|
223
|
+
})
|
|
224
|
+
// ---- the card is the answer; the sidebar just draws it -----------------------
|
|
225
|
+
// A card names a pair of switches, and those two switches are exactly what the
|
|
226
|
+
// sidebar renders afterwards. Writing only one of them (or, as the confirm
|
|
227
|
+
// branch once did, only the library) let ensureSession fill the other from the
|
|
228
|
+
// folder policy — which is how "private memory only" came back with both boxes
|
|
229
|
+
// ticked. These cases hold the three ideas together: card, binding, sidebar.
|
|
230
|
+
|
|
231
|
+
const CARD_ANSWERS = [
|
|
232
|
+
{ key: 'memOffTitle', layers: { useLibrary: false, useGlobal: false }, createLibrary: false },
|
|
233
|
+
{ key: 'memPrivateTitle', layers: { useLibrary: true, useGlobal: false }, createLibrary: true },
|
|
234
|
+
{ key: 'memGlobalTitle', layers: { useLibrary: false, useGlobal: true }, createLibrary: false },
|
|
235
|
+
{ key: 'memBothTitle', layers: { useLibrary: true, useGlobal: true }, createLibrary: true },
|
|
236
|
+
]
|
|
237
|
+
|
|
238
|
+
// Re-run the component with the current hook state but WITHOUT replaying the
|
|
239
|
+
// mount effect: the effect re-fetches the state and would reset the card the
|
|
240
|
+
// test just clicked.
|
|
241
|
+
function paint(element) {
|
|
242
|
+
cursor = 0
|
|
243
|
+
effects = []
|
|
244
|
+
return element.type(element.props)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findNode(tree, predicate) {
|
|
248
|
+
let hit = null
|
|
249
|
+
walk(tree, (node) => { if (!hit && predicate(node)) hit = node })
|
|
250
|
+
return hit
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
254
|
+
|
|
255
|
+
async function answerCard(cardKey) {
|
|
256
|
+
const element = renderGate(OPEN_FOLDER)
|
|
257
|
+
const { tree } = await settle(element)
|
|
258
|
+
const card = cardsOf(tree).find((node) => treeText(node.props.children).includes(cardKey))
|
|
259
|
+
assert.ok(card, 'the prompt has no card ' + cardKey)
|
|
260
|
+
card.props.onClick()
|
|
261
|
+
const painted = paint(element)
|
|
262
|
+
const confirm = findNode(painted, (node) => node.props?.key === 'ok' && typeof node.props.onClick === 'function')
|
|
263
|
+
assert.ok(confirm, 'the prompt has no confirm button')
|
|
264
|
+
assert.equal(confirm.props.disabled, false, 'confirm was disabled for ' + cardKey)
|
|
265
|
+
await confirm.props.onClick()
|
|
266
|
+
await tick()
|
|
267
|
+
await tick()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
for (const { key, layers, createLibrary } of CARD_ANSWERS) {
|
|
271
|
+
test('answering ' + key + ' writes both switches, not just the library', async () => {
|
|
272
|
+
await answerCard(key)
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
calls.map((call) => call.endpoint),
|
|
275
|
+
['initSession', 'setSessionScope', 'setDomain'],
|
|
276
|
+
key + ' did not bind first, scope second, and domain last',
|
|
277
|
+
)
|
|
278
|
+
const init = calls[0].payload
|
|
279
|
+
assert.equal(init.createLibrary, createLibrary, key + ' created the wrong library')
|
|
280
|
+
const scope = calls[1].payload
|
|
281
|
+
assert.equal(scope.useLibrary, layers.useLibrary, key + ' left the private switch to the folder policy')
|
|
282
|
+
assert.equal(scope.useGlobal, layers.useGlobal, key + ' left the global switch to the folder policy')
|
|
283
|
+
assert.equal(calls[2].payload.domain, 'rp', key + ' did not write the default domain')
|
|
284
|
+
})
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// The remembered answer has to mean the same thing as the card did. This branch
|
|
288
|
+
// used to write only the library (or only the layers), so the answer the user
|
|
289
|
+
// gave and the two boxes the sidebar drew could differ.
|
|
290
|
+
const REMEMBERED = [
|
|
291
|
+
['off', { useLibrary: false, useGlobal: false }, false],
|
|
292
|
+
['auto', { useLibrary: true, useGlobal: false }, true],
|
|
293
|
+
['global', { useLibrary: false, useGlobal: true }, false],
|
|
294
|
+
['both', { useLibrary: true, useGlobal: true }, true],
|
|
295
|
+
]
|
|
296
|
+
|
|
297
|
+
for (const [preference, layers, createLibrary] of REMEMBERED) {
|
|
298
|
+
test('a remembered "' + preference + '" answer is applied as both switches', async () => {
|
|
299
|
+
const dismissed = {
|
|
300
|
+
...OPEN_FOLDER,
|
|
301
|
+
settings: { newSessionPromptDismissed: true, newSessionDefault: preference },
|
|
302
|
+
}
|
|
303
|
+
const { tree } = await settle(renderGate(dismissed))
|
|
304
|
+
assert.equal(tree, null, 'a remembered answer still opened the prompt')
|
|
305
|
+
assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope'])
|
|
306
|
+
assert.equal(calls[0].payload.createLibrary, createLibrary, preference + ' created the wrong library')
|
|
307
|
+
assert.equal(calls[1].payload.useLibrary, layers.useLibrary, preference + ' lost its private switch')
|
|
308
|
+
assert.equal(calls[1].payload.useGlobal, layers.useGlobal, preference + ' lost its global switch')
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
test('the advanced library pick changes where memory lives, not which layers', async () => {
|
|
313
|
+
const element = renderGate(OPEN_FOLDER)
|
|
314
|
+
const { tree } = await settle(element)
|
|
315
|
+
const card = cardsOf(tree).find((node) => treeText(node.props.children).includes('memPrivateTitle'))
|
|
316
|
+
card.props.onClick()
|
|
317
|
+
let painted = paint(element)
|
|
318
|
+
const radios = []
|
|
319
|
+
walk(painted, (node) => {
|
|
320
|
+
if (node.props?.type === 'radio' && node.props?.name === 'memu-library') radios.push(node)
|
|
321
|
+
})
|
|
322
|
+
assert.equal(radios.length, 3, 'the advanced block lost its library radios')
|
|
323
|
+
// "create a new library" with an empty name cannot be confirmed yet.
|
|
324
|
+
radios[2].props.onChange({ target: { checked: true } })
|
|
325
|
+
painted = paint(element)
|
|
326
|
+
const blocked = findNode(painted, (node) => node.props?.key === 'ok')
|
|
327
|
+
assert.equal(blocked.props.disabled, true, 'an empty library name was confirmable')
|
|
328
|
+
// Pointing the private layer at the folder's existing library must keep the
|
|
329
|
+
// card's answer intact — this is the click that used to erase it.
|
|
330
|
+
radios[1].props.onChange({ target: { checked: true } })
|
|
331
|
+
painted = paint(element)
|
|
332
|
+
const confirm = findNode(painted, (node) => node.props?.key === 'ok')
|
|
333
|
+
assert.equal(confirm.props.disabled, false, 'a chosen existing library could not be confirmed')
|
|
334
|
+
await confirm.props.onClick()
|
|
335
|
+
await tick()
|
|
336
|
+
await tick()
|
|
337
|
+
assert.equal(calls[0].payload.libraryId, 'lib-1', 'the picked library was not bound')
|
|
338
|
+
assert.equal('createLibrary' in calls[0].payload, false, 'picking a library created another one')
|
|
339
|
+
assert.equal(calls[1].payload.useLibrary, true, 'the pick overwrote the private switch')
|
|
340
|
+
assert.equal(calls[1].payload.useGlobal, false, 'the pick turned the global layer back on')
|
|
341
|
+
assert.equal(calls[2].payload.domain, 'rp', 'the library pick lost the domain')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
// The domain selector is the point of this prompt: an RP workspace used for a
|
|
345
|
+
// coding session must not silently keep RP prompts and RP memory filtering.
|
|
346
|
+
test('the prompt defaults the domain to RP and can switch it to Coding', async () => {
|
|
347
|
+
const element = renderGate(OPEN_FOLDER)
|
|
348
|
+
const { tree } = await settle(element)
|
|
349
|
+
const select = findNode(tree, (node) => node.type === 'select')
|
|
350
|
+
assert.ok(select, 'the prompt has no domain selector')
|
|
351
|
+
assert.equal(select.props.value, 'rp', 'the domain did not default to the binding default')
|
|
352
|
+
assert.deepEqual(select.props.children.map((option) => option.props.value), ['rp', 'coding', 'general', 'mixed'])
|
|
353
|
+
select.props.onChange({ target: { value: 'coding' } })
|
|
354
|
+
const painted = paint(element)
|
|
355
|
+
const confirm = findNode(painted, (node) => node.props?.key === 'ok')
|
|
356
|
+
await confirm.props.onClick()
|
|
357
|
+
await tick()
|
|
358
|
+
await tick()
|
|
359
|
+
assert.deepEqual(calls.map((call) => call.endpoint), ['initSession', 'setSessionScope', 'setDomain'])
|
|
360
|
+
assert.equal(calls[2].payload.domain, 'coding', 'the selected Coding domain was not written')
|
|
361
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { spawnSync } from 'node:child_process'
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import os from 'node:os'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import { fileURLToPath } from 'node:url'
|
|
8
|
+
|
|
9
|
+
// H1 audit item: the published tarball used to carry no tests/ at all, so
|
|
10
|
+
// `npm test` inside an installed package reported "tests 0 / pass 0" and exited
|
|
11
|
+
// 0 while `npm run check` died with ENOENT on the missing tests/ directory.
|
|
12
|
+
// These assertions pin both halves of the fix: tests/ ships, and check-all.mjs
|
|
13
|
+
// treats a legitimately absent directory as skip instead of a crash.
|
|
14
|
+
|
|
15
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const REPO = path.resolve(HERE, '..')
|
|
17
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(REPO, 'package.json'), 'utf8'))
|
|
18
|
+
|
|
19
|
+
// A directory may only be skipped when the file that would have been read is
|
|
20
|
+
// really absent; every shipped directory must still be syntax-checked.
|
|
21
|
+
function fakeInstall({ dirs }) {
|
|
22
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'package-shipping-'))
|
|
23
|
+
fs.mkdirSync(path.join(root, 'scripts'), { recursive: true })
|
|
24
|
+
for (const name of ['check-all.mjs', 'check-doc-drift.mjs', 'check-i18n.mjs']) {
|
|
25
|
+
fs.copyFileSync(path.join(REPO, 'scripts', name), path.join(root, 'scripts', name))
|
|
26
|
+
}
|
|
27
|
+
for (const [rel, body] of Object.entries(dirs)) {
|
|
28
|
+
const abs = path.join(root, rel)
|
|
29
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true })
|
|
30
|
+
fs.writeFileSync(abs, body)
|
|
31
|
+
}
|
|
32
|
+
// Bounds check reads these two before it reaches the i18n stage; they must
|
|
33
|
+
// exist for the run to get past the syntax loop at all.
|
|
34
|
+
fs.writeFileSync(path.join(root, 'AGENTS.md'), '# index\n')
|
|
35
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fake-install', version: '0.0.0' }))
|
|
36
|
+
return root
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runCheckAll(root) {
|
|
40
|
+
return spawnSync(process.execPath, [path.join('scripts', 'check-all.mjs')], { cwd: root, encoding: 'utf8' })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('package.json ships tests/ so the packaged suite is not silently empty', () => {
|
|
44
|
+
assert.ok(Array.isArray(pkg.files), 'package.json needs a files whitelist')
|
|
45
|
+
assert.ok(pkg.files.includes('tests'), 'files must include "tests" or npm test finds 0 test files')
|
|
46
|
+
// The suite is only runnable if its harness (loader + stubs) travels with it.
|
|
47
|
+
for (const rel of ['loader-register.mjs', 'loader.mjs', 'stubs/dsh-llm.js']) {
|
|
48
|
+
assert.ok(fs.existsSync(path.join(REPO, 'tests', rel)), 'tests/' + rel + ' must ship with the package')
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('npm test glob still points at the shipped test files', () => {
|
|
53
|
+
assert.match(String(pkg.scripts?.test || ''), /tests\/\*\.test\.mjs/, 'test script must target tests/*.test.mjs')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('check-all.mjs skips a missing tests/ directory instead of crashing', () => {
|
|
57
|
+
const root = fakeInstall({ dirs: { 'lib/probe.js': 'export const ok = 1\n' } })
|
|
58
|
+
try {
|
|
59
|
+
const result = runCheckAll(root)
|
|
60
|
+
const stdout = String(result.stdout || '')
|
|
61
|
+
const stderr = String(result.stderr || '')
|
|
62
|
+
assert.ok(!/ENOENT/.test(stderr), 'a missing tests/ directory must not surface as ENOENT: ' + stderr.slice(0, 400))
|
|
63
|
+
assert.ok(!/at file:\/\//.test(stderr), 'check-all.mjs must not die with a stack trace: ' + stderr.slice(0, 400))
|
|
64
|
+
assert.match(stdout, /skip tests\//, 'the absent directory must be reported as skipped')
|
|
65
|
+
assert.match(stdout, /ok {3}lib\/probe\.js/, 'syntax checking must still run for the directories that exist')
|
|
66
|
+
// Reaching a later stage proves the loop did not abort the whole script.
|
|
67
|
+
assert.match(stdout, /i18n/)
|
|
68
|
+
} finally {
|
|
69
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test('check-all.mjs still checks lib/ and scripts/ when tests/ is present', () => {
|
|
74
|
+
const root = fakeInstall({ dirs: { 'lib/probe.js': 'export const ok = 1\n', 'tests/probe.test.mjs': '// x\n' } })
|
|
75
|
+
try {
|
|
76
|
+
const result = runCheckAll(root)
|
|
77
|
+
const stdout = String(result.stdout || '')
|
|
78
|
+
assert.match(stdout, /ok {3}lib\/probe\.js/)
|
|
79
|
+
assert.match(stdout, /ok {3}scripts\/check-all\.mjs/)
|
|
80
|
+
assert.doesNotMatch(stdout, /skip tests\//)
|
|
81
|
+
} finally {
|
|
82
|
+
fs.rmSync(root, { recursive: true, force: true })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import test from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
// Attach tests use fake agents/fiber and never touch the real host. The temp home is
|
|
8
|
+
// still set so any incidental state read/write from the attached listeners stays in
|
|
9
|
+
// os.tmpdir(), matching the rest of the suite.
|
|
10
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'dsh-memu-memory-preset-'))
|
|
11
|
+
process.env.DSH_MEMU_HOME = home
|
|
12
|
+
|
|
13
|
+
test.after(() => {
|
|
14
|
+
try { fs.rmSync(home, { recursive: true, force: true }) } catch {}
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
import { attachPresetAgents, registerPresetAttachment, sameScopeTree } from '../lib/preset-scope.js'
|
|
18
|
+
|
|
19
|
+
// CORDIS FIBERS
|
|
20
|
+
// =============
|
|
21
|
+
// The host instance of this plugin registers its pre-step listener on the host
|
|
22
|
+
// composition's context. A conversation whose preset lists its own plugins lives in a
|
|
23
|
+
// separate fiber tree, so that listener never fires and the conversation gets no memory.
|
|
24
|
+
// `sameScopeTree` is the whole guard against the opposite mistake — attaching a second
|
|
25
|
+
// listener to a conversation the host one already covers, which would inject twice.
|
|
26
|
+
|
|
27
|
+
function fiber(parent = null) {
|
|
28
|
+
const node = { parent: parent ? { fiber: parent } : null }
|
|
29
|
+
node.parent = parent ? { fiber: parent } : { fiber: node }
|
|
30
|
+
return node
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function chain(...nodes) {
|
|
34
|
+
for (let i = 0; i < nodes.length - 1; i += 1) nodes[i].parent = { fiber: nodes[i + 1] }
|
|
35
|
+
const last = nodes[nodes.length - 1]
|
|
36
|
+
last.parent = { fiber: last }
|
|
37
|
+
return nodes[0]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function fakeCtx({ agents = null, fiber: ownFiber = null, logger = null } = {}) {
|
|
41
|
+
const handlers = []
|
|
42
|
+
const injected = []
|
|
43
|
+
return {
|
|
44
|
+
fiber: ownFiber,
|
|
45
|
+
logger,
|
|
46
|
+
injected,
|
|
47
|
+
inject(...args) {
|
|
48
|
+
injected.push(args)
|
|
49
|
+
},
|
|
50
|
+
get(name) {
|
|
51
|
+
if (name === 'agents') return agents
|
|
52
|
+
return undefined
|
|
53
|
+
},
|
|
54
|
+
on(event, fn) {
|
|
55
|
+
handlers.push({ event, fn })
|
|
56
|
+
return () => {
|
|
57
|
+
const index = handlers.findIndex((entry) => entry.fn === fn)
|
|
58
|
+
if (index >= 0) handlers.splice(index, 1)
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
handlers,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function fakeAgents(list) {
|
|
66
|
+
return { list: () => list.slice() }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('an agent inside our own fiber chain is already covered', () => {
|
|
70
|
+
const host = fiber()
|
|
71
|
+
const child = fiber(host)
|
|
72
|
+
assert.equal(sameScopeTree({ fiber: child }, { fiber: host }), true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('an agent in a sibling tree is not covered', () => {
|
|
76
|
+
const host = fiber()
|
|
77
|
+
const presetRoot = fiber()
|
|
78
|
+
assert.equal(sameScopeTree({ fiber: presetRoot }, { fiber: host }), false)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('a missing fiber on either side is "unknown", never "covered"', () => {
|
|
82
|
+
assert.equal(sameScopeTree({}, { fiber: fiber() }), false)
|
|
83
|
+
assert.equal(sameScopeTree({ fiber: fiber() }, {}), false)
|
|
84
|
+
assert.equal(sameScopeTree(undefined, undefined), false)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('a deep chain terminates and finds the host fiber', () => {
|
|
88
|
+
const host = fiber()
|
|
89
|
+
const deep = chain(fiber(), fiber(), fiber(), host)
|
|
90
|
+
assert.equal(sameScopeTree({ fiber: deep }, { fiber: host }), true)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test('only preset conversations get attached, and only once', () => {
|
|
94
|
+
const host = fiber()
|
|
95
|
+
const presetAgent = fakeCtx({ fiber: fiber() })
|
|
96
|
+
const normalAgent = fakeCtx({ fiber: fiber(host) })
|
|
97
|
+
const logs = []
|
|
98
|
+
const agents = fakeAgents([
|
|
99
|
+
{ ctx: presetAgent, session: { header: { id: 'preset-1', origin: 'main' } } },
|
|
100
|
+
{ ctx: normalAgent, session: { header: { id: 'normal-1', origin: 'main' } } },
|
|
101
|
+
])
|
|
102
|
+
const ctx = fakeCtx({ agents, fiber: host, logger: { warn: (m) => logs.push(m) } })
|
|
103
|
+
|
|
104
|
+
assert.equal(attachPresetAgents(ctx, { log: (m) => logs.push(m) }), 1)
|
|
105
|
+
assert.equal(presetAgent.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
|
|
106
|
+
assert.equal(normalAgent.handlers.length, 0)
|
|
107
|
+
|
|
108
|
+
// Second pass over the same registry must not stack a second listener.
|
|
109
|
+
assert.equal(attachPresetAgents(ctx, { log: (m) => logs.push(m) }), 0)
|
|
110
|
+
assert.equal(presetAgent.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
|
|
111
|
+
|
|
112
|
+
assert.ok(logs.some((line) => line.includes('attached memory injection to preset conversation preset-1')))
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('subagents and agents without a context are left alone, with a signal', () => {
|
|
116
|
+
const host = fiber()
|
|
117
|
+
const subAgent = fakeCtx({ fiber: fiber() })
|
|
118
|
+
const logs = []
|
|
119
|
+
const agents = fakeAgents([
|
|
120
|
+
{ ctx: subAgent, session: { header: { id: 'sub-1', origin: 'subagent' } } },
|
|
121
|
+
{ session: { header: { id: 'no-ctx', origin: 'main' } } },
|
|
122
|
+
])
|
|
123
|
+
const ctx = fakeCtx({ agents, fiber: host, logger: { warn: (line) => logs.push(String(line)) } })
|
|
124
|
+
assert.equal(attachPresetAgents(ctx, { log: (line) => logs.push(String(line)) }), 0)
|
|
125
|
+
assert.equal(subAgent.handlers.length, 0)
|
|
126
|
+
// Without this line the adaptation fails exactly like "nothing happened".
|
|
127
|
+
assert.ok(logs.some((line) => line.includes('agents.list() exposed no agent context')))
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('a conversation that goes away is detached', () => {
|
|
131
|
+
const host = fiber()
|
|
132
|
+
const presetAgent = fakeCtx({ fiber: fiber() })
|
|
133
|
+
const live = [{ ctx: presetAgent, session: { header: { id: 'preset-1', origin: 'main' } } }]
|
|
134
|
+
const ctx = fakeCtx({ agents: fakeAgents(live), fiber: host })
|
|
135
|
+
assert.equal(attachPresetAgents(ctx), 1)
|
|
136
|
+
assert.equal(presetAgent.handlers.length, 4)
|
|
137
|
+
|
|
138
|
+
live.length = 0
|
|
139
|
+
assert.equal(attachPresetAgents(ctx), 0)
|
|
140
|
+
assert.equal(presetAgent.handlers.length, 0)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a host without an agents service is not an error', () => {
|
|
144
|
+
const ctx = fakeCtx({ agents: null, fiber: fiber() })
|
|
145
|
+
assert.equal(attachPresetAgents(ctx), 0)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('the attach loop runs immediately and can be stopped', () => {
|
|
149
|
+
const host = fiber()
|
|
150
|
+
const presetAgent = fakeCtx({ fiber: fiber() })
|
|
151
|
+
const ctx = fakeCtx({ agents: fakeAgents([{ ctx: presetAgent, session: { header: { id: 'p', origin: 'main' } } }]), fiber: host })
|
|
152
|
+
const stop = registerPresetAttachment(ctx, { intervalMs: 60_000 })
|
|
153
|
+
assert.equal(presetAgent.handlers.length, 4)
|
|
154
|
+
stop()
|
|
155
|
+
assert.equal(presetAgent.handlers.length, 0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// agents.list() is documented in two shapes on the host: the registry record and a
|
|
159
|
+
// bare agent. A bare entry may still have the model-facing context on the record that
|
|
160
|
+
// agents.get() returns. All three shapes must attach to the same agent-owned context.
|
|
161
|
+
test('records, bare agents and agents.get() fallbacks all provide the attach context', () => {
|
|
162
|
+
const host = fiber()
|
|
163
|
+
const recordCtx = fakeCtx({ fiber: fiber() })
|
|
164
|
+
const bareCtx = fakeCtx({ fiber: fiber() })
|
|
165
|
+
const fallbackCtx = fakeCtx({ fiber: fiber() })
|
|
166
|
+
const live = [
|
|
167
|
+
{ ctx: recordCtx, session: { header: { id: 'record-1', origin: 'main' } } },
|
|
168
|
+
{ ctx: bareCtx, session: { header: { id: 'bare-1', origin: 'main' } } },
|
|
169
|
+
{ session: { header: { id: 'fallback-1', origin: 'main' } } },
|
|
170
|
+
]
|
|
171
|
+
const agents = {
|
|
172
|
+
list: () => live.slice(),
|
|
173
|
+
get: (id) => (id === 'fallback-1' ? { ctx: fallbackCtx } : undefined),
|
|
174
|
+
}
|
|
175
|
+
const ctx = fakeCtx({ agents, fiber: host })
|
|
176
|
+
assert.equal(attachPresetAgents(ctx), 3)
|
|
177
|
+
for (const agentCtx of [recordCtx, bareCtx, fallbackCtx]) {
|
|
178
|
+
assert.equal(agentCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
|
|
179
|
+
assert.deepEqual(agentCtx.injected, [], 'a preset gets the message path, not a system prompt section')
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
// The plugin must not pretend that attaching an injection listener mounts tools. The
|
|
184
|
+
// tool surface is owned by the preset bundle: an explicitly listed preset has memu_*
|
|
185
|
+
// tools, an unlisted one has none. Both get the listener (the guide text tells the
|
|
186
|
+
// unlisted model to ignore the rules), and neither tool list is edited here.
|
|
187
|
+
test('the tool surface follows the preset bundle, while injection attaches to both', () => {
|
|
188
|
+
const host = fiber()
|
|
189
|
+
const listedCtx = fakeCtx({ fiber: fiber() })
|
|
190
|
+
const unlistedCtx = fakeCtx({ fiber: fiber() })
|
|
191
|
+
const listedTools = ['memu_private_recall', 'memu_private_remember', 'memu_status']
|
|
192
|
+
const unlistedTools = ['bash', 'read_file']
|
|
193
|
+
const live = [
|
|
194
|
+
{ ctx: listedCtx, session: { header: { id: 'listed-1', origin: 'main' } }, toolNames: listedTools.slice() },
|
|
195
|
+
{ ctx: unlistedCtx, session: { header: { id: 'unlisted-1', origin: 'main' } }, toolNames: unlistedTools.slice() },
|
|
196
|
+
]
|
|
197
|
+
const ctx = fakeCtx({ agents: fakeAgents(live), fiber: host })
|
|
198
|
+
assert.equal(attachPresetAgents(ctx), 2)
|
|
199
|
+
assert.equal(listedCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
|
|
200
|
+
assert.equal(unlistedCtx.handlers.filter((entry) => entry.event === 'agent/pre-step').length, 1)
|
|
201
|
+
assert.deepEqual(live[0].toolNames, listedTools, 'listing the plugin is what makes memu_* tools exist')
|
|
202
|
+
assert.deepEqual(live[1].toolNames, unlistedTools, 'injection must not fabricate memu_* tools')
|
|
203
|
+
assert.equal(live[1].toolNames.some((name) => name.startsWith('memu_')), false)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('a throwing agents service is contained', () => {
|
|
207
|
+
const ctx = fakeCtx({
|
|
208
|
+
agents: { list() { throw new Error('registry unavailable') } },
|
|
209
|
+
fiber: fiber(),
|
|
210
|
+
})
|
|
211
|
+
assert.equal(attachPresetAgents(ctx), 0)
|
|
212
|
+
})
|