Files
HologramAgent-DSH-Memory-Port/test/loader.test.js
jianf b73607ed02 feat(memory-b): F3/F4 四态派生计数 + D3 工具面 + D5 提示渲染
顺序遵守裁定:F2 过滤先落地(上一条 425c441),这一条才加计数。
先做计数会数出一个恒为 0 的假指标。

四态定义与归属
  noVector           vector 为空                     回填
  unknownSpace       有 vector 但 fp 为空            重嵌(说不出来属空间)
  spaceMismatch      有 vector、有 fp ≠ active        重嵌到当前空间
  dimensionMismatch  有 vector 但宽度不对             改 provider 配置(F4:与上面分开)
  embedFailure       本次调用拿不到向量                ← 不在这里,它是单次事件不是库状态

F3/F4 判据(4 条行为 + 3 条变异)
  F3   四态从库派生;skipped 非零(实测 skipped=2,不是恒 0)
  F4   dim 与 fp 分开计数,且不互相重复计入
  F3   改库/删库后计数跟着变(证明不是内存累加,D4)
  F3   skipped 在符号路也计(fusion.go:495 同款:只测向量路等于没测)
  F3†  内存累加器实现 ⇒ 「跟随数据库」断言红
  F3†symbol 计数只挂在向量路 ⇒ 符号路的 skipped 断言红
  F4†  把 dim 并进 spaceMismatch ⇒ 分桶断言红

D4:从 DB 派生,不累加
store.blockStates(activeFp, activeDim) 每次扫行计算,无缓存无累加。
判据用「绕过插件直接 UPDATE / DELETE,计数必须跟着变」证明。

D3/D5:两个消费者,一个来源
- renderIndex() 与 stablemem_stats 都调 blockStates(),不各留一份
- D5 的提示读同一个 unavailable 计数(noVector+unknownSpace+
  spaceMismatch+dimensionMismatch),说明「N 条暂不可语义召回,
  仍可按精确文本检索」
- D3 判据含无空间/有空间两例:无 active space 时 mismatch 必然为 0
  (没有参照物),这条我第一版判据写错了,已按实际语义修正

★ 一处新工具引入的回归(已修)
新增 stablemem_stats 时把工具名硬编码,第二个实例挂载时撞名
"tool 'stablemem_stats' is already registered" —— 与之前 prompt context
名同型:isolate() 不分区工具名。已改为可从 config.statsToolName 覆写,
loader 测试同步传参。

★ 一处「注册了但跑不到」的坑(已修)
registerTools 里我用了 return ctx.tools.register(...),后续第二个
register 永远不可达,但文件读起来像注册了两个。已去掉 return 并注释。

验证
- npm test 143/143(138 + F3/F4 四条 + 变异三条 + D3/D5 两条)
- npm run check 10/10
- 实测数字(5 块构造库):
    blockStates('fp-cur',3) = {total:5, active:1, noVector:1,
      unknownSpace:1, spaceMismatch:1, dimensionMismatch:1}
    recall("缓存", fp-cur) → 返回 1 条, skipped=2
    recall("4324", fp-cur) → 返回 2 条, skipped=2
- 干净副本实数见后续消息
2026-10-30 15:59:22 +08:00

145 lines
5.9 KiB
JavaScript

/**
* Real-loader integration test: mount the plugin the way a dsh profile does.
*
* This is the one test that proves the *packaging* is right — `name`/`inject`/
* `apply` exports, the `defineTool` contract, and `systemPrompt.context` all
* survive an actual `cordis-plugin-loader` mount rather than a hand-built app.
*
* If `cordis-plugin-loader` cannot be resolved (bare install, no dsh), the test
* skips rather than fails: the plugin itself does not depend on the loader.
*
* @module dsh-memory/test/loader
*/
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { Context } from '@deepseek-ai/cordis'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import * as plugin from '../lib/index.js'
/** @returns {Promise<unknown>} the loader module, or null when unavailable */
async function tryLoadLoader() {
try {
return await import('@deepseek-ai/cordis-plugin-loader')
} catch {
return null
}
}
test('plugin exports the shape the cordis loader requires', () => {
// A loader entry is only mountable when these three exist.
assert.equal(typeof plugin.apply, 'function', 'apply(ctx, config) is required')
assert.equal(typeof plugin.name, 'string', 'name is required for diagnostics')
assert.ok(Array.isArray(plugin.inject), 'inject must be an array')
assert.deepEqual(plugin.inject, ['systemPrompt', 'tools'])
// The default export must be the same plugin (loaders unwrap `default`).
assert.equal(plugin.default.name, plugin.name)
assert.equal(plugin.default.apply, plugin.apply)
})
test('mounting twice on the same app fails loudly (service name is taken)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-dup-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
plugin.apply(ctx, { path: join(dir, 'a.db') })
// 'stablemem' is provided by the first instance; cordis refuses a second
// implementation in the same realm — which is exactly why a preset that
// wants its own implementation must isolate the service name
// (docs/shadow-context.md §5.2).
assert.throws(
() => plugin.apply(ctx, { path: join(dir, 'b.db') }),
/stablemem/,
'duplicate service registration must be rejected',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('an isolated realm can host its own stablemem (the preset route)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-iso-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const outer = plugin.apply(ctx, { path: join(dir, 'outer.db') })
// A preset subtree isolates the service name and mounts a second instance.
// Prompt-context names and tool names are NOT partitioned by isolate()
// (dsh-system-prompt/lib/index.js:204, dsh-tools duplicate check), so a
// second instance needs a distinct tool name. The prompt-context name is
// derived from the db path automatically.
const realm = ctx.isolate('stablemem')
const inner = plugin.apply(realm, {
path: join(dir, 'inner.db'),
toolName: 'stablemem_commit_alt',
statsToolName: 'stablemem_stats_alt',
})
// Both awaits are required: commit() embeds (async) before writing, and
// relying on "no embedder means it happens synchronously" is exactly the
// fragile assumption this guards against. Promise.all keeps them concurrent,
// which is what this test is about (two isolated realms writing in parallel).
await Promise.all([
outer.commit({ text: 'outer-only 端口 1111' }),
inner.commit({ text: 'inner-only 端口 2222' }),
])
assert.equal(outer.store.countBlocks(), 1, 'outer store isolated')
assert.equal(inner.store.countBlocks(), 1, 'inner store isolated')
assert.notEqual(outer.store.path, inner.store.path, 'separate databases')
// The isolated service is reachable through the realm context, and the two
// contexts resolve DIFFERENT implementations — that is the whole point.
const realmService = realm.stablemem
// cordis hands out a PROXY, so identity differs from the instance we built;
// compare behaviour, not references. (This proxy is also why StableMem keeps
// ordinary fields instead of #private ones.)
assert.equal(realmService.store.countBlocks(), 1, 'realm sees only its own block')
assert.equal(realmService.store.path, inner.store.path, 'realm resolves the inner instance')
assert.equal(ctx.stablemem.store.path, outer.store.path, 'root resolves the outer instance')
assert.equal(ctx.stablemem.store.countBlocks(), 1, 'outer store untouched by the realm')
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})
test('a real cordis loader can mount the plugin by specifier', async (t) => {
const loaderModule = await tryLoadLoader()
if (loaderModule === null) {
t.skip('cordis-plugin-loader not installed in this environment')
return
}
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-loader-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
try {
const pluginSpecifier = new URL('../lib/index.js', import.meta.url).href
const fiber = ctx.plugin(
{
name: pluginSpecifier,
inject: plugin.inject,
apply: plugin.apply,
},
{ path: join(dir, 'loaded.db') },
)
await fiber.await()
assert.ok(ctx.stablemem, 'the loaded plugin registered ctx.stablemem')
assert.ok(
ctx.tools.schemas().some((schema) => schema.name === 'stablemem_commit'),
'the loaded plugin registered its tool',
)
} finally {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
}
})