Files
deepseek-harness/packages/core/agent-loop/tests/inbox.spec.ts
T
_Kerman ec51b7651f Merge remote-tracking branch 'origin/master' into xtr/durable-inbox-recovery
# Conflicts:
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/api/session-controller/tests/session-projections.host.spec.ts
#	packages/context/agent-instructions/tests/agent-instructions.spec.ts
#	packages/core/agent/src/inbox.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/terminal/terminal-bash/tests/index.spec.ts
2026-09-01 21:46:31 +08:00

267 lines
10 KiB
TypeScript

import { Context } from '@deepseek-ai/cordis'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { UserMessage } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { describe, expect, it } from 'vitest'
import { ReactLoopInbox } from '../src/inbox.ts'
function unsupportedInbox(): Agent['inbox'] {
const rejectMutation = (): never => {
throw new Error('this test Agent does not support Inbox mutations')
}
return {
nextTurn: [], nextStep: [], clear: rejectMutation, append: rejectMutation,
prepend: rejectMutation, replace: rejectMutation, remove: rejectMutation, splice: rejectMutation,
}
}
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
const id = SessionId(rawId)
const session = overrides.session ?? Session.create(id)
const ctx = overrides.ctx ?? new Context()
return {
id,
options: {},
session,
inbox: unsupportedInbox(),
status: 'idle',
ctx,
send: () => {},
followup: () => {},
steer: () => {},
inject: () => {},
cancel() {},
runMaintenance: task => task(new AbortController().signal),
whenIdle: () => Promise.resolve(),
...overrides,
}
}
async function inboxAgent(rawId: string): Promise<{
ctx: Context
session: Session
agent: Agent
inbox: ReactLoopInbox
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(SessionId(rawId))
const agent = stubAgent(rawId, { ctx, session })
const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
Object.assign(agent, { inbox })
return { ctx, session, agent, inbox }
}
async function reconstructPersistedInbox(
rawId: string,
populate: (session: Session) => void,
): Promise<Error> {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId(rawId))
populate(session)
await ctx.plugin(SessionProjectionRegistry)
const agent = stubAgent(rawId, { ctx, session })
const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
try {
void inbox.nextTurn
} catch (error: unknown) {
if (error instanceof Error) return error
throw error
}
throw new Error('persisted inbox reconstruction unexpectedly succeeded')
}
describe('ReactLoopInbox', () => {
it('registers the durable projection in its constructor', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const session = ctx.sessions.create(SessionId('inbox-projection'))
const pending = createUserMessage({
content: [{ type: 'text', text: 'pending' }],
source: { kind: 'user' },
})
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, inserted: [pending],
})
const agent = stubAgent('inbox-projection', { ctx, session })
const dispatch = agentEvents(ctx, agent)
const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
expect(first.nextTurn).toEqual([pending])
expect(second.nextTurn).toEqual([pending])
expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
'next-turn': [pending],
'next-step': [],
})
})
it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, removedCount: 1, inserted: [],
})
})
expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
const pending = createUserMessage({
content: [{ type: 'text', text: 'duplicate' }],
source: { kind: 'user' },
})
const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
session.append('agent/inbox/spliced', {
target: 'next-turn', start: 0, inserted: [pending],
})
session.append('agent/inbox/spliced', {
target: 'next-step', start: 0, inserted: [pending],
})
})
expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
})
it('projects inherited inbox events in a forked session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))
const inherited = createUserMessage({
content: [{ type: 'text', text: 'parent pending' }],
source: { kind: 'user' },
})
parentInbox.append('next-turn', inherited)
const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
const childAgent = stubAgent('inbox-fork-child', { ctx, session: child })
const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent))
expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length)
expect(childInbox.nextTurn).toEqual([inherited])
const own = createUserMessage({
content: [{ type: 'text', text: 'child pending' }],
source: { kind: 'user' },
})
childInbox.append('next-turn', own)
expect(childInbox.nextTurn).toEqual([inherited, own])
})
it('updates the projection cell before session observers run', async () => {
const { ctx, session, inbox } = await inboxAgent('inbox-live-projection')
const pending = createUserMessage({
content: [{ type: 'text', text: 'direct' }],
source: { kind: 'user' },
})
let observed: readonly UserMessage[] | undefined
ctx.on('session/event', (subject, event) => {
if (subject === session && event.type === 'agent/inbox/spliced') {
observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
}
})
inbox.append('next-turn', pending)
expect(observed).toEqual([pending])
expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
'next-turn': [pending], 'next-step': [],
})
})
it('replaces a pending message by identity across both lists', async () => {
const { ctx, agent } = await inboxAgent('replace-inbox')
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
agent.inbox.append('next-turn', original)
agent.inbox.append('next-step', nextStep)
expect(agent.inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(agent.inbox.replace(original.id, replacement)).toBe(true)
expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(agent.inbox.nextTurn).toEqual([replacement])
expect(agent.inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { agent.inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => {
const { agent } = await inboxAgent('splice-inbox')
const first = createUserMessage({
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
})
const second = createUserMessage({
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
})
const prefixed = createUserMessage({
content: [{ type: 'text', text: 'prefixed' }],
source: { kind: 'user' },
})
agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(agent.inbox.nextTurn).toEqual([first, second])
expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second])
agent.inbox.prepend('next-turn', prefixed)
expect(agent.inbox.nextTurn).toEqual([prefixed, first])
expect(agent.inbox.remove(second.id)).toBe(false)
expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})
it('clears both pending lists as durable cancellations', async () => {
const { ctx, session, agent } = await inboxAgent('clear-inbox')
const discarded: UserMessage[] = []
ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
agent.inbox.append('next-turn', nextTurn)
agent.inbox.append('next-step', nextStep)
const beforeClear = session.snapshotEvents().length
agent.inbox.clear()
expect(agent.inbox.nextTurn).toEqual([])
expect(agent.inbox.nextStep).toEqual([])
expect(discarded).toEqual([nextStep, nextTurn])
expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
? event.data
: event.type)).toEqual([
{ target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
])
agent.inbox.clear()
expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
})
})