controller.host.spec.ts 9.6 KB

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