Files
HologramAgent-DSH-Memory-Port/test/loop-ports.test.js
jianf 5c6aaf53cf feat(memory): land the loop ports without wiring them
The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.

Two packaging facts they bring with them:

  files  `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
         `lib/loop.js` and `lib/loop/*.js` are now listed separately because
         neither glob covers the other.
  peer   runtime-context.js imports @deepseek-ai/dsh-session, which the peer
         table never declared. G2b's allowlist covered it, so the static guard
         passed over a real declaration gap.

test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
2026-11-04 17:27:36 +08:00

155 lines
6.6 KiB
JavaScript

/**
* Contract for the four ported loop modules under `lib/loop/`.
*
* These files are a faithful port of the stock loop's internals into this
* package, so nothing here tests their behaviour — that is `loop.test.js` and
* the phase-B suites. What this file pins is the **port interface**, because
* every one of these failures is silent at runtime:
*
* L1 all four modules import on their own (no ordering, no index.js).
* L2 the export names a replacement loop will import are present.
* L3 `inboxProjectionSchema.parse` actually rejects a malformed persisted
* row. This is the ONE thing the port re-implemented instead of copying
* (it replaces a zod schema with a hand-written shape check), so it is
* exactly the thing that can be a no-op and still look fine.
* L4 the inbox projection KEY and `stateVersion` are unchanged. A different
* key reads as "no pending input" for sessions written by the stock loop;
* a different version makes `stateSchema.parse` refuse those rows. Both
* are quiet data loss on `resume`, not a crash.
*
* @module dsh-memory/test/loop-ports
*/
import assert from 'node:assert/strict'
import test from 'node:test'
// ── L1: each module stands alone ─────────────────────────────────────────
//
// Imported dynamically rather than at the top of the file so the assertion is
// per-module: a single top-level `import` would make one broken port fail the
// whole file with "load failed" and hide which of the four broke.
const MODULES = {
'assistant-stream': '../lib/loop/assistant-stream.js',
inbox: '../lib/loop/inbox.js',
'runtime-context': '../lib/loop/runtime-context.js',
'tool-calls': '../lib/loop/tool-calls.js',
}
test('L1: all four loop ports import successfully on their own', async () => {
for (const [name, specifier] of Object.entries(MODULES)) {
const mod = await import(specifier)
assert.ok(mod !== null && typeof mod === 'object', `${name} must import to a module namespace`)
}
})
// ── L2: the export surface a replacement loop depends on ─────────────────
//
// Named explicitly, not counted: the point is that `executeToolCalls`,
// `ReactLoopInbox`, … are still exported under those names, because a rename
// here only shows up as `undefined is not a function` at step() time.
test('L2: loop ports export the names the replacement loop imports', async () => {
const toolCalls = await import(MODULES['tool-calls'])
assert.equal(typeof toolCalls.executeToolCalls, 'function', 'tool-calls must export executeToolCalls')
const assistantStream = await import(MODULES['assistant-stream'])
assert.equal(
typeof assistantStream.AssistantStreamAttempt,
'function',
'assistant-stream must export AssistantStreamAttempt',
)
const runtimeContext = await import(MODULES['runtime-context'])
assert.equal(
typeof runtimeContext.SystemPromptProjection,
'function',
'runtime-context must export SystemPromptProjection',
)
assert.equal(
typeof runtimeContext.RuntimeContextProjection,
'function',
'runtime-context must export RuntimeContextProjection',
)
const inbox = await import(MODULES.inbox)
assert.equal(typeof inbox.ReactLoopInbox, 'function', 'inbox must export ReactLoopInbox')
assert.ok(
inbox.inboxProjectionDefinition !== null && typeof inbox.inboxProjectionDefinition === 'object',
'inbox must export inboxProjectionDefinition',
)
assert.ok(
inbox.inboxProjectionSchema !== null && typeof inbox.inboxProjectionSchema === 'object',
'inbox must export inboxProjectionSchema',
)
})
// ── L3: the schema bites ──────────────────────────────────────────────────
//
// The projection registry calls `stateSchema.parse` on durable checkpoint rows,
// so "it has a parse function" proves nothing. These assertions run the parse
// against the two shapes that matter: a real state must come back, and a
// malformed row must throw — not return undefined, not coerce.
test('L3: inboxProjectionSchema.parse accepts a well-formed two-array state', async () => {
const { inboxProjectionSchema } = await import(MODULES.inbox)
const state = {
'next-turn': [{ id: 'm1', role: 'user' }],
'next-step': [{ id: 'm2', role: 'user' }],
}
assert.equal(inboxProjectionSchema.parse(state), state, 'parse must return the same object it validated')
assert.deepEqual(
inboxProjectionSchema.parse({ 'next-turn': [], 'next-step': [] }),
{ 'next-turn': [], 'next-step': [] },
'the init() shape (both empty) must be accepted',
)
})
test('L3: inboxProjectionSchema.parse throws on a malformed persisted row', async () => {
const { inboxProjectionSchema } = await import(MODULES.inbox)
const malformed = [
['null', null],
['a bare array', []],
['a string', 'inbox'],
['an object missing "next-step"', { 'next-turn': [] }],
['a non-array "next-turn"', { 'next-turn': {}, 'next-step': [] }],
['a non-array "next-step"', { 'next-turn': [], 'next-step': 'pending' }],
['the stock loop key casing', { nextTurn: [], nextStep: [] }],
]
for (const [label, value] of malformed) {
assert.throws(
() => inboxProjectionSchema.parse(value),
`parse must reject ${label} — a returning-but-not-throwing parse would accept a corrupt checkpoint`,
)
}
})
// ── L4: the durable identity of the inbox projection ─────────────────────
//
// These two literals are a wire format shared with sessions written by the
// stock `dsh-agent-loop`. Changing either is silent: the stock loop's pending
// splices stop folding in, and the session resumes with an empty inbox.
test('L4: inbox projection keeps key "inbox" and stateVersion 1', async () => {
const { inboxProjectionDefinition, inboxProjectionSchema } = await import(MODULES.inbox)
assert.equal(
inboxProjectionDefinition.key,
'inbox',
'the projection key is a wire format shared with stock-loop sessions — changing it loses their pending input',
)
assert.equal(
inboxProjectionDefinition.stateVersion,
1,
'stateVersion 1 is what existing checkpoint rows carry — a bump makes stateSchema.parse refuse them',
)
assert.equal(
inboxProjectionDefinition.stateSchema,
inboxProjectionSchema,
'the definition must validate with the exported schema, not a private copy',
)
assert.deepEqual(
inboxProjectionDefinition.init(),
{ 'next-turn': [], 'next-step': [] },
'init() must produce the shape stateSchema.parse accepts',
)
})