controller.host.spec.ts 7.9 KB

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