agent.host.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import { mkdtempSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import AgentRegistry from '@deepseek-ai/dsh-agent'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  9. import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
  10. import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
  11. import { afterEach, describe, expect, it, vi } from 'vitest'
  12. import {
  13. ApiSessionAgentController,
  14. ApiSessionCwdConflict,
  15. ApiSessionNotFound,
  16. ApiSessionSubagentOwnership,
  17. inspectApiSession,
  18. } from '../src/agent.ts'
  19. const roots: Context[] = []
  20. afterEach(async () => {
  21. await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
  22. })
  23. async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> {
  24. const ctx = new Context()
  25. roots.push(ctx)
  26. await ctx.plugin(TypertRegistry)
  27. await ctx.plugin(SessionStore)
  28. await ctx.plugin(AgentRegistry)
  29. ctx.provide('agentDefaultModel', {
  30. currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
  31. saveSelection: () => Promise.resolve(),
  32. } as never)
  33. return { ctx, agents: new ApiSessionAgentController(ctx) }
  34. }
  35. function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
  36. return {
  37. version: 0,
  38. id: SessionId(id),
  39. createdAt: 1,
  40. ...(cwd === null ? {} : { cwd }),
  41. }
  42. }
  43. function agent(ctx: Context, meta: SessionHeader): Agent {
  44. const session = ctx.sessions.create(meta.id, { meta })
  45. return { id: meta.id, session, status: 'idle', ctx } as Agent
  46. }
  47. function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent {
  48. return {
  49. id: meta.id,
  50. session: { id: meta.id, header: meta, events: [] },
  51. status: 'idle',
  52. ctx,
  53. } as unknown as Agent
  54. }
  55. describe('ApiSession identity failures', () => {
  56. it('describes cwd conflicts with and without a recorded cwd', () => {
  57. expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message)
  58. .toContain('records no cwd')
  59. expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message)
  60. .toContain('belongs to "/existing"')
  61. })
  62. it('rejects absent persistence, catalog misses, and cwd-less inspected artifacts', async () => {
  63. const ctx = new Context()
  64. roots.push(ctx)
  65. await expect(inspectApiSession(ctx, SessionId('missing')))
  66. .rejects.toThrow('session persistence is not configured')
  67. const inspect = vi.fn(() => Promise.resolve({ meta: header('missing'), events: [] as SessionEvent[] }))
  68. const disposeMissing = ctx.provide('sessionPersistence', {
  69. list: () => Promise.resolve([]),
  70. inspect,
  71. } as never)
  72. await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound)
  73. expect(inspect).not.toHaveBeenCalled()
  74. disposeMissing()
  75. const listed = header('cwd-less-catalog', null)
  76. const disposeListed = ctx.provide('sessionPersistence', {
  77. list: () => Promise.resolve([listed]),
  78. inspect,
  79. } as never)
  80. await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
  81. disposeListed()
  82. const catalog = header('cwd-less-inspect')
  83. const inspected = header('cwd-less-inspect', null)
  84. ctx.provide('sessionPersistence', {
  85. list: () => Promise.resolve([catalog]),
  86. inspect: () => Promise.resolve({ meta: inspected, events: [] }),
  87. } as never)
  88. await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound)
  89. })
  90. })
  91. describe('ApiSession Agent lookup and recovery', () => {
  92. it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => {
  93. const { ctx } = await harness()
  94. const live = agent(ctx, header('live'))
  95. ctx.agents.register(live)
  96. ctx.provide('sessionPersistence', {
  97. list: () => Promise.resolve([]),
  98. inspect: vi.fn(),
  99. } as never)
  100. const host = ctx.typert.contexts.getHost('agent')
  101. if (host === undefined) throw new Error('Agent Context resolver was not registered')
  102. await expect(host.resolve(live.id)).resolves.toBe(live.ctx)
  103. await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure)
  104. })
  105. it('returns raced ordinary Agents and ownership failures after resume throws', async () => {
  106. const ordinary = await harness()
  107. const ordinaryMeta = header('ordinary-race')
  108. ordinary.ctx.provide('sessionPersistence', {
  109. list: () => Promise.resolve([ordinaryMeta]),
  110. inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }),
  111. } as never)
  112. const winner = agent(ordinary.ctx, ordinaryMeta)
  113. vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => {
  114. ordinary.ctx.agents.register(winner)
  115. throw new Error('raced publication')
  116. })
  117. await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner })
  118. const child = await harness()
  119. const childMeta = header('child-race')
  120. child.ctx.provide('sessionPersistence', {
  121. list: () => Promise.resolve([childMeta]),
  122. inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
  123. } as never)
  124. vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => {
  125. child.ctx.sessions.create(childMeta.id, {
  126. meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
  127. })
  128. throw new Error('raced child publication')
  129. })
  130. await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
  131. error: { code: 'agent-busy' },
  132. })
  133. })
  134. it('reports not-found and ordinary resume failures without fabricating an Agent', async () => {
  135. const missing = await harness()
  136. missing.ctx.provide('sessionPersistence', {
  137. list: () => Promise.resolve([]),
  138. inspect: vi.fn(),
  139. } as never)
  140. await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({
  141. error: { code: 'session-not-found' },
  142. })
  143. const failed = await harness()
  144. const meta = header('failed')
  145. failed.ctx.provide('sessionPersistence', {
  146. list: () => Promise.resolve([meta]),
  147. inspect: () => Promise.resolve({ meta, events: [] }),
  148. } as never)
  149. vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable'))
  150. await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({
  151. error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string },
  152. })
  153. })
  154. })
  155. describe('ApiSession create or adoption', () => {
  156. it('shares one in-flight creation between concurrent callers', async () => {
  157. const { ctx, agents } = await harness()
  158. const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-'))
  159. const meta = header('concurrent-create', cwd)
  160. const created = unpublishedAgent(ctx, meta)
  161. let release!: () => void
  162. const gate = new Promise<void>((resolve) => { release = resolve })
  163. const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => {
  164. await gate
  165. return { agent: created, dispose: () => Promise.resolve() }
  166. })
  167. const first = agents.ensureSession(meta.id, cwd, false)
  168. const second = agents.ensureSession(meta.id, cwd, false)
  169. release()
  170. await expect(Promise.all([first, second])).resolves.toEqual([created, created])
  171. expect(create).toHaveBeenCalledOnce()
  172. })
  173. it('accepts a raced ordinary creation and rejects a raced attached child', async () => {
  174. const ordinary = await harness()
  175. const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-'))
  176. const ordinaryMeta = header('create-race', cwd)
  177. const winner = agent(ordinary.ctx, ordinaryMeta)
  178. vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => {
  179. ordinary.ctx.agents.register(winner)
  180. throw new Error('raced creation')
  181. })
  182. await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false))
  183. .resolves.toBe(winner)
  184. const child = await harness()
  185. const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-'))
  186. const childId = SessionId('create-child-race')
  187. vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => {
  188. child.ctx.sessions.create(childId, {
  189. meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' },
  190. })
  191. throw new Error('raced child creation')
  192. })
  193. await expect(child.agents.ensureSession(childId, childCwd, false))
  194. .rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
  195. })
  196. it('validates ownership and cwd on the Agent returned by creation', async () => {
  197. const child = await harness()
  198. const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-'))
  199. const childMeta = {
  200. ...header('returned-child', childCwd),
  201. parentSession: SessionId('parent'),
  202. origin: 'subagent' as const,
  203. }
  204. const childAgent = unpublishedAgent(child.ctx, childMeta)
  205. vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({
  206. agent: childAgent,
  207. dispose: () => Promise.resolve(),
  208. })
  209. await expect(child.agents.ensureSession(childMeta.id, childCwd, false))
  210. .rejects.toBeInstanceOf(ApiSessionSubagentOwnership)
  211. const wrong = await harness()
  212. const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-'))
  213. const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other'))
  214. vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({
  215. agent: wrongAgent,
  216. dispose: () => Promise.resolve(),
  217. })
  218. await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false))
  219. .rejects.toBeInstanceOf(ApiSessionCwdConflict)
  220. })
  221. it('resumes a matching persisted identity and preserves its selected preset', async () => {
  222. const { ctx, agents } = await harness()
  223. const meta = { ...header('stored'), agentPreset: 'minimal' }
  224. const events = [{
  225. type: 'agent-preset/selected',
  226. seq: 0,
  227. time: 1,
  228. data: { agentPreset: 'minimal' },
  229. }] as SessionEvent[]
  230. ctx.provide('sessionPersistence', {
  231. list: () => Promise.resolve([meta]),
  232. inspect: () => Promise.resolve({ meta, events }),
  233. } as never)
  234. ctx.provide('agentPresets', {
  235. resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }),
  236. mount: () => Promise.resolve(),
  237. } as never)
  238. const resumed = {
  239. id: meta.id,
  240. session: { id: meta.id, header: meta, events },
  241. status: 'idle',
  242. ctx,
  243. } as unknown as Agent
  244. const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({
  245. agent: resumed,
  246. dispose: () => Promise.resolve(),
  247. })
  248. await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed)
  249. expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id }))
  250. })
  251. it('rejects an ownership race before resume and a persisted cwd conflict', async () => {
  252. const child = await harness()
  253. const childMeta = header('resume-child-race')
  254. child.ctx.provide('sessionPersistence', {
  255. list: () => Promise.resolve([childMeta]),
  256. inspect: () => Promise.resolve({ meta: childMeta, events: [] }),
  257. } as never)
  258. child.ctx.provide('agentPresets', {
  259. resolve: () => {
  260. child.ctx.sessions.create(childMeta.id, {
  261. meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' },
  262. })
  263. return Promise.resolve({ id: 'standard' })
  264. },
  265. mount: () => Promise.resolve(),
  266. } as never)
  267. await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({
  268. error: { code: 'agent-busy' },
  269. })
  270. const conflict = await harness()
  271. const stored = header('stored-cwd-conflict', '/stored')
  272. conflict.ctx.provide('sessionPersistence', {
  273. list: () => Promise.resolve([stored]),
  274. inspect: () => Promise.resolve({ meta: stored, events: [] }),
  275. } as never)
  276. await expect(conflict.agents.ensureSession(stored.id, '/requested', true))
  277. .rejects.toBeInstanceOf(ApiSessionCwdConflict)
  278. })
  279. it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => {
  280. const { agents } = await harness()
  281. const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-'))
  282. const file = join(parent, 'file')
  283. writeFileSync(file, 'not a directory')
  284. await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false))
  285. .rejects.toThrow('failed to ensure project directory')
  286. const composition = await agents.composeAgent(undefined)
  287. expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent')
  288. })
  289. })