inbox.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import { Context } from '@deepseek-ai/cordis'
  2. import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
  3. import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
  4. import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
  5. import type { UserMessage } from '@deepseek-ai/dsh-session'
  6. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  7. import { describe, expect, it } from 'vitest'
  8. import { ReactLoopInbox } from '../src/inbox.ts'
  9. function unsupportedInbox(): Agent['inbox'] {
  10. const rejectMutation = (): never => {
  11. throw new Error('this test Agent does not support Inbox mutations')
  12. }
  13. return {
  14. nextTurn: [], nextStep: [], clear: rejectMutation, append: rejectMutation,
  15. prepend: rejectMutation, replace: rejectMutation, remove: rejectMutation, splice: rejectMutation,
  16. }
  17. }
  18. function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
  19. const id = SessionId(rawId)
  20. const session = overrides.session ?? Session.create(id)
  21. const ctx = overrides.ctx ?? new Context()
  22. return {
  23. id,
  24. options: {},
  25. session,
  26. inbox: unsupportedInbox(),
  27. status: 'idle',
  28. ctx,
  29. send: () => {},
  30. followup: () => {},
  31. steer: () => {},
  32. inject: () => {},
  33. cancel() {},
  34. runMaintenance: task => task(new AbortController().signal),
  35. whenIdle: () => Promise.resolve(),
  36. ...overrides,
  37. }
  38. }
  39. async function inboxAgent(rawId: string): Promise<{
  40. ctx: Context
  41. session: Session
  42. agent: Agent
  43. inbox: ReactLoopInbox
  44. }> {
  45. const ctx = new Context()
  46. await ctx.plugin(SessionStore)
  47. await ctx.plugin(SessionProjectionRegistry)
  48. const session = ctx.sessions.create(SessionId(rawId))
  49. const agent = stubAgent(rawId, { ctx, session })
  50. const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
  51. Object.assign(agent, { inbox })
  52. return { ctx, session, agent, inbox }
  53. }
  54. async function reconstructPersistedInbox(
  55. rawId: string,
  56. populate: (session: Session) => void,
  57. ): Promise<Error> {
  58. const ctx = new Context()
  59. await ctx.plugin(SessionStore)
  60. const session = ctx.sessions.create(SessionId(rawId))
  61. populate(session)
  62. await ctx.plugin(SessionProjectionRegistry)
  63. const agent = stubAgent(rawId, { ctx, session })
  64. const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent))
  65. try {
  66. void inbox.nextTurn
  67. } catch (error: unknown) {
  68. if (error instanceof Error) return error
  69. throw error
  70. }
  71. throw new Error('persisted inbox reconstruction unexpectedly succeeded')
  72. }
  73. describe('ReactLoopInbox', () => {
  74. it('registers the durable projection in its constructor', async () => {
  75. const ctx = new Context()
  76. await ctx.plugin(SessionStore)
  77. await ctx.plugin(SessionProjectionRegistry)
  78. const session = ctx.sessions.create(SessionId('inbox-projection'))
  79. const pending = createUserMessage({
  80. content: [{ type: 'text', text: 'pending' }],
  81. source: { kind: 'user' },
  82. })
  83. session.append('agent/inbox/spliced', {
  84. target: 'next-turn', start: 0, inserted: [pending],
  85. })
  86. const agent = stubAgent('inbox-projection', { ctx, session })
  87. const dispatch = agentEvents(ctx, agent)
  88. const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
  89. const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch)
  90. expect(first.nextTurn).toEqual([pending])
  91. expect(second.nextTurn).toEqual([pending])
  92. expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
  93. 'next-turn': [pending],
  94. 'next-step': [],
  95. })
  96. })
  97. it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => {
  98. const outOfRange = await reconstructPersistedInbox('invalid-inbox-range', (session) => {
  99. session.append('agent/inbox/spliced', {
  100. target: 'next-turn', start: 0, removedCount: 1, inserted: [],
  101. })
  102. })
  103. expect(outOfRange.message).toBe('invalid persisted inbox splice at session seq 0')
  104. expect((outOfRange.cause as Error).message).toBe('invalid inbox splice')
  105. const pending = createUserMessage({
  106. content: [{ type: 'text', text: 'duplicate' }],
  107. source: { kind: 'user' },
  108. })
  109. const duplicate = await reconstructPersistedInbox('invalid-inbox-duplicate', (session) => {
  110. session.append('agent/inbox/spliced', {
  111. target: 'next-turn', start: 0, inserted: [pending],
  112. })
  113. session.append('agent/inbox/spliced', {
  114. target: 'next-step', start: 0, inserted: [pending],
  115. })
  116. })
  117. expect(duplicate.message).toBe('invalid persisted inbox splice at session seq 1')
  118. expect((duplicate.cause as Error).message).toBe(`message "${pending.id}" is already pending`)
  119. })
  120. it('projects inherited inbox events in a forked session', async () => {
  121. const ctx = new Context()
  122. await ctx.plugin(SessionStore)
  123. await ctx.plugin(SessionProjectionRegistry)
  124. const parent = ctx.sessions.create(SessionId('inbox-fork-parent'))
  125. const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent })
  126. const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent))
  127. const inherited = createUserMessage({
  128. content: [{ type: 'text', text: 'parent pending' }],
  129. source: { kind: 'user' },
  130. })
  131. parentInbox.append('next-turn', inherited)
  132. const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child'))
  133. const childAgent = stubAgent('inbox-fork-child', { ctx, session: child })
  134. const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent))
  135. expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length)
  136. expect(childInbox.nextTurn).toEqual([inherited])
  137. const own = createUserMessage({
  138. content: [{ type: 'text', text: 'child pending' }],
  139. source: { kind: 'user' },
  140. })
  141. childInbox.append('next-turn', own)
  142. expect(childInbox.nextTurn).toEqual([inherited, own])
  143. })
  144. it('updates the projection cell before session observers run', async () => {
  145. const { ctx, session, inbox } = await inboxAgent('inbox-live-projection')
  146. const pending = createUserMessage({
  147. content: [{ type: 'text', text: 'direct' }],
  148. source: { kind: 'user' },
  149. })
  150. let observed: readonly UserMessage[] | undefined
  151. ctx.on('session/event', (subject, event) => {
  152. if (subject === session && event.type === 'agent/inbox/spliced') {
  153. observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn']
  154. }
  155. })
  156. inbox.append('next-turn', pending)
  157. expect(observed).toEqual([pending])
  158. expect(ctx.sessionProjections.snapshot(session).values.inbox).toEqual({
  159. 'next-turn': [pending], 'next-step': [],
  160. })
  161. })
  162. it('replaces a pending message by identity across both lists', async () => {
  163. const { ctx, agent } = await inboxAgent('replace-inbox')
  164. const inserted: UserMessage[] = []
  165. const discarded: UserMessage[] = []
  166. ctx.on('agent/inbox/inserted', ({ message }) => void inserted.push(message))
  167. ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
  168. const original = createUserMessage({
  169. content: [{ type: 'text', text: 'original' }],
  170. source: { kind: 'user' },
  171. })
  172. const nextStep = createUserMessage({
  173. content: [{ type: 'text', text: 'step' }],
  174. source: { kind: 'user' },
  175. })
  176. const replacement = createUserMessage({
  177. content: [{ type: 'text', text: 'replacement' }],
  178. source: { kind: 'user' },
  179. })
  180. const editedStep = freezeMessage({
  181. ...nextStep,
  182. content: [{ type: 'text', text: 'edited step' }],
  183. })
  184. agent.inbox.append('next-turn', original)
  185. agent.inbox.append('next-step', nextStep)
  186. expect(agent.inbox.replace(createUserMessage({
  187. content: [{ type: 'text', text: 'missing' }],
  188. source: { kind: 'user' },
  189. }).id, replacement)).toBe(false)
  190. expect(agent.inbox.replace(original.id, replacement)).toBe(true)
  191. expect(agent.inbox.replace(nextStep.id, editedStep)).toBe(true)
  192. expect(agent.inbox.nextTurn).toEqual([replacement])
  193. expect(agent.inbox.nextStep).toEqual([editedStep])
  194. expect(discarded).toEqual([original, nextStep])
  195. expect(inserted).toEqual([original, nextStep, replacement, editedStep])
  196. expect(() => { agent.inbox.replace(editedStep.id, replacement) })
  197. .toThrow(`message "${replacement.id}" is already pending`)
  198. })
  199. it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', async () => {
  200. const { agent } = await inboxAgent('splice-inbox')
  201. const first = createUserMessage({
  202. content: [{ type: 'text', text: 'first' }],
  203. source: { kind: 'user' },
  204. })
  205. const second = createUserMessage({
  206. content: [{ type: 'text', text: 'second' }],
  207. source: { kind: 'user' },
  208. })
  209. const prefixed = createUserMessage({
  210. content: [{ type: 'text', text: 'prefixed' }],
  211. source: { kind: 'user' },
  212. })
  213. agent.inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
  214. expect(agent.inbox.nextTurn).toEqual([first, second])
  215. expect(agent.inbox.splice('next-turn', -1, 1, [])).toEqual([second])
  216. agent.inbox.prepend('next-turn', prefixed)
  217. expect(agent.inbox.nextTurn).toEqual([prefixed, first])
  218. expect(agent.inbox.remove(second.id)).toBe(false)
  219. expect(() => { agent.inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
  220. })
  221. it('clears both pending lists as durable cancellations', async () => {
  222. const { ctx, session, agent } = await inboxAgent('clear-inbox')
  223. const discarded: UserMessage[] = []
  224. ctx.on('agent/inbox/discarded', ({ message }) => void discarded.push(message))
  225. const nextTurn = createUserMessage({ content: [{ type: 'text', text: 'turn' }], source: { kind: 'user' } })
  226. const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } })
  227. agent.inbox.append('next-turn', nextTurn)
  228. agent.inbox.append('next-step', nextStep)
  229. const beforeClear = session.snapshotEvents().length
  230. agent.inbox.clear()
  231. expect(agent.inbox.nextTurn).toEqual([])
  232. expect(agent.inbox.nextStep).toEqual([])
  233. expect(discarded).toEqual([nextStep, nextTurn])
  234. expect(session.snapshotEvents().slice(beforeClear).map(event => event.type === 'agent/inbox/spliced'
  235. ? event.data
  236. : event.type)).toEqual([
  237. { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
  238. { target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' },
  239. ])
  240. agent.inbox.clear()
  241. expect(session.snapshotEvents()).toHaveLength(beforeClear + 2)
  242. })
  243. })