mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-13 04:03:30 +00:00
# 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
267 lines
10 KiB
TypeScript
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)
|
|
})
|
|
})
|