Files
HologramAgent-DSH-Memory-Port/test/loop.test.js
jianf c1f7e06b22 test(memory): 补 377e02c 的判据假绿 —— multiCarrier 名不符实,真多块零覆盖
`multiCarrier(texts, convention)` 名叫"多块",实际 `content: [{ type: 'text',
text: joined }]` 恒为 1 个 block。所以 S2a/S2b 读起来是"多块路径已覆盖",
实际一条都没走多块分支:

  M1 在 `const rendered = ...` 前插 `if (blocks.length > 1) return null`
     (即 V2 那个错误实现)→ 22 条判据 pass 20 / fail 0,全绿。

这正是上一轮 375→377e02c 留下的假绿:不是缺测试,是 helper 名字把人骗了。
名字夸大覆盖范围比没有测试更毒,因为它读起来像有。

改法(只动 test,lib/loop.js 零改动,sha256 f28c5564… 前后一致):

- `multiCarrier` → `singleBlockCarrier`,实现不动,JSDoc 写明
  "产出:1 个 text block 承载 N 个 section(lib/index.js:932 的真实形状)"
  并记下它曾叫 multiCarrier 这段历史。
- 新增 `trueMultiCarrier(texts, kind)`:N 个 text block,每个 block 一个
  section,rendered = texts.join('')(glued)。注释写明真多块没有 spaced 变体。
- S2a/S2b → singleBlockCarrier;S3 标题改成 "single content block carrying N
  sections != those sections …" 并改用 singleBlockCarrier。
- S2c/S2d → trueMultiCarrier(['a','b']),断言不变。

变异自证(两条都咬住,且撤净后 sha256 回到基线):
  M1 `if (blocks.length > 1) return null`   → S2c 红("got 1 message(s)")
  M2 删掉 glued 分支(只剩 `rendered !== spaced`)→ S2b 红("got 1")

对 5c6aaf5 的干净 archive 跑本判据:tests 22 / pass 10 / fail 12,
其中 S2c("got 1 message(s)")与 S2d("got 2")如实为红。
2026-11-04 18:32:16 +08:00

458 lines
21 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.

/**
* Behavioural contract for the loop that promotes snapshot sections.
*
* The loop only earns its keep if a promoted recall block reaches the SYSTEM
* prompt and does NOT reach history. These tests pin exactly that, plus the
* cases where it must keep its hands off.
*
* P1 a pure snapshot carrier message moves into `assembly.sections`.
* P2 the returned decision is a NEW object; the input is not mutated.
* P3 the promoted section carries `interpolate: false` — free text with
* `{{...}}` must not reach the strict interpolator.
* P4a a carrier whose content text equals its sections IS promoted (the real
* path: `lib/index.js:932` always attaches one text block); P4b the same
* message with differing content is NOT; P4c proves the split is decided
* by text equality, not by the presence of content.
* P5 a non-snapshot message is NOT promoted.
* P6 a name collision suffixes instead of overwriting or dropping.
* P7 `reject` decisions pass through untouched.
* P8 a decision with no `assembly` passes through untouched.
* P9 everything that is not promoted survives in `messages`, in order.
* P10 the class does not re-register the factory (no constructor / inject).
*
* @module dsh-memory/test/loop
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { HologramAgentLoop, promoteSnapshotSections } from '../lib/loop.js'
/** Build a user message shaped like a snapshot-style injector emits. */
function snapshotMessage(text, kind = 'memory') {
return {
role: 'user',
content: [],
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
}
}
/** Build a plain user message with real content. */
function plainMessage(text) {
return { role: 'user', content: [{ type: 'text', text }], source: { kind: 'user' } }
}
/** A minimal decision carrying an assembly with one existing section. */
function decisionWith(messages, sections = [{ name: 'base', text: 'base prompt' }]) {
return { kind: 'enter', messages, assembly: { sections, variables: {}, tools: [] } }
}
test('P1: a pure snapshot carrier is promoted into assembly.sections', () => {
const decision = decisionWith([plainMessage('what did I decide?'), snapshotMessage('note alpha')])
const out = promoteSnapshotSections(decision)
assert.equal(out.messages.length, 1)
assert.deepEqual(out.messages[0].content, [{ type: 'text', text: 'what did I decide?' }])
assert.equal(out.assembly.sections.length, 2)
const promoted = out.assembly.sections[1]
assert.equal(promoted.text, 'note alpha')
assert.ok(promoted.name.startsWith('memory'))
})
test('P2: the input decision is not mutated', () => {
const original = decisionWith([snapshotMessage('note alpha')])
const before = structuredClone(original)
promoteSnapshotSections(original)
assert.deepEqual(original, before)
})
test('P3: the promoted section opts out of interpolation', () => {
const out = promoteSnapshotSections(decisionWith([snapshotMessage('literal {{not-a-var}} here')]))
assert.equal(out.assembly.sections[1].interpolate, false)
})
test('P4a: a carrier whose content equals its sections IS promoted', () => {
// The old P4 asserted the opposite for every message carrying content, on the
// assumption that "content blocks present" means "the plugin wants the user to
// see this". That assumption is FALSE for the only producer that matters here:
// `lib/index.js:932` always injects exactly ONE text block whose text is
// `sections[0].text` verbatim — content and sections are the same string. So
// the old rule (`content non-empty ⇒ reject`) made `promoteSnapshotSections` a
// permanent no-op on the real recall path and left recall in user/message
// history, which is the bug this pair of tests exists to pin.
const aligned = {
role: 'user',
content: [{ type: 'text', text: 'recalled note' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
}
const out = promoteSnapshotSections(decisionWith([aligned]))
assert.equal(out.messages.length, 0, `content == sections must be promoted; got ${out.messages.length} message(s)`)
assert.equal(out.assembly.sections.length, 2, `expected base + promoted; got ${out.assembly.sections.length}`)
assert.equal(
out.assembly.sections[1].text,
'recalled note',
`promoted text must be the content text unchanged; got ${JSON.stringify(out.assembly.sections[1].text)}`,
)
})
test('P4b: a carrier whose content differs from its sections is NOT promoted', () => {
// The old P4 used 'visible' vs 'hidden' — content != sections — which is the
// side that must keep its hands off. That is exactly why it was green on the
// broken commit: it asserted the correct behaviour for the wrong reason and
// never covered the case that actually occurs (`content == sections`). It is
// kept here, correctly named, as one half of the pair.
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'visible' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
}
const out = promoteSnapshotSections(decisionWith([drifted]))
assert.equal(out.messages.length, 1, `content != sections must stay in history; got ${out.messages.length}`)
assert.equal(out.assembly.sections.length, 1, `no section may be added; got ${out.assembly.sections.length}`)
})
test('P4c: P4a and P4b are decided by TEXT, not by "has content at all"', () => {
// One decision holding both carriers: if the implementation matched on the
// mere presence of content, both would land on the same side and the
// messages/sections counts below could not both hold.
const aligned = {
role: 'user',
content: [{ type: 'text', text: 'recalled note' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'recalled note' }] },
}
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'visible' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] },
}
const out = promoteSnapshotSections(decisionWith([aligned, drifted]))
assert.deepEqual(out.messages, [drifted], 'only the mismatching carrier may survive')
assert.equal(out.assembly.sections.length, 2, `only the matching carrier may be promoted; got ${out.assembly.sections.length}`)
})
test('P5: a non-snapshot message is not promoted', () => {
const runtime = {
role: 'user',
content: [],
source: { kind: 'runtime-context', form: 'inline', sections: [{ name: 'x', text: 'y' }] },
}
const out = promoteSnapshotSections(decisionWith([runtime]))
assert.equal(out.messages.length, 1)
assert.equal(out.assembly.sections.length, 1)
})
test('P6: a name collision is suffixed, never dropped', () => {
const out = promoteSnapshotSections(
decisionWith([snapshotMessage('note', 'base')], [{ name: 'base', text: 'base prompt' }]),
)
assert.equal(out.assembly.sections.length, 2)
const names = out.assembly.sections.map((section) => section.name)
assert.equal(new Set(names).size, names.length, 'section names must stay unique')
assert.equal(out.assembly.sections[1].text, 'note')
})
test('P7: a reject decision passes through untouched', () => {
const reject = { kind: 'reject', reason: 'nope' }
assert.equal(promoteSnapshotSections(reject), reject)
})
test('P8: a decision without an assembly passes through untouched', () => {
const bare = { kind: 'enter', messages: [snapshotMessage('note')] }
assert.equal(promoteSnapshotSections(bare), bare)
})
test('P9: non-promoted messages keep their order', () => {
const out = promoteSnapshotSections(
decisionWith([plainMessage('first'), snapshotMessage('note'), plainMessage('second')]),
)
assert.deepEqual(
out.messages.map((message) => message.content[0].text),
['first', 'second'],
)
})
test('P10: the subclass does not re-declare the factory or its inject list', () => {
// A second `setFactory` registration throws at startup, and re-declaring
// `inject` would pull a duplicate AgentLoop into the container. Both are
// avoided by not defining a constructor or static inject at all.
assert.equal(Object.prototype.hasOwnProperty.call(HologramAgentLoop, 'inject'), false)
const source = HologramAgentLoop.prototype.constructor.toString()
assert.equal(source.includes('constructor'), false, 'subclass must not define a constructor')
assert.equal(typeof HologramAgentLoop.prototype.prepare, 'function')
})
// ─────────────────────────────────────────────────────────────────────────
// S1–S7 · the two canonical spellings, and the ones that must not be touched
// ─────────────────────────────────────────────────────────────────────────
//
// Why this block exists: every test above builds its snapshot carrier with
// `content: []`. That is a shape NO real producer emits — `lib/index.js:932`
// always attaches exactly one `{ type: 'text' }` block whose text is
// `sections[0].text` verbatim. So the whole suite was green over a code path
// that never runs in production, and `promoteSnapshotSections` was a no-op on
// every real recall. The rule under test is three-way:
//
// content == sections (either joining convention) → PROMOTE, moving the text
// content != sections → leave it in history
//
// The invariant is "move, don't rewrite": the promoted section text must be
// *identical* to the text the model would otherwise have read out of the
// message content, so these tests can tell a relocation from a silent rewrite.
//
// ★ Every negative case below carries a POSITIVE CONTROL in the same decision:
// a carrier of the same shape that *must* be promoted. Without the control a
// negative assertion is satisfied by the old "content non-empty ⇒ reject" rule
// alone, so it would be green on the broken commit and prove nothing. With it,
// the assertion fails unless the implementation is comparing the two texts.
/** A user message whose content and sections are the SAME single text. */
function carrier(text, kind = 'memory') {
return {
role: 'user',
content: [{ type: 'text', text }],
source: { kind, form: 'snapshot', sections: [{ name: kind, text }] },
}
}
/**
* A carrier whose N sections are rendered into ONE content block.
*
* The name states the shape: ONE text block. It models the two joining
* conventions ('\n\n' and '') on that single block, which is what
* `lib/index.js:932` actually emits.
*
* ★ This helper was previously called `multiCarrier` while producing a single
* block, and that name is exactly what let the V2 bug (`if (blocks.length > 1)
* return null`) pass untouched: S2a/S2b read as multi-block coverage while
* exercising none of it. A helper whose name overstates its shape is worse than
* a missing test, because it looks like coverage. Real multi-block carriers come
* from `trueMultiCarrier` below and are exercised by S2c/S2d.
* @param {string[]} texts one entry per section
* @param {'glued'|'spaced'} convention how the sections were concatenated
*/
function singleBlockCarrier(texts, convention = 'spaced', kind = 'memory') {
const joined = convention === 'glued' ? texts.join('') : texts.join('\n\n')
return {
role: 'user',
content: [{ type: 'text', text: joined }],
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
}
}
/**
* 产出:N 个 text block,每个 block 一个 section 的文本(rendered = texts.join(''))。
* 这不是本插件产出的形状,是"其它 producer 逐块发文本"的形状;只用来证明多块路径有牙。
* 注意:真多块时 rendered 恒为 glued 拼法,没有 spaced 变体 —— spaced 只由单块承载。
* @param {string[]} texts one entry per block AND per section
* @param {string} kind source kind
*/
function trueMultiCarrier(texts, kind = 'memory') {
return {
role: 'user',
content: texts.map((text) => ({ type: 'text', text })),
source: { kind, form: 'snapshot', sections: texts.map((text, i) => ({ name: `${kind}-${i}`, text })) },
}
}
/** Readable "expected vs actual" rendering for the assertion messages. */
const show = (value) => JSON.stringify(value)
test('S1: single block == single section is promoted, text byte-identical', () => {
const out = promoteSnapshotSections(decisionWith([plainMessage('what did I decide?'), carrier('note alpha')]))
assert.equal(
out.messages.length,
1,
`the carrier must leave messages (expected 1 remaining, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted section = 2, got ${out.assembly.sections.length}`,
)
const promoted = out.assembly.sections[1]
assert.equal(
promoted.text,
'note alpha',
`promoted text must be the carrier text unchanged; expected ${show('note alpha')}, got ${show(promoted.text)}`,
)
})
test('S2a: single content block carrying N sections ("\\n\\n" convention) is promoted', () => {
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'spaced')]))
assert.equal(
out.messages.length,
0,
`the spaced carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
)
assert.equal(
out.assembly.sections[1].text,
'a\n\nb',
`promoted text must equal the content text exactly (move, not rewrite); expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S2b: single content block carrying N sections ("" glued convention) is promoted', () => {
const out = promoteSnapshotSections(decisionWith([singleBlockCarrier(['a', 'b'], 'glued')]))
assert.equal(
out.messages.length,
0,
`the glued carrier must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.equal(
out.assembly.sections.length,
2,
`expected base + promoted = 2 sections, got ${out.assembly.sections.length}`,
)
assert.equal(
out.assembly.sections[1].text,
'ab',
`promoted text must equal the content text exactly; expected ${show('ab')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S2c: two content blocks == two sections (glued) is promoted', () => {
const msg = trueMultiCarrier(['a', 'b']) // 2 blocks, rendered='ab'
const out = promoteSnapshotSections(decisionWith([msg]))
assert.equal(out.messages.length, 0, `two blocks == two sections must be promoted; got ${out.messages.length} message(s)`)
assert.equal(out.assembly.sections[1].text, 'ab', `promoted text must be the rendered content; got ${JSON.stringify(out.assembly.sections[1].text)}`)
})
test('S2d: two content blocks != sections is NOT promoted (control: the match is)', () => {
// rendered = 'ab' (glued), while the single section says 'a\n\nb' — neither
// canonical join matches, so this must stay in history. The control beside it
// is the same two blocks with matching sections, which must be promoted.
const drifted = {
...trueMultiCarrier(['a', 'b']),
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'm0', text: 'a\n\nb' }] },
}
const control = trueMultiCarrier(['a', 'b'])
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(out.messages.length, 1, `only the drifted one may stay; got ${out.messages.length}`)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted one')
})
test('S3: single content block carrying N sections != those sections is NOT promoted (control: the match is)', () => {
// Sections say "a"+"b", the content block says "zzz": editing the text would
// not be a move, so this one must stay in history — while the matching
// carrier beside it must go. The two must be told apart by CONTENT, not by
// "does it carry content at all".
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'zzz' }],
source: {
kind: 'memory',
form: 'snapshot',
sections: [{ name: 'memory-0', text: 'a' }, { name: 'memory-1', text: 'b' }],
},
}
const control = singleBlockCarrier(['a', 'b'], 'spaced')
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(
out.messages.length,
1,
`only the drifted carrier may stay (expected 1 message, got ${out.messages.length})`,
)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
assert.equal(
out.assembly.sections.length,
2,
`only the control may be promoted (expected 2 sections, got ${out.assembly.sections.length})`,
)
assert.equal(
out.assembly.sections[1].text,
'a\n\nb',
`the promoted text must come from the control; expected ${show('a\n\nb')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S4: single block != single section is NOT promoted (control: the match is)', () => {
const drifted = {
role: 'user',
content: [{ type: 'text', text: 'content says this' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'sections say that' }] },
}
const control = carrier('note alpha')
const out = promoteSnapshotSections(decisionWith([control, drifted]))
assert.equal(out.messages.length, 1, `only the drifted carrier may stay; got ${out.messages.length} messages`)
assert.equal(out.messages[0], drifted, 'the surviving message must be the drifted carrier itself')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'note alpha',
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S5: empty content (legacy carrier) is promoted — regression, with its real-world twin', () => {
// Two carriers: the legacy `content: []` shape (which the old code did handle)
// and the shape every real producer emits (which it did not). Both must be
// promoted, so this doubles as the regression pin for the old path AND as the
// minimal reproduction of the production no-op.
const out = promoteSnapshotSections(decisionWith([snapshotMessage('note alpha'), carrier('note beta')]))
assert.equal(
out.messages.length,
0,
`both carriers must be promoted (expected 0 messages, got ${out.messages.length})`,
)
assert.deepEqual(
out.assembly.sections.map((section) => section.text),
['base prompt', 'note alpha', 'note beta'],
`expected the legacy text first, then the real-world text; got ${show(out.assembly.sections.map((s) => s.text))}`,
)
})
test('S6: runtime-context shape (prefix in content, absent from sections) is NOT promoted', () => {
// The stock loop prepends a fixed sentence to the content and keeps `sections`
// as the raw contributions, so the two can never be equal. Promoting it would
// delete that preamble, so it must pass through — the control carrier in the
// same decision proves the comparison is on text, not on "has content".
const runtime = {
role: 'user',
content: [
{
type: 'text',
text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\ncwd /tmp',
},
],
source: { kind: 'runtime-context', form: 'snapshot', sections: [{ name: 'runtime-context', text: 'cwd /tmp' }] },
}
const control = carrier('cwd /tmp', 'runtime-context')
const out = promoteSnapshotSections(decisionWith([control, runtime]))
assert.equal(out.messages.length, 1, `runtime-context must stay in history; got ${out.messages.length} messages`)
assert.equal(out.messages[0], runtime, 'the surviving message must be the runtime-context snapshot itself')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'cwd /tmp',
`promoted text must be the control's; expected ${show('cwd /tmp')}, got ${show(out.assembly.sections[1].text)}`,
)
})
test('S7: a non-text block means NOT promoted (control: the text-only one is)', () => {
const imageCarrier = {
role: 'user',
content: [{ type: 'image', text: 'note alpha' }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'note alpha' }] },
}
const badText = {
role: 'user',
content: [{ type: 'text', text: 42 }],
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: '42' }] },
}
const control = carrier('note alpha')
const out = promoteSnapshotSections(decisionWith([control, imageCarrier, badText]))
assert.equal(out.messages.length, 2, `only the text-only control may be promoted; got ${out.messages.length} messages`)
assert.equal(out.messages[0], imageCarrier, 'the image carrier must survive')
assert.equal(out.messages[1], badText, 'the non-string-text carrier must survive')
assert.equal(out.assembly.sections.length, 2, `only the control may be promoted; got ${out.assembly.sections.length} sections`)
assert.equal(
out.assembly.sections[1].text,
'note alpha',
`promoted text must be the control's; expected ${show('note alpha')}, got ${show(out.assembly.sections[1].text)}`,
)
})