|
|
@@ -1,12 +1,11 @@
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
|
|
-import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
|
|
-import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
|
+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 { ProjectedInbox } from '../src/inbox.ts'
|
|
|
+import { ReactLoopInbox } from '../src/inbox.ts'
|
|
|
|
|
|
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
|
|
const id = SessionId(rawId)
|
|
|
@@ -30,33 +29,140 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-async function inboxAgent(rawId: string): Promise<{ ctx: Context; session: Session; agent: Agent }> {
|
|
|
+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)
|
|
|
- await ctx.plugin(AgentRegistry)
|
|
|
const session = ctx.sessions.create(SessionId(rawId))
|
|
|
const agent = stubAgent(rawId, { ctx, session })
|
|
|
- Object.assign(agent, {
|
|
|
- inbox: new ProjectedInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)),
|
|
|
- })
|
|
|
- return { ctx, session, agent }
|
|
|
+ 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('ProjectedInbox', () => {
|
|
|
- it('reports a missing inbox projection as a composition error', async () => {
|
|
|
+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 agent = stubAgent('missing-inbox-projection', { ctx })
|
|
|
- const inbox = new ProjectedInbox(
|
|
|
- ctx.sessionProjections,
|
|
|
- agent.session,
|
|
|
- agentEvents(ctx, agent),
|
|
|
- )
|
|
|
-
|
|
|
- expect(() => inbox.nextTurn).toThrow(
|
|
|
- 'agent "missing-inbox-projection" cannot read inbox state: session projection "inbox" is not registered; load AgentRegistry with SessionProjectionRegistry before constructing ReactLoopAgent',
|
|
|
- )
|
|
|
+ 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.header.seedLength).toBe(parent.events.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 () => {
|
|
|
@@ -65,7 +171,6 @@ describe('ProjectedInbox', () => {
|
|
|
const discarded: UserMessage[] = []
|
|
|
ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
|
|
|
ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
|
|
|
- const { inbox } = agent
|
|
|
const original = createUserMessage({
|
|
|
content: [{ type: 'text', text: 'original' }],
|
|
|
source: { kind: 'user' },
|
|
|
@@ -82,26 +187,25 @@ describe('ProjectedInbox', () => {
|
|
|
...nextStep,
|
|
|
content: [{ type: 'text', text: 'edited step' }],
|
|
|
})
|
|
|
- inbox.append('next-turn', original)
|
|
|
- inbox.append('next-step', nextStep)
|
|
|
+ agent.inbox.append('next-turn', original)
|
|
|
+ agent.inbox.append('next-step', nextStep)
|
|
|
|
|
|
- expect(inbox.replace(createUserMessage({
|
|
|
+ expect(agent.inbox.replace(createUserMessage({
|
|
|
content: [{ type: 'text', text: 'missing' }],
|
|
|
source: { kind: 'user' },
|
|
|
}).id, replacement)).toBe(false)
|
|
|
- expect(inbox.replace(original.id, replacement)).toBe(true)
|
|
|
- expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
|
|
|
- expect(inbox.nextTurn).toEqual([replacement])
|
|
|
- expect(inbox.nextStep).toEqual([editedStep])
|
|
|
+ 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(() => { inbox.replace(editedStep.id, replacement) })
|
|
|
+ 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 { inbox } = agent
|
|
|
const first = createUserMessage({
|
|
|
content: [{ type: 'text', text: 'first' }],
|
|
|
source: { kind: 'user' },
|
|
|
@@ -115,30 +219,29 @@ describe('ProjectedInbox', () => {
|
|
|
source: { kind: 'user' },
|
|
|
})
|
|
|
|
|
|
- inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
|
|
|
- expect(inbox.nextTurn).toEqual([first, second])
|
|
|
- expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
|
|
|
- inbox.prepend('next-turn', prefixed)
|
|
|
- expect(inbox.nextTurn).toEqual([prefixed, first])
|
|
|
- expect(inbox.remove(second.id)).toBe(false)
|
|
|
- expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
|
|
|
+ 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 { inbox } = agent
|
|
|
const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
|
|
|
const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
|
|
|
- inbox.append('next-turn', nextTurn)
|
|
|
- inbox.append('next-step', nextStep)
|
|
|
+ agent.inbox.append('next-turn', nextTurn)
|
|
|
+ agent.inbox.append('next-step', nextStep)
|
|
|
const beforeClear = session.events.length
|
|
|
|
|
|
- inbox.clear()
|
|
|
+ agent.inbox.clear()
|
|
|
|
|
|
- expect(inbox.nextTurn).toEqual([])
|
|
|
- expect(inbox.nextStep).toEqual([])
|
|
|
+ expect(agent.inbox.nextTurn).toEqual([])
|
|
|
+ expect(agent.inbox.nextStep).toEqual([])
|
|
|
expect(discarded).toEqual([nextStep, nextTurn])
|
|
|
expect(session.events.slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
|
|
|
? event.data
|
|
|
@@ -147,7 +250,7 @@ describe('ProjectedInbox', () => {
|
|
|
{ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
|
|
|
])
|
|
|
|
|
|
- inbox.clear()
|
|
|
+ agent.inbox.clear()
|
|
|
expect(session.events).toHaveLength(beforeClear + 2)
|
|
|
})
|
|
|
})
|