`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")如实为红。
458 lines
21 KiB
JavaScript
458 lines
21 KiB
JavaScript
/**
|
||
* 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)}`,
|
||
)
|
||
})
|