Files
deepseek-harness/packages/core/agent-controller/tests/agent-controller.spec.ts
T
Yichen Jiang 43d6585375 refactor(agent-controller): recognize admission refusals by code
AgentAdmissionRejected now extends HarnessError with the fixed code
AGENT_ADMISSION_REJECTED. The Session Remote recognizes a refusal by that
code and imports only agent-controller types, so its mapping also holds for
an error from another copy of the module; its literals are typed by the
agent-controller constants, so a changed value fails the build.
2026-10-20 10:40:31 +08:00

455 lines
19 KiB
TypeScript

/**
* Delivery from outside an Agent: `ctx.agentController.deliver` prepares the
* message content, asks the `agent-controller/admission` listeners for a
* decision, then appends an admitted message, one message per Agent at a time.
*/
import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-llm'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, Inbox } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentController, { ADMISSION_AGENT_GONE, ADMISSION_FAILED, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller'
import type { DeliveryMode, DeliveryRequest } from '@deepseek-ai/dsh-agent-controller'
/** One message the Agent received through `followup` or `steer`. */
interface Delivery {
readonly mode: DeliveryMode
readonly text: string
readonly id: string
}
/** An inbox delivery never touches directly: messages enter through `followup` and `steer`. */
function untouchedInbox(): Inbox {
const refuse = (): never => {
throw new Error('delivery must enter through followup or steer')
}
return {
nextTurn: [], nextStep: [], clear: refuse, append: refuse, prepend: refuse, replace: refuse, remove: refuse, splice: refuse,
}
}
function textOf(message: UserMessage): string {
return message.content.map(block => block.type === 'text' ? block.text : '').join('')
}
/** A sender that stays connected. */
function connected(): AbortSignal {
return new AbortController().signal
}
/** A followup whose prepared content is one text block. */
function request(text: string, overrides: Partial<DeliveryRequest> = {}): DeliveryRequest {
return {
mode: 'followup',
source: { kind: 'user' },
signal: connected(),
prepare: () => Promise.resolve([{ type: 'text', text }]),
...overrides,
}
}
/** A preparation that records its text, then returns it as the message content. */
function recordingPrepare(prepared: string[], text: string): DeliveryRequest['prepare'] {
return () => {
prepared.push(text)
return Promise.resolve([{ type: 'text', text }])
}
}
/** Let pending deliveries run as far as they can. */
async function settle(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
/** The rejection of a delivery that must fail. */
async function refusalOf(delivery: Promise<unknown>): Promise<unknown> {
return await delivery.then(() => { throw new Error('the message was delivered') }, (error: unknown) => error)
}
async function harness(): Promise<{
ctx: Context
controller: AgentController
disposeController: () => Promise<void>
agent: Agent
delivered: Delivery[]
recordingAgent: (rawId: string, deliveries: Delivery[], refusal?: Error) => Promise<{ agent: Agent; dispose: () => Promise<void> }>
dispose: () => Promise<void>
}> {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
const controllerFiber = await ctx.plugin(AgentController)
const recordingAgent = async (rawId: string, deliveries: Delivery[], refusal?: Error) => {
const id = SessionId(rawId)
const receive = (mode: DeliveryMode, received: UserMessage): void => {
if (refusal !== undefined) throw refusal
deliveries.push({ mode, text: textOf(received), id: received.id })
}
const agent: Agent = {
id,
options: {},
session: Session.create(id),
inbox: untouchedInbox(),
status: 'idle',
ctx,
send: () => {},
followup: (received) => { receive('followup', received) },
steer: (received) => { receive('steer', received) },
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
}
const unregister = await ctx.agents.register(agent)
return { agent, dispose: async () => { await unregister() } }
}
const delivered: Delivery[] = []
const { agent, dispose } = await recordingAgent('delivery-agent', delivered)
return {
ctx,
controller: ctx.agentController,
disposeController: async () => { await controllerFiber.dispose() },
agent,
delivered,
recordingAgent,
dispose,
}
}
describe('agent controller delivery', () => {
it('appends the prepared message with followup or steer only after every listener, in registration order, admitted it', async () => {
const { ctx, agent, delivered } = await harness()
const unwatched = await ctx.agentController.deliver(agent, request('unwatched', { source: { kind: 'schedule' } }))
expect(delivered).toEqual([{ mode: 'followup', text: 'unwatched', id: unwatched }])
const order: string[] = []
ctx.on('agent-controller/admission', async ({ agent: subject, message }, next) => {
expect(subject).toBe(agent)
expect(message.source).toEqual({ kind: 'user' })
order.push(`first ${textOf(message)}`)
const decision = await next()
order.push(`first got ${decision.kind} with ${String(delivered.length)} delivered`)
return decision
})
ctx.on('agent-controller/admission', async (_admission, next) => {
order.push('second')
return await next()
})
const steered = await ctx.agentController.deliver(agent, request('steered', { mode: 'steer' }))
expect(order).toEqual(['first steered', 'second', 'first got admit with 1 delivered'])
expect(delivered).toEqual([
{ mode: 'followup', text: 'unwatched', id: unwatched },
{ mode: 'steer', text: 'steered', id: steered },
])
})
it('holds a message until its listener passes it on, keeping later messages to that Agent behind it, preparation included', async () => {
const { ctx, agent, delivered, recordingAgent } = await harness()
const other: Delivery[] = []
const { agent: otherAgent } = await recordingAgent('other-agent', other)
const release = Promise.withResolvers<undefined>()
const prepared: string[] = []
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message }, next) => {
seen.push(textOf(message))
if (textOf(message) === 'first') await release.promise
return await next()
})
const prepareText = (text: string): DeliveryRequest['prepare'] => recordingPrepare(prepared, text)
const first = ctx.agentController.deliver(agent, request('first', { prepare: prepareText('first') }))
const second = ctx.agentController.deliver(agent, request('second', { prepare: prepareText('second') }))
await ctx.agentController.deliver(otherAgent, request('elsewhere', { prepare: prepareText('elsewhere') }))
await settle()
expect(prepared).toEqual(['first', 'elsewhere'])
expect(seen).toEqual(['first', 'elsewhere'])
expect(delivered).toEqual([])
expect(other.map(delivery => delivery.text)).toEqual(['elsewhere'])
release.resolve(undefined)
await Promise.all([first, second])
expect(prepared).toEqual(['first', 'elsewhere', 'second'])
expect(delivered.map(delivery => delivery.text)).toEqual(['first', 'second'])
})
it('rejects a message with the decision of a listener, skips later listeners, and delivers the next message', async () => {
const { ctx, agent, delivered } = await harness()
let reject = true
let later = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
if (reject) return { kind: 'reject', reason: 'fixture/missing', message: 'The fixture is missing.', presented: true }
return await next()
})
ctx.on('agent-controller/admission', async (_admission, next) => {
later += 1
return await next()
})
const refusal = await refusalOf(ctx.agentController.deliver(agent, request('refused')))
expect(refusal).toBeInstanceOf(AgentAdmissionRejected)
expect(refusal).toMatchObject({
name: 'AgentAdmissionRejected',
code: 'AGENT_ADMISSION_REJECTED',
reason: 'fixture/missing',
presented: true,
message: 'The fixture is missing.',
})
expect(delivered).toEqual([])
expect(later).toBe(0)
reject = false
await ctx.agentController.deliver(agent, request('admitted'))
expect(delivered.map(delivery => delivery.text)).toEqual(['admitted'])
expect(later).toBe(1)
})
it.each([
['before it passes the message on', false],
['after the rest of the chain admitted the message', true],
])('rejects a message whose listener throws %s as a failed check', async (_label, afterNext) => {
const { ctx, agent, delivered } = await harness()
const broken = new Error('listener broke')
ctx.on('agent-controller/admission', async (_admission, next) => {
if (afterNext) await next()
throw broken
})
const refusal = await refusalOf(ctx.agentController.deliver(agent, request('broken')))
expect(refusal).toMatchObject({ code: 'AGENT_ADMISSION_REJECTED', reason: ADMISSION_FAILED, presented: false, cause: broken })
expect(refusal).toHaveProperty('message', expect.stringContaining('listener broke'))
expect(delivered).toEqual([])
})
it('passes the failure of preparation to the sender unchanged without asking a listener', async () => {
const { ctx, agent, delivered } = await harness()
const unreadable = new Error('attachment unreadable')
let asked = 0
ctx.on('agent-controller/admission', async (_admission, next) => {
asked += 1
return await next()
})
await expect(ctx.agentController.deliver(agent, request('unreadable', { prepare: () => Promise.reject(unreadable) })))
.rejects.toBe(unreadable)
expect(asked).toBe(0)
expect(delivered).toEqual([])
})
it('runs beforeInsert with the admitted message right before it enters the inbox, and keeps the message out when it throws', async () => {
const { ctx, agent, delivered } = await harness()
const observed: { readonly text: string; readonly deliveredBefore: number }[] = []
const id = await ctx.agentController.deliver(agent, request('recorded', {
beforeInsert: (message) => { observed.push({ text: textOf(message), deliveredBefore: delivered.length }) },
}))
expect(observed).toEqual([{ text: 'recorded', deliveredBefore: 0 }])
expect(delivered).toEqual([{ mode: 'followup', text: 'recorded', id }])
const unbound = new Error('upload binding failed')
await expect(ctx.agentController.deliver(agent, request('unbound', { beforeInsert: () => { throw unbound } })))
.rejects.toBe(unbound)
expect(delivered.map(delivery => delivery.text)).toEqual(['recorded'])
})
it('passes the refusal of the Agent itself to queue a message to the sender unchanged', async () => {
const { ctx, recordingAgent } = await harness()
const full = new Error('inbox refused the message')
const { agent: refusing } = await recordingAgent('refusing-agent', [], full)
await expect(ctx.agentController.deliver(refusing, request('overflow', { mode: 'steer' }))).rejects.toBe(full)
})
it('rejects a message whose Agent left the registry while a listener held it', async () => {
const { ctx, agent, delivered, dispose } = await harness()
const release = Promise.withResolvers<undefined>()
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const pending = refusalOf(ctx.agentController.deliver(agent, request('orphaned')))
await settle()
await dispose()
release.resolve(undefined)
expect(await pending).toMatchObject({ code: 'AGENT_ADMISSION_REJECTED', reason: ADMISSION_AGENT_GONE, presented: false })
expect(delivered).toEqual([])
})
it('rejects a message whose Agent left the registry before its turn without preparing it', async () => {
const { ctx, agent, delivered, dispose } = await harness()
const release = Promise.withResolvers<undefined>()
let prepared = false
ctx.on('agent-controller/admission', async (_admission, next) => {
await release.promise
return await next()
})
const blocking = refusalOf(ctx.agentController.deliver(agent, request('blocking')))
const waiting = refusalOf(ctx.agentController.deliver(agent, request('waiting', {
prepare: () => {
prepared = true
return Promise.resolve([{ type: 'text', text: 'waiting' }])
},
})))
await settle()
await dispose()
release.resolve(undefined)
expect(await blocking).toMatchObject({ reason: ADMISSION_AGENT_GONE })
expect(await waiting).toMatchObject({ reason: ADMISSION_AGENT_GONE })
expect(prepared).toBe(false)
expect(delivered).toEqual([])
})
it('stops a message its sender cancels while a listener holds it, while it waits for its turn, during preparation, or before it is appended', async () => {
const { ctx, agent, delivered } = await harness()
const release = Promise.withResolvers<undefined>()
const unheeded = Promise.withResolvers<undefined>()
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
seen.push(textOf(message))
if (textOf(message) === 'blocking') await release.promise
// A listener that ignores the sender's signal still cannot append a cancelled message.
if (textOf(message) === 'unheeded') await unheeded.promise
if (textOf(message) === 'held') {
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
}
return await next()
})
const heldSender = new AbortController()
const held = ctx.agentController.deliver(agent, request('held', { signal: heldSender.signal }))
await settle()
const heldReason = new Error('held sender left')
heldSender.abort(heldReason)
await expect(held).rejects.toBe(heldReason)
const blocking = ctx.agentController.deliver(agent, request('blocking'))
const waitingSender = new AbortController()
const waiting = ctx.agentController.deliver(agent, request('waiting', { signal: waitingSender.signal }))
const waitingReason = new Error('waiting sender left')
waitingSender.abort(waitingReason)
release.resolve(undefined)
await blocking
await expect(waiting).rejects.toBe(waitingReason)
const preparingSender = new AbortController()
const preparingReason = new Error('preparing sender left')
const preparing = ctx.agentController.deliver(agent, request('preparing', {
signal: preparingSender.signal,
prepare: () => {
preparingSender.abort(preparingReason)
return Promise.resolve([{ type: 'text', text: 'preparing' }])
},
}))
await expect(preparing).rejects.toBe(preparingReason)
const unheededSender = new AbortController()
const ignored = ctx.agentController.deliver(agent, request('unheeded', { signal: unheededSender.signal }))
await settle()
const unheededReason = new Error('unheeded sender left')
unheededSender.abort(unheededReason)
unheeded.resolve(undefined)
await expect(ignored).rejects.toBe(unheededReason)
expect(seen).toEqual(['held', 'blocking', 'unheeded'])
expect(delivered.map(delivery => delivery.text)).toEqual(['blocking'])
})
it('ends the wait of a message whose sender cancels behind a held one, keeping later messages in order', async () => {
const { ctx, agent, delivered } = await harness()
const release = Promise.withResolvers<undefined>()
const prepared: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
if (textOf(message) === 'held') {
// Holds until released, and lets a stopping controller end the hold.
await new Promise<void>((resolve, reject) => {
void release.promise.then(() => { resolve() })
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
}
return await next()
})
const prepareText = (text: string): DeliveryRequest['prepare'] => recordingPrepare(prepared, text)
const held = ctx.agentController.deliver(agent, request('held', { prepare: prepareText('held') }))
const queuedSender = new AbortController()
const queued = refusalOf(ctx.agentController.deliver(agent, request('queued', {
signal: queuedSender.signal, prepare: prepareText('queued'),
})))
const goneSender = new AbortController()
const goneReason = new Error('sender left before delivering')
goneSender.abort(goneReason)
const gone = refusalOf(ctx.agentController.deliver(agent, request('gone', {
signal: goneSender.signal, prepare: prepareText('gone'),
})))
const later = ctx.agentController.deliver(agent, request('later', { prepare: prepareText('later') }))
await settle()
const queuedReason = new Error('queued sender left')
queuedSender.abort(queuedReason)
// Both senders hear their own reason while the held message still blocks the Agent.
expect(await Promise.race([queued, settle().then(() => 'still waiting')])).toBe(queuedReason)
expect(await gone).toBe(goneReason)
expect(prepared).toEqual(['held'])
release.resolve(undefined)
await Promise.all([held, later])
expect(prepared).toEqual(['held', 'later'])
expect(delivered.map(delivery => delivery.text)).toEqual(['held', 'later'])
})
it('finishes disposal only after the deliveries it stopped have settled', async () => {
const { ctx, controller, disposeController, agent, delivered } = await harness()
const cleanup = Promise.withResolvers<undefined>()
const steps: string[] = []
ctx.on('agent-controller/admission', async ({ signal }, next) => {
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
steps.push('stop observed')
await cleanup.promise
steps.push('cleaned up')
return await next()
})
const held = refusalOf(controller.deliver(agent, request('held')))
await settle()
let disposed = false
const disposal = disposeController().then(() => { disposed = true })
await settle()
expect(steps).toEqual(['stop observed'])
expect(disposed).toBe(false)
cleanup.resolve(undefined)
await disposal
expect(steps).toEqual(['stop observed', 'cleaned up'])
expect(await held).toHaveProperty('message', expect.stringContaining('agent controller stopped'))
expect(delivered).toEqual([])
})
it('stops every message that has not entered the inbox when the controller is disposed', async () => {
const { ctx, controller, disposeController, agent, delivered } = await harness()
const seen: string[] = []
ctx.on('agent-controller/admission', async ({ message, signal }, next) => {
seen.push(textOf(message))
await new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true })
})
return await next()
})
const held = refusalOf(controller.deliver(agent, request('held')))
const waiting = refusalOf(controller.deliver(agent, request('waiting')))
await settle()
await disposeController()
for (const refusal of [await held, await waiting, await refusalOf(controller.deliver(agent, request('late')))]) {
expect(refusal).toHaveProperty('message', expect.stringContaining('agent controller stopped'))
}
expect(seen).toEqual(['held'])
expect(delivered).toEqual([])
})
})