Files
HologramAgent-DSH-Memory-Port/test/loop.test.js
jianf 6d9ad62a8f feat(memory): surface recall as a system section instead of history
Per-turn recall currently enters through the `agent/pre-step` waterfall, and
that hook can only return `messages`. The recall block therefore lands in
conversation history as a user message: the model reads its own memory as if a
user had said it. `systemPrompt.context` cannot fix this — it runs before the
turn text is known, so it can only render input-independent text.

The loop's `preStep` is the first seam holding BOTH the assembled prompt and
this turn's claimed messages. `lib/loop.js` subclasses the harness AgentLoop and
wraps that seam on the agent instance: a pure snapshot carrier message is moved
out of `messages` and into `assembly.sections`, so it becomes part of the system
prompt for that turn and never enters history.

Deliberately decoupled from the memory plugin: nothing imports it or matches its
name. Promotion keys off the shape every snapshot-style injector already uses
(`source.sections`, the same shape `dsh-time-context` emits).

Constraints honoured, each of which was a measured failure elsewhere:
- The promoted section carries `interpolate: false`; recalled text is free text
  and may contain `{{...}}`, which the strict interpolator rejects.
- The subclass defines no constructor and no static `inject`. The parent already
  registers the turnBoundary + inbox projections, calls `setFactory(this)`, and
  declares `{{provider}}`/`{{model}}`; re-registering any of them throws at
  startup.
- Only the instance's `preStep` is patched, never the prototype, which other
  agents share.

Also wires the new peer: `scripts/link-dsh.sh` gains dsh-agent/dsh-agent-loop
(without them the package cannot resolve at test time), package.json declares
dsh-agent-loop as a peer and adds the new test file, and G2b's allowlist admits
dsh-agent-loop as a first-party harness package, with the reason recorded.

npm test: 247 pass, 0 fail.
2026-11-04 14:54:49 +08:00

132 lines
5.4 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.
* 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')
})