Files
HologramAgent-DSH-Memory-Port/test/plugin.test.js
jianf 114fae84aa feat(memory): MVP 阶段 A 切片 —— 块进 dsh、召回、进 prompt
按 docs/feasibility.md §7.1 阶段 A 落地最小可验证切片,
刻意不引入 embedding、不碰 L1 淘汰。

落地内容
- lib/store.js   BlockStore:node:sqlite 建 memory_blocks /
                 memory_block_edges / graph_centroid 三表 + FTS5 索引。
                 内容派生 id(blk_+sha256[:12],移植 distill.BlockID);
                 putBlock 走「空值不覆盖」,重复 commit 幂等。
- lib/recall.js  FTS5 bm25 + 精确串布尔置顶(移植 SymbolScore/fusion.go:427)、
                 单字符不成符号(abstain.go:115)、前缀特异性 tie-break、
                 CJK 2..4 字滑窗兜底(移植 cjkWindows/fusion.go:240)。
- lib/index.js   StableMem service + systemPrompt.context 常驻索引 +
                 agent/pre-step 按本轮输入召回注入 + stablemem_commit 工具。
- scripts/checkpoints.js  V1–V5 + 两条变异,对真实 cordis 服务跑。
- test/          26 个测试(store / recall / plugin wiring / real loader / isolate)。
- scripts/link-dsh.sh     把全局 dsh 包软链进 node_modules(插件本身零依赖)。

三个实测出来的 dsh 约束(都是先红了才发现的)
1. cordis service 不能用 JS #private 字段 —— ctx.<name> 是 proxy,
   访问私有字段抛 "Cannot read private member"。状态必须放普通属性。
2. isolate() 不隔离 prompt context 名与工具名(只隔离 service 查找)——
   所以工具名做成可配置,prompt context 名由 db 路径派生以保唯一。
3. systemPrompt.getContextOrder() 是分配器不是常量表,用命名槽位。

验证
- npm test  → 26/26 通过
- npm run check → 8/8 checkpoint 通过
  V1 sqlite/WAL/100 写回读;V2 能力分层(tier1 30/30、tier2 20/20,
  tier3/4 作为已知缺口显式记录);V3 AssembleContext 仅 [agent];
  V3b 注入落进批次且带 source.kind;V3b† 静默召回则判据红;
  V4 工具 schema+render 端到端;V5 fiber dispose 释放句柄并可重开;
  A5† 退化成包含匹配则区分度消失。

阶段 A exit criteria 全部满足(含 V3/V3b 分工结论写入 README)。
参考仓 /home/program/TrueAgent 未改动。
2026-10-30 09:44:42 +08:00

232 lines
7.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Plugin-wiring tests against **real** dsh services.
*
* These are the assertions that would silently rot if dsh changed a hook, so they
* run the actual `SystemPrompt` and `ToolRuntime` rather than stubs. The
* checkpoint script covers the same ground with human-readable output; this file
* is what CI should run.
*
* Run: node --test test/
*
* @module dsh-memory/test/plugin
*/
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 { createUserMessage } from '@deepseek-ai/dsh-llm'
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
import { apply as applyMemory, lastUserText, name as pluginName } from '../lib/index.js'
/**
* Build a real cordis app carrying the services this plugin injects.
* `ToolRuntime` declares `inject = ['systemPrompt']`, so prompt comes first.
* @returns {Promise<{ctx: Context, dir: string, done: () => void}>}
*/
async function app() {
const dir = mkdtempSync(join(tmpdir(), 'dsh-memory-plugin-'))
const ctx = new Context()
new SystemPrompt(ctx, {})
new ToolRuntime(ctx, {})
return {
ctx,
dir,
done: async () => {
await ctx.fiber.dispose()
rmSync(dir, { recursive: true, force: true })
},
}
}
test('lastUserText picks the last user message and joins only text blocks', () => {
const messages = [
{ role: 'user', content: [{ type: 'text', text: 'first' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'reply' }] },
{ role: 'user', content: [{ type: 'tool-result' }, { type: 'text', text: 'second' }] },
]
assert.equal(lastUserText(messages), 'second')
assert.equal(lastUserText([]), '')
assert.equal(lastUserText([{ role: 'assistant', content: [{ type: 'text', text: 'x' }] }]), '')
})
test('V3: systemPrompt.context exposes agent but no step messages', async () => {
const { ctx, done } = await app()
try {
let keys = []
ctx.systemPrompt.context({
name: 'probe:v3',
order: 210,
text: (context) => {
keys = Object.keys(context).sort()
return 'probe'
},
})
await ctx.systemPrompt.assemble({ agent: { id: 'a', session: {} } })
assert.ok(keys.includes('agent'), `AssembleContext must carry agent; got ${keys.join(',')}`)
// The negative assertion is the load-bearing one: if dsh ever adds the step
// messages here, recall could move to this hook and this slice's whole
// design note would be wrong.
for (const forbidden of ['messages', 'input', 'text']) {
assert.ok(!keys.includes(forbidden), `AssembleContext must NOT expose ${forbidden}`)
}
} finally {
await done()
}
})
test('V3b: pre-step injection adds one attributed message carrying the recall', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周', source: 'seed' })
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324 是谁在值班' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.kind, 'enter')
assert.equal(decision.messages.length, 2, 'input + one injected recall')
const injected = decision.messages[1]
assert.equal(injected.role, 'user', 'injected context is a user message')
assert.equal(injected.source.kind, pluginName)
assert.equal(injected.source.form, 'snapshot')
assert.ok(injected.content.map((b) => b.text ?? '').join('').includes('4324'))
} finally {
await done()
}
})
test('V3b mutation: with recall silenced the batch stays un-augmented', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db'), topK: 3 })
mem.commit({ text: '值班室分机号 4324,值班人 老周' })
mem.contextForStep = () => ''
const input = createUserMessage({
content: [{ type: 'text', text: '值班室分机号 4324' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'no injection when recall yields nothing')
} finally {
await done()
}
})
test('V3b: a rejected step is passed through untouched', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db') })
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'reject' }),
)
assert.equal(decision.kind, 'reject', 'a rejection must not be converted into an enter')
} finally {
await done()
}
})
test('V4: stablemem_commit registers with a schema and executes end to end', async () => {
const { ctx, dir, done } = await app()
try {
const mem = applyMemory(ctx, { path: join(dir, 'm.db') })
const schemas = ctx.tools.schemas()
const tool = schemas.find((entry) => entry.name === 'stablemem_commit')
assert.ok(tool, 'tool must be visible to the model')
assert.ok(tool.description.length > 0, 'tool must carry a description')
assert.ok(tool.parameters?.properties?.text, 'tool must declare its text parameter')
const result = await ctx.tools.execute({
callId: 'c1',
name: 'stablemem_commit',
arguments: { text: 'etcd 集群端口 2379', source: 'test' },
signal: new AbortController().signal,
})
assert.equal(result.isError, false, JSON.stringify(result))
assert.ok(String(result.value).includes('Committed'), result.value)
assert.equal(result.content[0].type, 'text', 'render must produce a text block')
assert.equal(mem.store.countBlocks(), 1)
} finally {
await done()
}
})
test('V5: disposing the fiber closes the store and releases the file', async () => {
const { ctx, dir, done } = await app()
const path = join(dir, 'm.db')
const mem = applyMemory(ctx, { path })
mem.commit({ text: 'V5 句柄释放块' })
assert.equal(mem.store.isOpen, true)
await ctx.fiber.dispose()
assert.equal(mem.store.isOpen, false, 'fiber disposal must close the handle')
// Strongest proof: reopen the same file for writing.
const { BlockStore } = await import('../lib/store.js')
const reopened = new BlockStore({ path }).open()
assert.equal(reopened.countBlocks(), 1)
reopened.close()
rmSync(dir, { recursive: true, force: true })
void done
})
test('config.inject:false registers the tool but no pre-step listener', async () => {
const { ctx, dir, done } = await app()
try {
applyMemory(ctx, { path: join(dir, 'm.db'), inject: false })
const input = createUserMessage({
content: [{ type: 'text', text: 'anything' }],
source: { kind: 'user' },
})
const decision = await ctx.waterfall(
'agent/pre-step',
{
agent: { id: 'a', session: {} },
messages: [input],
turn: 1,
step: 1,
signal: new AbortController().signal,
},
() => Promise.resolve({ kind: 'enter', messages: [input] }),
)
assert.equal(decision.messages.length, 1, 'injection must be off')
assert.ok(ctx.tools.schemas().some((s) => s.name === 'stablemem_commit'), 'tool still present')
} finally {
await done()
}
})