`promoteSnapshotSections` accepted only ONE carrier spelling: `content: []`.
The plugin never emits that shape. `lib/index.js:932` always attaches exactly
one `{ type: 'text', text: injected }` block whose text IS `sections[0].text`,
so `promotedSection`'s `if (blocks.length > 0) return null` matched on every
real recall and the promotion was a no-op in production. Recall stayed in
user/message history — the exact defect 6d9ad62 claimed to fix.
6d9ad62's "surface recall as a system section instead of history" was never
true: the promotion it relies on was blocked by that content-non-empty check.
The unit tests could not see it because their `snapshotMessage()` helper built
`content: []` — a shape no producer emits — and the old P4 asserted "real
content blocks are NOT promoted", which is the opposite of reality.
It was confirmed today, from `lib/index.js:932`'s constant one-text-block
injection, and is fixed here.
The rule is now "move the text, don't rewrite it": a snapshot carrier with
content is promoted only when the rendered blocks equal the sections' text
under one of the two canonical joins (`''` glued, `'\n\n'` spaced), and the
promoted section text is the rendered content, so relocation is assertable.
Non-text blocks, mismatched text, and the stock runtime-context snapshot (whose
content carries a preamble the sections do not) pass through untouched.
Tests: S1-S7 plus real multi-block S2c/S2d pin the acceptance surface; every
negative case carries a positive control in the same decision, so the old
"content non-empty => reject" rule cannot satisfy it. P4 is split into P4a/b
(content == sections promotes; content != sections does not) with P4c proving
the split is decided by text, not by presence of content.
`test/loop-system-e2e.test.js` mounts the real HologramAgentLoop on real dsh
services, runs one turn, and asserts on the request the adapter received: the
recall is in the system prompt (E1) and in no non-system message body (E2),
carried by exactly one message (E2b).
195 lines
8.0 KiB
JavaScript
195 lines
8.0 KiB
JavaScript
/**
|
|
* End-to-end proof: a recall block produced by the REAL injection shape reaches
|
|
* the SYSTEM prompt and does NOT reach any user/message content.
|
|
*
|
|
* Why this file exists next to `loop.test.js`
|
|
* -------------------------------------------
|
|
* `loop.test.js` asserts the pure decision rewrite (`promoteSnapshotSections`).
|
|
* That is necessary but not sufficient: the bug it fixes was that the rewrite
|
|
* never fired on the real injection shape, and a unit test can only catch that
|
|
* if it also encodes the real shape. This file closes the loop the other way —
|
|
* it mounts the actual `HologramAgentLoop` on real dsh services (`SessionStore`,
|
|
* `AgentRegistry`, `LlmRuntime`, `SystemPrompt`, `ToolRuntime`,
|
|
* `SessionProjectionRegistry`), drives one real turn (pre-step → request build →
|
|
* LLM call), and asserts against **the request object the adapter actually
|
|
* received**, not an intermediate decision.
|
|
*
|
|
* Two directions, both required:
|
|
* E1 positive — the recall text appears in the system prompt the adapter got.
|
|
* E2 negative — the recall text appears in NO message body the adapter got
|
|
* (every message, every block; not just the last one).
|
|
*
|
|
* Only E1 fails on the broken commit; E2 is already true there because the old
|
|
* code left recall in history. E2 is what this fix is *for*: it must stay true
|
|
* after the move, otherwise the promotion would duplicate recall rather than
|
|
* relocate it.
|
|
*
|
|
* No network: the "adapter" is a local `LlmAdapter` subclass that records the
|
|
* request and yields one text chunk.
|
|
*
|
|
* @module dsh-memory/test/loop-system-e2e
|
|
*/
|
|
|
|
import assert from 'node:assert/strict'
|
|
import { createRequire } from 'node:module'
|
|
import { pathToFileURL } from 'node:url'
|
|
import test from 'node:test'
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
import { AgentRegistry } from '@deepseek-ai/dsh-agent'
|
|
import { LlmAdapter, LlmRuntime, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import { SessionStore } from '@deepseek-ai/dsh-session'
|
|
import { SystemPrompt } from '@deepseek-ai/dsh-system-prompt'
|
|
import { ToolRuntime } from '@deepseek-ai/dsh-tools'
|
|
import { HologramAgentLoop } from '../lib/loop.js'
|
|
|
|
/**
|
|
* Load `SessionProjectionRegistry` the way the harness does.
|
|
*
|
|
* It is a peer of `dsh-agent-loop`, not of this package, so a bare specifier
|
|
* only resolves when the repo's `node_modules` happens to link it. Resolving
|
|
* from `dsh-agent-loop`'s own location is exactly what a real dsh process does;
|
|
* if neither route works there is no harness to run against, and the test skips
|
|
* loudly instead of asserting on a half-mounted app.
|
|
* @returns {Promise<Function|null>}
|
|
*/
|
|
async function loadProjectionRegistry() {
|
|
try {
|
|
return (await import('@deepseek-ai/dsh-session-projection')).SessionProjectionRegistry
|
|
} catch {
|
|
try {
|
|
const peerRequire = createRequire(import.meta.resolve('@deepseek-ai/dsh-agent-loop'))
|
|
return (await import(pathToFileURL(peerRequire.resolve('@deepseek-ai/dsh-session-projection')).href))
|
|
.SessionProjectionRegistry
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Every text the model would read out of a message body. */
|
|
function bodyText(message) {
|
|
if (!Array.isArray(message?.content)) return ''
|
|
return message.content.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('')
|
|
}
|
|
|
|
/** The system prompt text as the adapter received it. */
|
|
function systemText(request) {
|
|
return request.messages
|
|
.filter((message) => message.role === 'system')
|
|
.map((message) => bodyText(message))
|
|
.join('\n')
|
|
}
|
|
|
|
/** Every non-system message body, flattened, for the negative direction. */
|
|
function historyBlocks(request) {
|
|
return request.messages
|
|
.filter((message) => message.role !== 'system')
|
|
.map((message, index) => ({ index, role: message.role, kind: message.source?.kind, text: bodyText(message) }))
|
|
}
|
|
|
|
test('E1/E2: recall reaches the system prompt and no message body', async (t) => {
|
|
const SessionProjectionRegistry = await loadProjectionRegistry()
|
|
if (SessionProjectionRegistry === null) {
|
|
t.skip('@deepseek-ai/dsh-session-projection not resolvable from this install (no harness to mount)')
|
|
return
|
|
}
|
|
|
|
const RECALL = 'RECALLED-NOTE-4471: user prefers evidence over summaries.'
|
|
|
|
/** Records the request it is called with; yields one stop-finish text chunk. */
|
|
const captured = []
|
|
class CaptureAdapter extends LlmAdapter {
|
|
async *stream(request) {
|
|
captured.push(request)
|
|
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
yield { type: 'text-delta', index: 0, text: 'acknowledged' }
|
|
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'acknowledged' } }
|
|
yield { type: 'finish', reason: 'stop' }
|
|
}
|
|
}
|
|
|
|
const ctx = new Context()
|
|
try {
|
|
// Cordis defers service publication by a microtask; each `new` below needs
|
|
// the previous one visible, so yield between mounts.
|
|
new SessionStore(ctx)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
new AgentRegistry(ctx)
|
|
new SystemPrompt(ctx, {})
|
|
new ToolRuntime(ctx)
|
|
new LlmRuntime(ctx)
|
|
new SessionProjectionRegistry(ctx)
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
|
|
// The loop under test. Mount before any agent exists: `setFactory` runs in
|
|
// the constructor and the class refuses a second registration.
|
|
new HologramAgentLoop(ctx, { maxParallelToolCalls: 4, agents: [] })
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
ctx.llm.registerAdapter(['capture'], new CaptureAdapter())
|
|
|
|
// The REAL injection shape, reproduced byte for byte from `lib/index.js:932`:
|
|
// one text block whose text IS the section text, `form: 'snapshot'`.
|
|
ctx.on(
|
|
'agent/pre-step',
|
|
async (payload, next) => {
|
|
const decision = await next()
|
|
if (decision.kind === 'reject' || payload.signal.aborted) return decision
|
|
return {
|
|
...decision,
|
|
messages: [
|
|
...decision.messages,
|
|
createUserMessage({
|
|
content: [{ type: 'text', text: RECALL }],
|
|
source: { kind: 'memory', form: 'snapshot', sections: [{ name: 'memory', text: RECALL }] },
|
|
}),
|
|
],
|
|
}
|
|
},
|
|
{ prepend: true },
|
|
)
|
|
|
|
const handle = await ctx.agents.create({
|
|
sessionId: 'loop-system-e2e',
|
|
agentOptions: { provider: 'capture', model: 'capture-1' },
|
|
})
|
|
const agent = handle.agent
|
|
agent.followup(
|
|
createUserMessage({ content: [{ type: 'text', text: 'what did we decide?' }], source: { kind: 'user' } }),
|
|
)
|
|
await agent.whenIdle()
|
|
|
|
assert.equal(captured.length, 1, `the adapter must be called exactly once; got ${captured.length} call(s)`)
|
|
const request = captured[0]
|
|
|
|
// ── E1 · positive: the recall is in the system prompt ────────────────
|
|
const system = systemText(request)
|
|
assert.ok(
|
|
system.includes(RECALL),
|
|
`E1: recall must appear in the system prompt the adapter received. system prompt was ${JSON.stringify(system)}`,
|
|
)
|
|
|
|
// ── E2 · negative: the recall is in NO message body ──────────────────
|
|
const history = historyBlocks(request)
|
|
const leaking = history.filter((entry) => entry.text.includes(RECALL))
|
|
assert.deepEqual(
|
|
leaking,
|
|
[],
|
|
`E2: recall must not appear in ANY non-system message; leaking messages: ${JSON.stringify(leaking)}`,
|
|
)
|
|
|
|
// E2b · the loop must not duplicate it either: exactly one system message
|
|
// may carry it, so a "promote but keep" bug cannot hide behind E1.
|
|
const carriers = request.messages.filter((message) => bodyText(message).includes(RECALL))
|
|
assert.equal(
|
|
carriers.length,
|
|
1,
|
|
`E2b: exactly one message may carry the recall (a move, not a copy); got ${carriers.length}: ${JSON.stringify(
|
|
carriers.map((message) => message.role),
|
|
)}`,
|
|
)
|
|
assert.equal(carriers[0].role, 'system', `E2b: the sole carrier must be the system message; got ${carriers[0].role}`)
|
|
} finally {
|
|
await ctx.fiber.dispose()
|
|
}
|
|
})
|