Files
deepseek-harness/packages/core/agent-loop/tests/invariant.spec.ts
Turtle 44fd93fd06 feat(agent): unify send(target × wakeup), coalesce context/message into user/message
Replace send/steer/inject with one Agent.send primitive over the
(target × wakeup) matrix; followup/steer/inject become fixed-preset
alias methods on the now-abstract Agent class. Coalesce context/message
into user/message (injected context is a non-user source). Replace
agent/queued with agent/inbox/enqueue/dequeue/discard, add cancel
keepInbox, and add a FIFO-conservation invariant.
2026-07-23 19:15:45 +08:00

134 lines
7.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { markAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
async function setup(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
return ctx
}
function dispatch(ctx: Context, options: unknown): void {
void ctx.waterfall('llm/stream', options as never, () => (async function* () {})() as never)
}
function loopRequest<T extends object>(options: T): Readonly<T> {
markAgentLoopRequest(options as GenerateOptions)
return Object.freeze(options)
}
async function requestSetup() {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const boundary = session.deriveMessages()
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
return { ctx, session, boundary }
}
describe('request-reconstruction invariant', () => {
it('accepts a frozen request equal to the boundary derivation and folded header', async () => {
const { ctx, session, boundary } = await requestSetup()
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('uses the step boundary rather than content appended afterward', async () => {
const { ctx, session, boundary } = await requestSetup()
session.append('user/message', { content: [{ type: 'text', text: '[late]' }], source: { kind: 'plugin', plugin: 'x' } }, { surfaceOp: 'append' })
const options = loopRequest({ model: 'm', messages: Object.freeze(boundary), sessionId: session.id })
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('requires the folded session prefix ahead of derived history', async () => {
const { ctx, session, boundary } = await requestSetup()
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })) })
.not.toThrow()
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
})
it('rejects message and header divergence', async () => {
const { ctx, session, boundary } = await requestSetup()
const divergent = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze(divergent), sessionId: session.id })) })
.toThrow(/diverges from the boundary derivation/)
expect(() => { dispatch(ctx, loopRequest({ model: 'other', messages: Object.freeze(boundary), sessionId: session.id })) })
.toThrow(/diverges from the folded request header/)
})
it('rejects loop requests with no boundary or header', async () => {
const ctx = await setup()
const session = ctx.sessions.create(SessionId('req-bare'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const bare = loopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/no step\/start/)
session.append('step/start', { turn: 1, step: 1 })
expect(() => { dispatch(ctx, bare) }).toThrow(/no request\/header event/)
})
it('rejects an unfrozen messages array but skips requests outside the loop contract', async () => {
const { ctx, session, boundary } = await requestSetup()
expect(() => { dispatch(ctx, loopRequest({ model: 'm', messages: [...boundary], sessionId: session.id })) })
.toThrow(/frozen messages array/)
expect(() => { dispatch(ctx, { model: 'summarizer', messages: [], sessionId: session.id }) }).not.toThrow()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]) })) }).not.toThrow()
expect(() => { dispatch(ctx, Object.freeze({ model: 'm', messages: Object.freeze([]), sessionId: SessionId('ghost') })) })
.not.toThrow()
const directSession = ctx.sessions.create(SessionId('direct-one-shot'))
expect(() => {
dispatch(ctx, Object.freeze({ model: 'one-shot', messages: Object.freeze([]), sessionId: directSession.id }))
}).not.toThrow()
})
it('rejects malformed requests carrying the loop marker', async () => {
const { ctx, session } = await requestSetup()
const messages: GenerateOptions['messages'] = []
Object.freeze(messages)
expect(() => {
dispatch(ctx, markAgentLoopRequest({ provider: 'p', model: 'm', messages, sessionId: session.id }))
}).toThrow(/request must be frozen/)
expect(() => {
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))
}).toThrow(/carry a session id/)
expect(() => {
dispatch(ctx, loopRequest({
model: 'm',
messages: Object.freeze([]),
sessionId: SessionId('missing-loop-session'),
}))
}).toThrow(/live session id/)
})
it('prepends ahead of a short-circuiting stream listener', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
ctx.on('llm/stream', () => (async function* () {})() as never)
await ctx.plugin(InvariantService)
await ctx.plugin(AgentLoopInvariant)
const session = ctx.sessions.create(SessionId('prepend-check'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('request/header', { header: { config: { provider: 'mock', model: 'm' } }, reason: 'initial' })
const divergent = loopRequest({
model: 'm',
messages: Object.freeze([{ role: 'user', content: [{ type: 'text', text: 'phantom' }] }]),
sessionId: session.id,
})
expect(() => { dispatch(ctx, divergent) }).toThrow(/diverges from the boundary derivation/)
})
})