controller.host.spec.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import { Context } from '@deepseek-ai/cordis'
  2. import AgentRegistry from '@deepseek-ai/dsh-agent'
  3. import type { Agent } from '@deepseek-ai/dsh-agent'
  4. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  5. import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
  6. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  7. import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
  8. import { describe, expect, it, vi } from 'vitest'
  9. import SessionController from '../src/index.ts'
  10. import type { ApiSessionAgentController } from '../src/agent.ts'
  11. import { createSessionTestController, testSessionPersistence } from './test-remote.ts'
  12. const defaults = {
  13. defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  14. cwd: '/tmp',
  15. }
  16. describe('SessionController facade', () => {
  17. it('does not require the Tools service', () => {
  18. expect(SessionController.inject).not.toContain('tools')
  19. })
  20. it('owns Host service methods and publishes Agent lifecycle projections', async () => {
  21. const ctx = new Context()
  22. await ctx.plugin(SessionStore)
  23. await ctx.plugin(AgentRegistry)
  24. const sessionId = SessionId('controller-session')
  25. const header: SessionHeader = {
  26. version: SESSION_FORMAT_VERSION,
  27. id: sessionId,
  28. createdAt: 1,
  29. cwd: '/workspace',
  30. isSeeded: false,
  31. }
  32. const events: SessionEvent[] = []
  33. const inspect = vi.fn(() => Promise.resolve({
  34. meta: header,
  35. inheritedEventCount: SessionLogOffset(0),
  36. events,
  37. }))
  38. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  39. list: () => Promise.resolve([header]),
  40. inspect,
  41. }) as never)
  42. let uploadResolver: ((sessionId: SessionId) => Promise<Agent>) | undefined
  43. ctx.provide('fileUploads', {
  44. registerAgentResolver: (resolve: (sessionId: SessionId) => Promise<Agent>) => {
  45. uploadResolver = resolve
  46. return () => {}
  47. },
  48. resolve: () => undefined,
  49. bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
  50. retirePrompt: () => {},
  51. } as never)
  52. const controller = createSessionTestController(ctx, defaults)
  53. const status = vi.fn()
  54. const failure = vi.fn()
  55. const activity = vi.fn()
  56. ctx.on('api-session/status', status)
  57. ctx.on('api-session/error', failure)
  58. ctx.on('api-session/activity', activity)
  59. await expect(controller.inspect(sessionId)).resolves.toEqual({
  60. meta: header,
  61. inheritedEventCount: SessionLogOffset(0),
  62. events,
  63. })
  64. expect(inspect).toHaveBeenCalledOnce()
  65. const session = ctx.sessions.create(sessionId, { meta: header })
  66. const agent = {
  67. id: sessionId,
  68. session,
  69. status: 'idle',
  70. ctx,
  71. } as Agent
  72. ctx.agents.register(agent)
  73. const resolveUploadAgent = (id: SessionId): Promise<Agent> => {
  74. if (uploadResolver === undefined) throw new Error('file upload resolver was not registered')
  75. return uploadResolver(id)
  76. }
  77. await expect(resolveUploadAgent(sessionId)).resolves.toBe(agent)
  78. const activationError = new RemoteError('session/not-found', 'missing upload session', { sessionId })
  79. vi.spyOn(
  80. (controller as unknown as { agents: ApiSessionAgentController }).agents,
  81. 'resolveAgent',
  82. ).mockResolvedValueOnce({ error: activationError })
  83. await expect(resolveUploadAgent(sessionId)).rejects.toBe(activationError)
  84. const consumeSelection = vi.spyOn(
  85. (controller as unknown as { agents: ApiSessionAgentController }).agents,
  86. 'consumeSelection',
  87. )
  88. await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent })
  89. await expect(controller.inspect(sessionId)).resolves.toEqual({
  90. meta: header,
  91. inheritedEventCount: SessionLogOffset(0),
  92. events,
  93. })
  94. expect(inspect).toHaveBeenCalledOnce()
  95. ctx.emit('agent/status', { agent, status: 'running' })
  96. ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') })
  97. session.append('user/message', createUserMessage({
  98. content: [{ type: 'text', text: 'hello' }],
  99. source: { kind: 'user' },
  100. }), { surfaceOp: 'append' })
  101. session.append('user/message', createUserMessage({
  102. content: [{ type: 'text', text: 'browser prompt' }],
  103. source: { kind: 'user', rpcId: 'controller-rpc' as never },
  104. }), { surfaceOp: 'append' })
  105. expect(status).toHaveBeenCalledWith(sessionId, true)
  106. expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure'))
  107. expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number))
  108. session.append('request/header', {
  109. header: { config: { provider: 'fixture', model: 'fixture-model' } },
  110. reason: 'initial',
  111. })
  112. expect(consumeSelection).toHaveBeenCalledWith(
  113. agent, 'fixture', 'fixture-model', undefined,
  114. )
  115. const unowned = ctx.sessions.create(SessionId('controller-unowned'), {
  116. meta: { cwd: '/workspace' },
  117. })
  118. unowned.append('request/header', {
  119. header: { config: { provider: 'fixture', model: 'other-model' } },
  120. reason: 'initial',
  121. })
  122. expect(consumeSelection).toHaveBeenCalledTimes(1)
  123. const abort = new AbortController()
  124. const iterator = controller.follow({
  125. address: { kind: 'session', sessionId },
  126. }, abort.signal)[Symbol.asyncIterator]()
  127. await expect(iterator.next()).resolves.toMatchObject({
  128. done: false,
  129. value: { type: 'snapshot', cursor: 2 },
  130. })
  131. abort.abort()
  132. await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
  133. })
  134. it.each(['success', 'domain-error', 'throw'] as const)(
  135. 'promotes a prepared follow observation in the background: %s',
  136. async (outcome) => {
  137. const ctx = new Context()
  138. await ctx.plugin(SessionStore)
  139. await ctx.plugin(AgentRegistry)
  140. const sessionId = SessionId(`background-${outcome}`)
  141. const header: SessionHeader = {
  142. version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
  143. }
  144. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  145. list: () => Promise.resolve([header]),
  146. inspect: () => Promise.resolve({
  147. meta: header,
  148. inheritedEventCount: SessionLogOffset(0),
  149. events: [],
  150. }),
  151. }) as never)
  152. const controller = createSessionTestController(ctx, defaults)
  153. const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
  154. const apiError = vi.fn()
  155. ctx.on('api-session/error', apiError)
  156. const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  157. const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent
  158. const resolve = vi.spyOn(agents, 'resolveObservedAgent')
  159. if (outcome === 'success') resolve.mockResolvedValue({ agent: live })
  160. else if (outcome === 'domain-error') {
  161. resolve.mockResolvedValue({
  162. error: new RemoteError('gateway/internal', 'activation unavailable', {}),
  163. })
  164. } else {
  165. resolve.mockRejectedValue(new Error('activation crashed'))
  166. }
  167. const abort = new AbortController()
  168. const iterator = controller.follow({
  169. address: { kind: 'session', sessionId },
  170. }, abort.signal)[Symbol.asyncIterator]()
  171. await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
  172. const waiting = iterator.next()
  173. await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() })
  174. if (outcome === 'domain-error') {
  175. await vi.waitFor(() => {
  176. expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable')
  177. })
  178. } else if (outcome === 'throw') {
  179. await vi.waitFor(() => {
  180. expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed'))
  181. })
  182. } else {
  183. expect(apiError).not.toHaveBeenCalled()
  184. }
  185. abort.abort()
  186. await expect(waiting).resolves.toMatchObject({ done: true })
  187. await ctx.fiber.dispose()
  188. },
  189. )
  190. it('waits for an admitted background promotion during teardown', async () => {
  191. const ctx = new Context()
  192. await ctx.plugin(SessionStore)
  193. await ctx.plugin(AgentRegistry)
  194. const sessionId = SessionId('background-disposal')
  195. const header: SessionHeader = {
  196. version: SESSION_FORMAT_VERSION, id: sessionId, createdAt: 1, cwd: '/workspace', isSeeded: false,
  197. }
  198. ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
  199. list: () => Promise.resolve([header]),
  200. inspect: () => Promise.resolve({
  201. meta: header,
  202. inheritedEventCount: SessionLogOffset(0),
  203. events: [],
  204. }),
  205. }) as never)
  206. const controller = createSessionTestController(ctx, defaults)
  207. const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
  208. const started = Promise.withResolvers<undefined>()
  209. const release = Promise.withResolvers<undefined>()
  210. vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => {
  211. started.resolve(undefined)
  212. await release.promise
  213. return {
  214. agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent,
  215. }
  216. })
  217. const iterator = controller.follow({
  218. address: { kind: 'session', sessionId },
  219. }, new AbortController().signal)[Symbol.asyncIterator]()
  220. await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } })
  221. const waiting = iterator.next()
  222. await started.promise
  223. let disposed = false
  224. const disposal = ctx.fiber.dispose().then(() => { disposed = true })
  225. await Promise.resolve()
  226. expect(disposed).toBe(false)
  227. release.resolve(undefined)
  228. await disposal
  229. await expect(waiting).resolves.toMatchObject({ done: true })
  230. })
  231. })