server.spec.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572
  1. import { createServer } from 'node:http'
  2. import type { IncomingMessage, Server, ServerResponse } from 'node:http'
  3. import { mkdtemp, rm } from 'node:fs/promises'
  4. import { join } from 'node:path'
  5. import { tmpdir } from 'node:os'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { Context } from 'cordis'
  8. import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
  9. import { SessionId } from '@deepseek-ai/dsh-session'
  10. import * as agentCore from '@deepseek-ai/dsh-agent-core'
  11. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  12. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  13. import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
  14. import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts'
  15. class FakeTransport implements JsonRpcTransportPeer {
  16. notifications: { method: string; params?: Record<string, unknown> }[] = []
  17. async request(method: string, params: Record<string, unknown>): Promise<unknown> {
  18. throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
  19. }
  20. notify(method: string, params?: Record<string, unknown>): void {
  21. this.notifications.push(params === undefined ? { method } : { method, params })
  22. }
  23. }
  24. const servers: Server[] = []
  25. afterEach(async () => {
  26. await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
  27. vi.unstubAllEnvs()
  28. })
  29. async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> {
  30. const requests: unknown[] = []
  31. const headers: IncomingMessage['headers'][] = []
  32. const server = createServer((request: IncomingMessage, response: ServerResponse) => {
  33. let body = ''
  34. request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
  35. request.on('end', () => {
  36. requests.push(JSON.parse(body))
  37. headers.push(request.headers)
  38. response.writeHead(200, { 'content-type': 'text/event-stream' })
  39. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
  40. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  41. response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  42. response.write('data: [DONE]\n\n')
  43. response.end()
  44. })
  45. })
  46. servers.push(server)
  47. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  48. const address = server.address()
  49. if (address === null || typeof address === 'string') throw new Error('no port')
  50. return { url: `http://127.0.0.1:${address.port}`, requests, headers }
  51. }
  52. async function makeHarness(storageDir: string) {
  53. const ctx = new Context()
  54. await ctx.plugin(agentCore)
  55. await ctx.plugin(SubagentService)
  56. await ctx.plugin(SessionPersistenceJsonl, { root: storageDir })
  57. await new Promise(resolve => setTimeout(resolve, 50))
  58. return ctx
  59. }
  60. /** Drive the owning service so test lifecycle events carry the real parent scope. */
  61. async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise<void> {
  62. const disposeProvider = ctx.subagents.registerProvider({
  63. name: info.provider,
  64. capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  65. inheritsParentContext: false,
  66. async start() {
  67. return {
  68. id: info.id,
  69. result: info.lastAssistantMessage === undefined
  70. ? Promise.reject(new Error('synthetic infrastructure failure'))
  71. : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }),
  72. dispose: () => Promise.resolve(),
  73. }
  74. },
  75. })
  76. try {
  77. const run = await ctx.subagents.start(info.provider, {
  78. parent,
  79. prompt: [],
  80. signal: new AbortController().signal,
  81. })
  82. await run.result.then(() => undefined, () => undefined)
  83. await run.dispose()
  84. } finally {
  85. disposeProvider()
  86. }
  87. }
  88. describe('HarnessSdkServer', () => {
  89. it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => {
  90. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
  91. const llmServer = await mockCompletionServer()
  92. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  93. vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
  94. const ctx = await makeHarness(storageDir)
  95. try {
  96. const transport = new FakeTransport()
  97. const server = new HarnessSdkServer(ctx, transport)
  98. const init = await server.handleRequest('initialize', {
  99. cwd: storageDir,
  100. model: 'dsagent-model',
  101. }) as { serverInfo: { name: string } }
  102. expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
  103. await server.handleRequest('session/prompt', {
  104. sessionId: 'main',
  105. contentBlocks: [{ type: 'text', text: 'fix it' }],
  106. })
  107. expect(llmServer.requests).toHaveLength(1)
  108. const body = llmServer.requests[0] as { model: string; messages: { role: string }[] }
  109. expect(body.model).toBe('dsagent-model')
  110. expect(body.messages[0]?.role).toBe('system')
  111. expect(body.messages.at(-1)?.role).toBe('user')
  112. expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
  113. expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
  114. expect(transport.notifications.at(-1)).toMatchObject({
  115. method: 'session.finished',
  116. params: { sessionId: 'main', status: 'ok' },
  117. })
  118. await server.handleRequest('session/prompt', {
  119. sessionId: 'main',
  120. contentBlocks: [{ type: 'text', text: 'again' }],
  121. })
  122. expect(llmServer.requests).toHaveLength(2)
  123. const orphanHandle = await ctx.agents.create({
  124. agentId: AgentId('orphan-agent'),
  125. sessionId: SessionId('orphan-session'),
  126. meta: { cwd: storageDir },
  127. agentOptions: { model: 'dsagent-model' },
  128. })
  129. orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }])
  130. await orphanHandle.agent.whenIdle()
  131. await orphanHandle.dispose()
  132. expect(llmServer.requests).toHaveLength(3)
  133. await server.handleRequest('shutdown', undefined)
  134. } finally {
  135. await ctx.fiber.dispose()
  136. await rm(storageDir, { recursive: true, force: true })
  137. }
  138. })
  139. it('rejects overlapping prompts for one session without serializing other sessions', async () => {
  140. let releaseMain: (() => void) | undefined
  141. const firstMainIdle = new Promise<void>((resolve) => { releaseMain = resolve })
  142. const mainWhenIdle = vi.fn<() => Promise<void>>()
  143. .mockReturnValueOnce(firstMainIdle)
  144. .mockResolvedValue(undefined)
  145. const mainSend = vi.fn()
  146. const mainAgent = {
  147. send: mainSend,
  148. whenIdle: mainWhenIdle,
  149. } as unknown as Agent
  150. const otherSend = vi.fn()
  151. const otherAgent = {
  152. send: otherSend,
  153. whenIdle: vi.fn(() => Promise.resolve()),
  154. } as unknown as Agent
  155. const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
  156. const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
  157. const create = vi.fn(async (options: { agentId: AgentId }) =>
  158. String(options.agentId) === 'main' ? mainHandle : otherHandle)
  159. const ctx = {
  160. on: vi.fn(() => () => undefined),
  161. agents: { create, get: () => undefined },
  162. get: () => undefined,
  163. } as unknown as Context
  164. const server = new HarnessSdkServer(ctx, new FakeTransport())
  165. const prompt = (sessionId: string, text: string) => server.prompt({
  166. sessionId,
  167. contentBlocks: [{ type: 'text', text }],
  168. })
  169. const first = prompt('main', 'first')
  170. await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() })
  171. await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main')
  172. await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true })
  173. releaseMain?.()
  174. await expect(first).resolves.toEqual({ accepted: true })
  175. await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true })
  176. mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed'))
  177. await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed')
  178. await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true })
  179. expect(mainSend).toHaveBeenCalledTimes(4)
  180. expect(otherSend).toHaveBeenCalledOnce()
  181. await server.shutdown()
  182. expect(mainHandle.dispose).toHaveBeenCalledOnce()
  183. expect(otherHandle.dispose).toHaveBeenCalledOnce()
  184. })
  185. it('notifies the host when a child session is created with parent lineage', async () => {
  186. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
  187. const ctx = await makeHarness(storageDir)
  188. try {
  189. const transport = new FakeTransport()
  190. const server = new HarnessSdkServer(ctx, transport)
  191. ctx.sessions.create(SessionId('root-session'), {
  192. meta: { cwd: storageDir },
  193. })
  194. ctx.sessions.create(SessionId('child-session'), {
  195. meta: { cwd: storageDir, parentSession: SessionId('main') },
  196. })
  197. expect(transport.notifications).toContainEqual({
  198. method: 'subagent.started',
  199. params: {
  200. parentSessionId: 'main',
  201. childSessionId: 'child-session',
  202. },
  203. })
  204. await server.shutdown()
  205. } finally {
  206. await ctx.fiber.dispose()
  207. await rm(storageDir, { recursive: true, force: true })
  208. }
  209. })
  210. it('creates an SDK session without an optional system prompt', async () => {
  211. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
  212. const llmServer = await mockCompletionServer()
  213. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  214. vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
  215. const ctx = await makeHarness(storageDir)
  216. try {
  217. const server = new HarnessSdkServer(ctx, new FakeTransport())
  218. await server.initialize({ cwd: storageDir, model: 'plain-model' })
  219. await server.prompt({
  220. sessionId: 'plain',
  221. contentBlocks: [{ type: 'text', text: 'hello' }],
  222. })
  223. expect(llmServer.requests).toHaveLength(1)
  224. await server.shutdown()
  225. } finally {
  226. await ctx.fiber.dispose()
  227. await rm(storageDir, { recursive: true, force: true })
  228. }
  229. })
  230. it('notifies the host when a subagent run settles', async () => {
  231. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-'))
  232. const ctx = await makeHarness(storageDir)
  233. try {
  234. const transport = new FakeTransport()
  235. const server = new HarnessSdkServer(ctx, transport)
  236. const parentHandle = await ctx.agents.create({
  237. agentId: AgentId('parent-agent'),
  238. sessionId: SessionId('main'),
  239. meta: { cwd: storageDir },
  240. agentOptions: { model: 'deepseek' },
  241. })
  242. const handle = await ctx.agents.create({
  243. agentId: AgentId('child-agent'),
  244. sessionId: SessionId('child-session'),
  245. meta: { cwd: storageDir, parentSession: SessionId('main') },
  246. agentOptions: { model: 'deepseek' },
  247. })
  248. await settleSubagent(ctx, parentHandle.agent, {
  249. provider: 'spawn',
  250. id: AgentId('child-agent'),
  251. stopReason: 'completed',
  252. lastAssistantMessage: [{ type: 'text', text: 'child done' }],
  253. })
  254. expect(transport.notifications).toContainEqual({
  255. method: 'subagent.finished',
  256. params: {
  257. provider: 'spawn',
  258. agentId: 'child-agent',
  259. parentSessionId: 'main',
  260. childSessionId: 'child-session',
  261. status: 'ok',
  262. stopReason: 'completed',
  263. lastAssistantMessage: [{ type: 'text', text: 'child done' }],
  264. },
  265. })
  266. await handle.dispose()
  267. await parentHandle.dispose()
  268. await server.shutdown()
  269. } finally {
  270. await ctx.fiber.dispose()
  271. await rm(storageDir, { recursive: true, force: true })
  272. }
  273. })
  274. it('falls back to live agent lineage for uncached subagent end events', async () => {
  275. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
  276. const ctx = await makeHarness(storageDir)
  277. let parentHandle: AgentHandle | undefined
  278. let handle: AgentHandle | undefined
  279. let failedHandle: AgentHandle | undefined
  280. try {
  281. parentHandle = await ctx.agents.create({
  282. agentId: AgentId('fallback-parent-agent'),
  283. sessionId: SessionId('fallback-parent'),
  284. meta: { cwd: storageDir },
  285. agentOptions: { model: 'deepseek' },
  286. })
  287. handle = await ctx.agents.create({
  288. agentId: AgentId('fallback-child-agent'),
  289. sessionId: SessionId('fallback-child-session'),
  290. meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
  291. agentOptions: { model: 'deepseek' },
  292. })
  293. failedHandle = await ctx.agents.create({
  294. agentId: AgentId('failed-child-agent'),
  295. sessionId: SessionId('failed-child-session'),
  296. meta: { cwd: storageDir },
  297. agentOptions: { model: 'deepseek' },
  298. })
  299. const transport = new FakeTransport()
  300. const server = new HarnessSdkServer(ctx, transport)
  301. await settleSubagent(ctx, parentHandle.agent, {
  302. provider: 'fork',
  303. id: AgentId('fallback-child-agent'),
  304. stopReason: 'max-tokens',
  305. lastAssistantMessage: [],
  306. })
  307. await settleSubagent(ctx, parentHandle.agent, {
  308. provider: 'fork',
  309. id: AgentId('failed-child-agent'),
  310. stopReason: 'error',
  311. })
  312. await settleSubagent(ctx, parentHandle.agent, {
  313. provider: 'fork',
  314. id: AgentId('missing-child-agent'),
  315. stopReason: 'error',
  316. })
  317. expect(transport.notifications).toContainEqual({
  318. method: 'subagent.finished',
  319. params: {
  320. provider: 'fork',
  321. agentId: 'fallback-child-agent',
  322. parentSessionId: 'fallback-parent',
  323. childSessionId: 'fallback-child-session',
  324. status: 'error',
  325. stopReason: 'max-tokens',
  326. lastAssistantMessage: [],
  327. },
  328. })
  329. expect(transport.notifications).toContainEqual({
  330. method: 'subagent.finished',
  331. params: {
  332. provider: 'fork',
  333. agentId: 'failed-child-agent',
  334. childSessionId: 'failed-child-session',
  335. status: 'error',
  336. stopReason: 'error',
  337. },
  338. })
  339. expect(transport.notifications.some(n =>
  340. n.method === 'subagent.finished'
  341. && n.params?.agentId === 'missing-child-agent',
  342. )).toBe(false)
  343. await server.shutdown()
  344. } finally {
  345. await handle?.dispose()
  346. await failedHandle?.dispose()
  347. await parentHandle?.dispose()
  348. await ctx.fiber.dispose()
  349. await rm(storageDir, { recursive: true, force: true })
  350. }
  351. })
  352. it('does not re-register an LLM adapter that already exists', async () => {
  353. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
  354. const ctx = await makeHarness(storageDir)
  355. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  356. await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] })
  357. try {
  358. const server = new HarnessSdkServer(ctx, new FakeTransport())
  359. const inspect = server as unknown as { hasAdapterFor(model: string): boolean }
  360. expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true)
  361. expect(inspect.hasAdapterFor('missing-model')).toBe(false)
  362. await server.initialize({ cwd: storageDir, model: 'preinstalled-model' })
  363. expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model'])
  364. await server.shutdown()
  365. } finally {
  366. await ctx.fiber.dispose()
  367. await rm(storageDir, { recursive: true, force: true })
  368. }
  369. })
  370. it('registers a missing model when an LLM service already exists', async () => {
  371. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
  372. const ctx = await makeHarness(storageDir)
  373. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  374. await ctx.plugin(LlmDeepSeek, { models: ['other-model'] })
  375. try {
  376. const server = new HarnessSdkServer(ctx, new FakeTransport())
  377. await server.initialize({ cwd: storageDir, model: 'new-model' })
  378. expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model']))
  379. await server.shutdown()
  380. } finally {
  381. await ctx.fiber.dispose()
  382. await rm(storageDir, { recursive: true, force: true })
  383. }
  384. })
  385. it('classifies defensive finish states', async () => {
  386. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-'))
  387. const ctx = await makeHarness(storageDir)
  388. try {
  389. const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
  390. finishedStatus(reason: unknown): 'ok' | 'error'
  391. shutdown(): Promise<Record<string, never>>
  392. }
  393. expect(server.finishedStatus(undefined)).toBe('error')
  394. expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error')
  395. expect(server.finishedStatus({ kind: 'error' })).toBe('error')
  396. await server.shutdown()
  397. } finally {
  398. await ctx.fiber.dispose()
  399. await rm(storageDir, { recursive: true, force: true })
  400. }
  401. })
  402. it('reports no adapter when the LLM service is absent', async () => {
  403. const ctx = new Context()
  404. try {
  405. const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
  406. hasAdapterFor(model: string): boolean
  407. shutdown(): Promise<Record<string, never>>
  408. }
  409. expect(server.hasAdapterFor('missing-model')).toBe(false)
  410. await server.shutdown()
  411. } finally {
  412. await ctx.fiber.dispose()
  413. }
  414. })
  415. it('rejects unknown JSON-RPC runtime methods', async () => {
  416. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-'))
  417. const ctx = await makeHarness(storageDir)
  418. try {
  419. const server = new HarnessSdkServer(ctx, new FakeTransport())
  420. await expect(server.handleRequest('does/not/exist', {}))
  421. .rejects
  422. .toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist')
  423. await server.shutdown()
  424. } finally {
  425. await ctx.fiber.dispose()
  426. await rm(storageDir, { recursive: true, force: true })
  427. }
  428. })
  429. it('coalesces concurrent session creation and retries a failed creation', async () => {
  430. let resolveShared: ((handle: AgentHandle) => void) | undefined
  431. const sharedCreation = new Promise<AgentHandle>((resolve) => { resolveShared = resolve })
  432. const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
  433. const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
  434. const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
  435. .mockReturnValueOnce(sharedCreation)
  436. .mockRejectedValueOnce(new Error('creation failed'))
  437. .mockResolvedValueOnce(retryHandle)
  438. const ctx = {
  439. on: vi.fn(() => () => undefined),
  440. agents: { create, get: () => undefined },
  441. get: () => undefined,
  442. } as unknown as Context
  443. const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
  444. getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }>
  445. shutdown(): Promise<Record<string, never>>
  446. }
  447. const first = server.getOrCreateSession('shared')
  448. const second = server.getOrCreateSession('shared')
  449. expect(create).toHaveBeenCalledTimes(1)
  450. resolveShared?.(sharedHandle)
  451. const [firstRecord, secondRecord] = await Promise.all([first, second])
  452. expect(firstRecord).toBe(secondRecord)
  453. await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed')
  454. await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle })
  455. expect(create).toHaveBeenCalledTimes(3)
  456. await server.shutdown()
  457. expect(sharedHandle.dispose).toHaveBeenCalledOnce()
  458. expect(retryHandle.dispose).toHaveBeenCalledOnce()
  459. await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
  460. })
  461. it('resolves a relative cwd before creating the session', async () => {
  462. const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
  463. .mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
  464. const ctx = {
  465. on: vi.fn(() => () => undefined),
  466. agents: { create, get: () => undefined },
  467. get: () => ({ models: () => ['model'] }),
  468. } as unknown as Context
  469. const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
  470. initialize(params: { cwd: string; model: string }): Promise<unknown>
  471. getOrCreateSession(sessionId: string): Promise<unknown>
  472. shutdown(): Promise<Record<string, never>>
  473. }
  474. await server.initialize({ cwd: '.', model: 'model' })
  475. await server.getOrCreateSession('relative')
  476. expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } }))
  477. await server.shutdown()
  478. })
  479. it('settles every teardown and aggregates multiple failures', async () => {
  480. const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
  481. const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
  482. const ctx = {
  483. on: vi.fn(() => () => undefined),
  484. agents: { create: vi.fn(), get: () => undefined },
  485. get: () => undefined,
  486. } as unknown as Context
  487. const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as {
  488. sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
  489. shutdown(): Promise<Record<string, never>>
  490. }
  491. server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false })
  492. server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false })
  493. await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
  494. expect(firstDispose).toHaveBeenCalledOnce()
  495. expect(secondDispose).toHaveBeenCalledOnce()
  496. })
  497. it('continues teardown after a subscription disposer fails', async () => {
  498. let subscription = 0
  499. const listenerFailure = new Error('listener teardown failed')
  500. const on = vi.fn(() => {
  501. subscription += 1
  502. return subscription === 1 ? () => { throw listenerFailure } : () => undefined
  503. })
  504. const ctx = {
  505. on,
  506. agents: { create: vi.fn(), get: () => undefined },
  507. get: () => undefined,
  508. } as unknown as Context
  509. const server = new HarnessSdkServer(ctx, new FakeTransport())
  510. await expect(server.shutdown()).rejects.toBe(listenerFailure)
  511. expect(on).toHaveBeenCalledTimes(4)
  512. })
  513. })