/** * 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. * P4 a message with real content blocks is NOT promoted (plugin said * "user-visible"). * 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('P4: a message carrying real content blocks is not promoted', () => { const mixed = { role: 'user', content: [{ type: 'text', text: 'visible' }], source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: 'hidden' }] }, } const out = promoteSnapshotSections(decisionWith([mixed])) assert.equal(out.messages.length, 1) assert.equal(out.assembly.sections.length, 1) }) 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') })