controller.host.spec.ts 9.2 KB

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