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.
155 lines
6.6 KiB
JavaScript
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',
|
|
)
|
|
})
|