server.spec.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056
  1. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  2. import { createServer } from 'node:http'
  3. import type { IncomingMessage, Server, ServerResponse } from 'node:http'
  4. import { mkdtemp, rm } from 'node:fs/promises'
  5. import { join } from 'node:path'
  6. import { tmpdir } from 'node:os'
  7. import { afterEach, describe, expect, it, vi } from 'vitest'
  8. import { Context } from '@deepseek-ai/cordis'
  9. import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
  10. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  11. import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
  12. import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
  13. import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
  14. import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent'
  15. import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol'
  16. import { HarnessSdkJsonRpcServer } from '../src/index.ts'
  17. class FakeTransport implements JsonRpcTransportPeer {
  18. notifications: { method: string; params?: Record<string, unknown> }[] = []
  19. async request(method: string, params: object): Promise<unknown> {
  20. throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
  21. }
  22. notify(method: string, params?: object): void {
  23. this.notifications.push(params === undefined ? { method } : { method, params: params as Record<string, unknown> })
  24. }
  25. }
  26. const servers: Server[] = []
  27. afterEach(async () => {
  28. await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
  29. vi.unstubAllEnvs()
  30. })
  31. async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> {
  32. const requests: unknown[] = []
  33. const headers: IncomingMessage['headers'][] = []
  34. const server = createServer((request: IncomingMessage, response: ServerResponse) => {
  35. let body = ''
  36. request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
  37. request.on('end', () => {
  38. requests.push(JSON.parse(body))
  39. headers.push(request.headers)
  40. response.writeHead(200, { 'content-type': 'text/event-stream' })
  41. response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n')
  42. response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n')
  43. response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n')
  44. response.write('data: [DONE]\n\n')
  45. response.end()
  46. })
  47. })
  48. servers.push(server)
  49. await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
  50. const address = server.address()
  51. if (address === null || typeof address === 'string') throw new Error('no port')
  52. return { url: `http://127.0.0.1:${address.port}`, requests, headers }
  53. }
  54. async function makeHarness(storageDir: string) {
  55. const ctx = new Context()
  56. await ctx.plugin(agentCore, { workspaceContext: false })
  57. await ctx.plugin(SubagentRuntime)
  58. await ctx.plugin(JsonlSessionPersistence, { root: storageDir })
  59. await new Promise(resolve => setTimeout(resolve, 50))
  60. return ctx
  61. }
  62. /** Drive the owning service so test lifecycle events carry the real parent scope. */
  63. async function settleSubagent(
  64. ctx: Context,
  65. parent: Agent,
  66. info: Omit<SubagentRunEndInfo, 'runId' | 'local'> & { localAgent: Agent | undefined },
  67. beforeSettle?: () => Promise<void>,
  68. ): Promise<void> {
  69. const result = Promise.withResolvers<SubagentResult>()
  70. const disposeProvider = ctx.subagents.registerProvider({
  71. name: info.provider,
  72. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  73. inheritsParentContext: false,
  74. async start() {
  75. return {
  76. id: info.id,
  77. localAgent: info.localAgent,
  78. result: result.promise,
  79. dispose: () => Promise.resolve(),
  80. }
  81. },
  82. })
  83. try {
  84. const run = await ctx.subagents.start(info.provider, {
  85. parent,
  86. prompt: [],
  87. signal: new AbortController().signal,
  88. })
  89. await beforeSettle?.()
  90. if (info.lastAssistantMessage === undefined) {
  91. result.reject(new Error('synthetic infrastructure failure'))
  92. } else {
  93. result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason })
  94. }
  95. await run.result.then(() => undefined, () => undefined)
  96. await run.dispose()
  97. } finally {
  98. disposeProvider()
  99. }
  100. }
  101. describe('HarnessSdkJsonRpcServer', () => {
  102. it('creates a harness agent and calls the configured OpenAI-compatible endpoint', { timeout: 15_000 }, async () => {
  103. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-'))
  104. const llmServer = await mockCompletionServer()
  105. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  106. vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
  107. const ctx = await makeHarness(storageDir)
  108. try {
  109. const transport = new FakeTransport()
  110. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  111. const init = await server.handleRequest('initialize', {
  112. cwd: storageDir,
  113. provider: 'deepseek-official',
  114. model: 'dsagent-model',
  115. maxTokens: 321,
  116. }) as { serverInfo: { name: string } }
  117. expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime')
  118. const receipt = await server.handleRequest('session/prompt', {
  119. sessionId: 'main',
  120. contentBlocks: [{ type: 'text', text: 'fix it' }],
  121. })
  122. expect((receipt as { messageId?: unknown }).messageId).toBeTypeOf('string')
  123. await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
  124. const body = llmServer.requests[0] as { model: string; messages: { role: string }[]; max_tokens?: number }
  125. expect(body.model).toBe('dsagent-model')
  126. expect(body.max_tokens).toBe(321)
  127. expect(body.messages[0]?.role).toBe('system')
  128. expect(body.messages.at(-1)?.role).toBe('user')
  129. expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key')
  130. expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true)
  131. await vi.waitFor(() => {
  132. expect(transport.notifications.findLast(n => n.method === 'session.status')).toEqual({
  133. method: 'session.status',
  134. params: { sessionId: 'main', status: 'idle' },
  135. })
  136. })
  137. await server.handleRequest('session/prompt', {
  138. sessionId: 'main',
  139. contentBlocks: [{ type: 'text', text: 'again' }],
  140. })
  141. await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) })
  142. const orphanHandle = await ctx.agents.create({
  143. sessionId: SessionId('orphan-session'),
  144. meta: { cwd: storageDir },
  145. agentOptions: { provider: 'deepseek-official', model: 'dsagent-model' },
  146. })
  147. orphanHandle.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'outside the sdk session map' }], source: { kind: 'user' } }))
  148. await orphanHandle.agent.whenIdle()
  149. await orphanHandle.dispose()
  150. expect(llmServer.requests).toHaveLength(3)
  151. await server.handleRequest('shutdown', undefined)
  152. } finally {
  153. await ctx.fiber.dispose()
  154. await rm(storageDir, { recursive: true, force: true })
  155. }
  156. })
  157. it('queues overlapping prompts for one session without blocking other sessions', async () => {
  158. const mainFollowup = vi.fn<Agent['followup']>()
  159. const mainAgent = ({
  160. id: SessionId('main'),
  161. followup: mainFollowup,
  162. } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
  163. const otherFollowup = vi.fn<Agent['followup']>()
  164. const otherAgent = ({
  165. id: SessionId('other'),
  166. followup: otherFollowup,
  167. } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
  168. const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) }
  169. const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) }
  170. const create = vi.fn(async (options: { sessionId: SessionId }) =>
  171. String(options.sessionId) === 'main' ? mainHandle : otherHandle)
  172. const liveAgents = new Map<string, Agent>([['main', mainAgent], ['other', otherAgent]])
  173. const ctx = {
  174. on: vi.fn(() => () => undefined),
  175. agents: { create, get: (id: SessionId) => liveAgents.get(String(id)) },
  176. get: () => undefined,
  177. } as unknown as Context
  178. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  179. const prompt = (sessionId: string, text: string) => server.prompt({
  180. sessionId,
  181. contentBlocks: [{ type: 'text', text }],
  182. })
  183. expect((await prompt('main', 'first')).messageId).toBeTypeOf('string')
  184. expect((await prompt('main', 'overlap')).messageId).toBeTypeOf('string')
  185. expect((await prompt('other', 'independent')).messageId).toBeTypeOf('string')
  186. expect(mainFollowup).toHaveBeenCalledTimes(2)
  187. expect(otherFollowup).toHaveBeenCalledOnce()
  188. await server.shutdown()
  189. expect(mainHandle.dispose).toHaveBeenCalledOnce()
  190. expect(otherHandle.dispose).toHaveBeenCalledOnce()
  191. })
  192. it('admits inline SDK images before the user message enters the session', async () => {
  193. const followup = vi.fn<Agent['followup']>()
  194. const agent = ({ id: SessionId('image'), followup } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
  195. const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
  196. const ref = {
  197. attachmentId: 'sha256:image',
  198. mediaType: 'image/png',
  199. bytes: 1,
  200. width: 1,
  201. height: 1,
  202. }
  203. const saveImages = vi.fn(async () => [ref])
  204. const ctx = {
  205. on: vi.fn(() => () => undefined),
  206. agents: { create: vi.fn(async () => handle), get: () => agent },
  207. get: (name: string) => name === 'attachments' ? { saveImages } : undefined,
  208. } as unknown as Context
  209. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  210. await server.prompt({
  211. sessionId: 'image',
  212. contentBlocks: [
  213. { type: 'text', text: 'inspect' },
  214. { type: 'image', data: 'AQ==', mimeType: 'image/png' },
  215. ],
  216. })
  217. expect(saveImages).toHaveBeenCalledWith([{ data: Uint8Array.of(1), mediaType: 'image/png' }])
  218. expect(followup.mock.calls[0]?.[0].content).toEqual([
  219. { type: 'text', text: 'inspect' },
  220. { type: 'image', attachment: ref },
  221. ])
  222. await server.shutdown()
  223. })
  224. it('rejects inline SDK images when the composition has no attachment store', async () => {
  225. const followup = vi.fn<Agent['followup']>()
  226. const agent = ({ id: SessionId('image'), followup } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
  227. const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
  228. const ctx = {
  229. on: vi.fn(() => () => undefined),
  230. agents: { create: vi.fn(async () => handle), get: () => agent },
  231. get: () => undefined,
  232. } as unknown as Context
  233. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  234. await expect(server.prompt({
  235. sessionId: 'image',
  236. contentBlocks: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
  237. })).rejects.toThrow('SDK image prompt requires an attachment store')
  238. expect(followup).not.toHaveBeenCalled()
  239. await server.shutdown()
  240. })
  241. it('rechecks agent liveness after asynchronous image admission', async () => {
  242. const followup = vi.fn<Agent['followup']>()
  243. const agent = ({ id: SessionId('image-race'), followup } satisfies Pick<Agent, 'id' | 'followup'>) as unknown as Agent
  244. const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
  245. const admitted = Promise.withResolvers<Array<{
  246. attachmentId: string
  247. mediaType: string
  248. bytes: number
  249. }>>()
  250. const saveImages = vi.fn(() => admitted.promise)
  251. let live = true
  252. const ctx = {
  253. on: vi.fn(() => () => undefined),
  254. agents: {
  255. create: vi.fn(async () => handle),
  256. get: () => live ? agent : undefined,
  257. },
  258. get: (name: string) => name === 'attachments' ? { saveImages } : undefined,
  259. } as unknown as Context
  260. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  261. const prompting = server.prompt({
  262. sessionId: 'image-race',
  263. contentBlocks: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }],
  264. })
  265. await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() })
  266. live = false
  267. admitted.resolve([{ attachmentId: 'sha256:image', mediaType: 'image/png', bytes: 1 }])
  268. await expect(prompting).rejects.toThrow('session agent was disposed outside the server: image-race')
  269. expect(followup).not.toHaveBeenCalled()
  270. await server.shutdown()
  271. })
  272. it('rejects a prompt for a session whose agent was disposed outside the server', async () => {
  273. const followup = vi.fn<Agent['followup']>()
  274. const agent = ({
  275. id: SessionId('zombie'),
  276. followup,
  277. whenIdle: vi.fn(() => Promise.resolve()),
  278. } satisfies Pick<Agent, 'id' | 'followup' | 'whenIdle'>) as unknown as Agent
  279. const handle = { agent, dispose: vi.fn(() => Promise.resolve()) }
  280. // The registry drops the agent after creation, modelling an agent-loop-only
  281. // reload that leaves the server's SessionRecord pointing at a detached agent.
  282. let live = true
  283. const ctx = {
  284. on: vi.fn(() => () => undefined),
  285. agents: {
  286. create: vi.fn(async () => handle),
  287. get: (id: SessionId) => (live && String(id) === 'zombie' ? agent : undefined),
  288. },
  289. get: () => undefined,
  290. } as unknown as Context
  291. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  292. const prompt = (text: string) => server.prompt({
  293. sessionId: 'zombie',
  294. contentBlocks: [{ type: 'text', text }],
  295. })
  296. expect((await prompt('while live')).messageId).toBeTypeOf('string')
  297. live = false
  298. await expect(prompt('after detach')).rejects.toThrow('session agent was disposed outside the server: zombie')
  299. // The detached agent was never driven by the rejected prompt.
  300. expect(followup).toHaveBeenCalledOnce()
  301. await server.shutdown()
  302. })
  303. it('forwards whole-agent status without attributing a turn outcome', async () => {
  304. const ctx = new Context()
  305. await ctx.plugin(SessionStore)
  306. await ctx.plugin(AgentRegistry)
  307. const transport = new FakeTransport()
  308. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  309. const session = ctx.sessions.create(SessionId('message-outcome'))
  310. const agent = ({
  311. id: SessionId('message-outcome'),
  312. session,
  313. } satisfies Pick<Agent, 'id' | 'session'>) as Agent
  314. ctx.emit('agent/status', { agent, status: 'running' })
  315. ctx.emit('agent/status', { agent, status: 'idle' })
  316. expect(transport.notifications.filter(notification => notification.method === 'session.status'))
  317. .toEqual([
  318. { method: 'session.status', params: { sessionId: 'message-outcome', status: 'running' } },
  319. { method: 'session.status', params: { sessionId: 'message-outcome', status: 'idle' } },
  320. ])
  321. await server.shutdown()
  322. await ctx.fiber.dispose()
  323. })
  324. it('notifies the host when a child session is created with parent lineage', async () => {
  325. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-'))
  326. const ctx = await makeHarness(storageDir)
  327. try {
  328. const transport = new FakeTransport()
  329. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  330. ctx.sessions.create(SessionId('root-session'), {
  331. meta: { cwd: storageDir },
  332. })
  333. ctx.sessions.create(SessionId('child-session'), {
  334. meta: { cwd: storageDir, parentSession: SessionId('main') },
  335. })
  336. expect(transport.notifications).toContainEqual({
  337. method: 'subagent.started',
  338. params: {
  339. parentSessionId: 'main',
  340. childSessionId: 'child-session',
  341. },
  342. })
  343. await server.shutdown()
  344. } finally {
  345. await ctx.fiber.dispose()
  346. await rm(storageDir, { recursive: true, force: true })
  347. }
  348. })
  349. it('creates an SDK session without an optional system prompt', { timeout: 15_000 }, async () => {
  350. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-'))
  351. const llmServer = await mockCompletionServer()
  352. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  353. vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url)
  354. const ctx = await makeHarness(storageDir)
  355. try {
  356. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  357. await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'plain-model' })
  358. await server.prompt({
  359. sessionId: 'plain',
  360. contentBlocks: [{ type: 'text', text: 'hello' }],
  361. })
  362. await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) })
  363. await server.shutdown()
  364. } finally {
  365. await ctx.fiber.dispose()
  366. await rm(storageDir, { recursive: true, force: true })
  367. }
  368. })
  369. it('notifies the host when a subagent run settles', async () => {
  370. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-'))
  371. const ctx = await makeHarness(storageDir)
  372. try {
  373. const transport = new FakeTransport()
  374. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  375. const parentHandle = await ctx.agents.create({
  376. sessionId: SessionId('main'),
  377. meta: { cwd: storageDir },
  378. agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
  379. })
  380. // A custom in-process provider may own its child at the provider/root
  381. // scope while preserving durable parent lineage.
  382. const handle = await ctx.agents.create({
  383. sessionId: SessionId('child-session'),
  384. meta: { cwd: storageDir, parentSession: SessionId('main') },
  385. agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
  386. })
  387. expect(ctx.agents.roots()).toContain(handle.agent)
  388. const parentlessHandle = await parentHandle.agent.ctx.agents.create({
  389. sessionId: SessionId('parentless-child-session'),
  390. meta: { cwd: storageDir },
  391. agentOptions: { model: 'deepseek-official' },
  392. })
  393. await settleSubagent(ctx, parentHandle.agent, {
  394. provider: 'spawn',
  395. id: SessionId('child-session'),
  396. localAgent: handle.agent,
  397. stopReason: 'completed',
  398. lastAssistantMessage: [{ type: 'text', text: 'child done' }],
  399. }, () => handle.dispose())
  400. await settleSubagent(ctx, parentHandle.agent, {
  401. provider: 'spawn',
  402. id: SessionId('parentless-child-session'),
  403. localAgent: parentlessHandle.agent,
  404. stopReason: 'error',
  405. }, () => parentlessHandle.dispose())
  406. expect(transport.notifications).toContainEqual({
  407. method: 'subagent.finished',
  408. params: {
  409. provider: 'spawn',
  410. agentId: 'child-session',
  411. parentSessionId: 'main',
  412. childSessionId: 'child-session',
  413. status: 'ok',
  414. stopReason: 'completed',
  415. lastAssistantMessage: [{ type: 'text', text: 'child done' }],
  416. },
  417. })
  418. expect(transport.notifications).toContainEqual({
  419. method: 'subagent.finished',
  420. params: {
  421. provider: 'spawn',
  422. agentId: 'parentless-child-session',
  423. parentSessionId: 'main',
  424. childSessionId: 'parentless-child-session',
  425. status: 'error',
  426. stopReason: 'error',
  427. },
  428. })
  429. await parentHandle.dispose()
  430. await server.shutdown()
  431. } finally {
  432. await ctx.fiber.dispose()
  433. await rm(storageDir, { recursive: true, force: true })
  434. }
  435. })
  436. it('ignores a remote run id that collides with a local child of the same parent', async () => {
  437. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-'))
  438. const ctx = await makeHarness(storageDir)
  439. try {
  440. const transport = new FakeTransport()
  441. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  442. const parentHandle = await ctx.agents.create({
  443. sessionId: SessionId('collision-parent'),
  444. meta: { cwd: storageDir },
  445. agentOptions: { model: 'deepseek-official' },
  446. })
  447. const collidingChild = await parentHandle.agent.ctx.agents.create({
  448. sessionId: SessionId('remote-run-id'),
  449. meta: { cwd: storageDir, parentSession: SessionId('collision-parent') },
  450. agentOptions: { model: 'deepseek-official' },
  451. })
  452. await settleSubagent(ctx, parentHandle.agent, {
  453. provider: 'remote',
  454. id: SessionId('remote-run-id'),
  455. localAgent: undefined,
  456. stopReason: 'completed',
  457. lastAssistantMessage: [],
  458. })
  459. expect(transport.notifications.some(notification =>
  460. notification.method === 'subagent.finished'
  461. && notification.params?.agentId === 'remote-run-id',
  462. )).toBe(false)
  463. await collidingChild.dispose()
  464. await parentHandle.dispose()
  465. await server.shutdown()
  466. } finally {
  467. await ctx.fiber.dispose()
  468. await rm(storageDir, { recursive: true, force: true })
  469. }
  470. })
  471. it('retains locality across continuation runs on one live child', async () => {
  472. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-'))
  473. const ctx = await makeHarness(storageDir)
  474. try {
  475. const transport = new FakeTransport()
  476. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  477. const parentHandle = await ctx.agents.create({
  478. sessionId: SessionId('continuation-parent'),
  479. meta: { cwd: storageDir },
  480. agentOptions: { model: 'deepseek-official' },
  481. })
  482. const childHandle = await parentHandle.agent.ctx.agents.create({
  483. sessionId: SessionId('continuation-child'),
  484. meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') },
  485. agentOptions: { model: 'deepseek-official' },
  486. })
  487. await settleSubagent(ctx, parentHandle.agent, {
  488. provider: 'continuation',
  489. id: SessionId('continuation-child'),
  490. localAgent: childHandle.agent,
  491. stopReason: 'completed',
  492. lastAssistantMessage: [{ type: 'text', text: 'first' }],
  493. })
  494. await settleSubagent(ctx, parentHandle.agent, {
  495. provider: 'continuation',
  496. id: SessionId('continuation-child'),
  497. localAgent: childHandle.agent,
  498. stopReason: 'completed',
  499. lastAssistantMessage: [{ type: 'text', text: 'second' }],
  500. }, () => childHandle.dispose())
  501. expect(transport.notifications.filter(notification =>
  502. notification.method === 'subagent.finished'
  503. && notification.params?.childSessionId === 'continuation-child',
  504. )).toHaveLength(2)
  505. await parentHandle.dispose()
  506. await server.shutdown()
  507. } finally {
  508. await ctx.fiber.dispose()
  509. await rm(storageDir, { recursive: true, force: true })
  510. }
  511. })
  512. it('correlates reused local ids by parent scope when runs settle out of order', async () => {
  513. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-'))
  514. const ctx = await makeHarness(storageDir)
  515. try {
  516. const transport = new FakeTransport()
  517. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  518. const oldParent = await ctx.agents.create({
  519. sessionId: SessionId('old-parent'),
  520. meta: { cwd: storageDir },
  521. agentOptions: { model: 'deepseek-official' },
  522. })
  523. const oldChild = await oldParent.agent.ctx.agents.create({
  524. sessionId: SessionId('reused-child'),
  525. meta: { cwd: storageDir, parentSession: SessionId('old-parent') },
  526. agentOptions: { model: 'deepseek-official' },
  527. })
  528. const first = Promise.withResolvers<SubagentResult>()
  529. const sameLifetime = Promise.withResolvers<SubagentResult>()
  530. const replacement = Promise.withResolvers<SubagentResult>()
  531. const results = [first.promise, sameLifetime.promise, replacement.promise]
  532. let starts = 0
  533. let currentLocalAgent = oldChild.agent
  534. const disposeProvider = ctx.subagents.registerProvider({
  535. name: 'reused',
  536. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  537. inheritsParentContext: false,
  538. start() {
  539. const result = results[starts]
  540. starts += 1
  541. if (result === undefined) throw new Error('unexpected fourth reused-id run')
  542. return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() })
  543. },
  544. })
  545. const firstRun = await ctx.subagents.start('reused', {
  546. parent: oldParent.agent,
  547. prompt: [],
  548. signal: new AbortController().signal,
  549. })
  550. const sameLifetimeRun = await ctx.subagents.start('reused', {
  551. parent: oldParent.agent,
  552. prompt: [],
  553. signal: new AbortController().signal,
  554. })
  555. sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' })
  556. await sameLifetimeRun.result
  557. await oldChild.dispose()
  558. const newParent = await ctx.agents.create({
  559. sessionId: SessionId('new-parent'),
  560. meta: { cwd: storageDir },
  561. agentOptions: { model: 'deepseek-official' },
  562. })
  563. const newChild = await newParent.agent.ctx.agents.create({
  564. sessionId: SessionId('reused-child'),
  565. meta: { cwd: storageDir, parentSession: SessionId('new-parent') },
  566. agentOptions: { model: 'deepseek-official' },
  567. })
  568. currentLocalAgent = newChild.agent
  569. const secondRun = await ctx.subagents.start('reused', {
  570. parent: newParent.agent,
  571. prompt: [],
  572. signal: new AbortController().signal,
  573. })
  574. replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' })
  575. await secondRun.result
  576. first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' })
  577. await firstRun.result
  578. await Promise.resolve()
  579. const finished = transport.notifications.filter(notification =>
  580. notification.method === 'subagent.finished'
  581. && notification.params?.childSessionId === 'reused-child',
  582. )
  583. expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([
  584. [{ type: 'text', text: 'same lifetime' }],
  585. [{ type: 'text', text: 'new lifetime' }],
  586. [{ type: 'text', text: 'old lifetime' }],
  587. ])
  588. expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([
  589. 'old-parent',
  590. 'new-parent',
  591. 'old-parent',
  592. ])
  593. await firstRun.dispose()
  594. await sameLifetimeRun.dispose()
  595. await secondRun.dispose()
  596. disposeProvider()
  597. await newChild.dispose()
  598. await oldParent.dispose()
  599. await newParent.dispose()
  600. await server.shutdown()
  601. } finally {
  602. await ctx.fiber.dispose()
  603. await rm(storageDir, { recursive: true, force: true })
  604. }
  605. })
  606. it('keeps locality bound to the accepted run across provider re-registration', async () => {
  607. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-'))
  608. const ctx = await makeHarness(storageDir)
  609. try {
  610. const transport = new FakeTransport()
  611. const server = new HarnessSdkJsonRpcServer(ctx, transport)
  612. const parent = await ctx.agents.create({
  613. sessionId: SessionId('provider-reuse-parent'),
  614. meta: { cwd: storageDir },
  615. agentOptions: { model: 'deepseek-official' },
  616. })
  617. const child = await parent.agent.ctx.agents.create({
  618. sessionId: SessionId('provider-reuse-child'),
  619. meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') },
  620. agentOptions: { model: 'deepseek-official' },
  621. })
  622. const localResult = Promise.withResolvers<SubagentResult>()
  623. const remoteResult = Promise.withResolvers<SubagentResult>()
  624. const unregisterLocal = ctx.subagents.registerProvider({
  625. name: 'reused-provider',
  626. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  627. inheritsParentContext: false,
  628. start: () => Promise.resolve({
  629. id: SessionId('provider-reuse-child'),
  630. localAgent: child.agent,
  631. result: localResult.promise,
  632. dispose: () => Promise.resolve(),
  633. }),
  634. })
  635. const localRun = await ctx.subagents.start('reused-provider', {
  636. parent: parent.agent,
  637. prompt: [],
  638. signal: new AbortController().signal,
  639. })
  640. unregisterLocal()
  641. const unregisterRemote = ctx.subagents.registerProvider({
  642. name: 'reused-provider',
  643. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  644. inheritsParentContext: false,
  645. start: () => Promise.resolve({
  646. id: SessionId('provider-reuse-child'),
  647. localAgent: undefined,
  648. result: remoteResult.promise,
  649. dispose: () => Promise.resolve(),
  650. }),
  651. })
  652. const remoteRun = await ctx.subagents.start('reused-provider', {
  653. parent: parent.agent,
  654. prompt: [],
  655. signal: new AbortController().signal,
  656. })
  657. remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' })
  658. await remoteRun.result
  659. await Promise.resolve()
  660. expect(transport.notifications.some(notification =>
  661. notification.method === 'subagent.finished'
  662. && notification.params?.lastAssistantMessage !== undefined,
  663. )).toBe(false)
  664. await child.dispose()
  665. localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' })
  666. await localRun.result
  667. await Promise.resolve()
  668. expect(transport.notifications.filter(notification =>
  669. notification.method === 'subagent.finished'
  670. && notification.params?.childSessionId === 'provider-reuse-child',
  671. )).toEqual([{
  672. method: 'subagent.finished',
  673. params: {
  674. provider: 'reused-provider',
  675. agentId: 'provider-reuse-child',
  676. parentSessionId: 'provider-reuse-parent',
  677. childSessionId: 'provider-reuse-child',
  678. status: 'ok',
  679. stopReason: 'completed',
  680. lastAssistantMessage: [{ type: 'text', text: 'local' }],
  681. },
  682. }])
  683. await localRun.dispose()
  684. await remoteRun.dispose()
  685. unregisterRemote()
  686. await parent.dispose()
  687. await server.shutdown()
  688. } finally {
  689. await ctx.fiber.dispose()
  690. await rm(storageDir, { recursive: true, force: true })
  691. }
  692. })
  693. it('uses the recorded local flag when start was missed and ignores remote runs', async () => {
  694. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-'))
  695. const ctx = await makeHarness(storageDir)
  696. let parentHandle: AgentHandle | undefined
  697. let handle: AgentHandle | undefined
  698. let failedHandle: AgentHandle | undefined
  699. try {
  700. parentHandle = await ctx.agents.create({
  701. sessionId: SessionId('fallback-parent'),
  702. meta: { cwd: storageDir },
  703. agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
  704. })
  705. handle = await parentHandle.agent.ctx.agents.create({
  706. sessionId: SessionId('fallback-child-session'),
  707. meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') },
  708. agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
  709. })
  710. const fallbackChild = handle.agent
  711. failedHandle = await parentHandle.agent.ctx.agents.create({
  712. sessionId: SessionId('failed-child-session'),
  713. meta: { cwd: storageDir },
  714. agentOptions: { provider: 'deepseek-official', model: 'deepseek-official' },
  715. })
  716. const missedStartResult = Promise.withResolvers<SubagentResult>()
  717. const disposeMissedStartProvider = ctx.subagents.registerProvider({
  718. name: 'fork',
  719. capabilities: { agentOptions: false, outputSchema: false, depthLimit: false, toolFilter: false, persona: false },
  720. inheritsParentContext: true,
  721. start: () => Promise.resolve({
  722. id: SessionId('fallback-child-session'),
  723. localAgent: fallbackChild,
  724. result: missedStartResult.promise,
  725. dispose: () => Promise.resolve(),
  726. }),
  727. })
  728. // Start before the server subscribes. The terminal payload still carries
  729. // this run's exact local child without reconstructing it from ids.
  730. const missedStartRun = await ctx.subagents.start('fork', {
  731. parent: parentHandle.agent,
  732. prompt: [],
  733. signal: new AbortController().signal,
  734. })
  735. const transport = new FakeTransport()
  736. const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: true })
  737. missedStartResult.resolve({ output: [], stopReason: 'max-tokens' })
  738. await missedStartRun.result
  739. await Promise.resolve()
  740. await missedStartRun.dispose()
  741. disposeMissedStartProvider()
  742. // The server also missed this agent's creation but sees the exact child
  743. // on the run lifecycle payload.
  744. await settleSubagent(ctx, parentHandle.agent, {
  745. provider: 'fork-live-fallback',
  746. id: SessionId('fallback-child-session'),
  747. localAgent: fallbackChild,
  748. stopReason: 'completed',
  749. lastAssistantMessage: [],
  750. })
  751. await settleSubagent(ctx, parentHandle.agent, {
  752. provider: 'fork',
  753. id: SessionId('failed-child-session'),
  754. localAgent: failedHandle.agent,
  755. stopReason: 'error',
  756. })
  757. await settleSubagent(ctx, parentHandle.agent, {
  758. provider: 'fork',
  759. id: SessionId('missing-child-agent'),
  760. localAgent: undefined,
  761. stopReason: 'error',
  762. })
  763. // A result without output omits lastAssistantMessage from the wire; it
  764. // never sends `[]`.
  765. expect(transport.notifications).toContainEqual({
  766. method: 'subagent.finished',
  767. params: {
  768. provider: 'fork',
  769. agentId: 'fallback-child-session',
  770. parentSessionId: 'fallback-parent',
  771. childSessionId: 'fallback-child-session',
  772. status: 'ok',
  773. stopReason: 'max-tokens',
  774. },
  775. })
  776. expect(transport.notifications).toContainEqual({
  777. method: 'subagent.finished',
  778. params: {
  779. provider: 'fork',
  780. agentId: 'failed-child-session',
  781. parentSessionId: 'fallback-parent',
  782. childSessionId: 'failed-child-session',
  783. status: 'error',
  784. stopReason: 'error',
  785. },
  786. })
  787. expect(transport.notifications.some(n =>
  788. n.method === 'subagent.finished'
  789. && n.params?.agentId === 'missing-child-agent',
  790. )).toBe(false)
  791. await server.shutdown()
  792. } finally {
  793. await handle?.dispose()
  794. await failedHandle?.dispose()
  795. await parentHandle?.dispose()
  796. await ctx.fiber.dispose()
  797. await rm(storageDir, { recursive: true, force: true })
  798. }
  799. })
  800. it('does not re-register an LLM adapter whose provider already has an owner', async () => {
  801. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-'))
  802. const ctx = await makeHarness(storageDir)
  803. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  804. await ctx.plugin(LlmDeepSeek)
  805. try {
  806. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  807. const inspect = server as unknown as { hasAdapterFor(provider: string): boolean }
  808. expect(inspect.hasAdapterFor('deepseek-official')).toBe(true)
  809. expect(inspect.hasAdapterFor('missing-provider')).toBe(false)
  810. await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'preinstalled-model' })
  811. expect(ctx.get('llm')?.listProviders().filter(provider => provider.id === 'deepseek-official')).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
  812. await server.shutdown()
  813. } finally {
  814. await ctx.fiber.dispose()
  815. await rm(storageDir, { recursive: true, force: true })
  816. }
  817. })
  818. it('rejects a missing non-DeepSeek provider when an LLM service already exists', async () => {
  819. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-'))
  820. const ctx = await makeHarness(storageDir)
  821. vi.stubEnv('DEEPSEEK_API_KEY', 'test-key')
  822. await ctx.plugin(LlmDeepSeek)
  823. try {
  824. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  825. await expect(server.initialize({ cwd: storageDir, provider: 'private', model: 'new-model' }))
  826. .rejects.toThrow('no adapter registered for provider "private"')
  827. expect(ctx.get('llm')?.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }])
  828. await server.shutdown()
  829. } finally {
  830. await ctx.fiber.dispose()
  831. await rm(storageDir, { recursive: true, force: true })
  832. }
  833. })
  834. it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])(
  835. 'rejects invalid initialize maxTokens %s at the wire boundary',
  836. async (maxTokens) => {
  837. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-invalid-max-tokens-'))
  838. const ctx = await makeHarness(storageDir)
  839. try {
  840. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  841. await expect(server.initialize({
  842. cwd: storageDir,
  843. provider: 'deepseek-official',
  844. model: 'model',
  845. maxTokens,
  846. })).rejects.toThrow('initialize maxTokens must be a positive safe integer')
  847. await server.shutdown()
  848. } finally {
  849. await ctx.fiber.dispose()
  850. await rm(storageDir, { recursive: true, force: true })
  851. }
  852. },
  853. )
  854. it('reports no adapter when the LLM service is absent', async () => {
  855. const ctx = new Context()
  856. try {
  857. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
  858. hasAdapterFor(model: string): boolean
  859. shutdown(): Promise<Record<string, never>>
  860. }
  861. expect(server.hasAdapterFor('missing-model')).toBe(false)
  862. await server.shutdown()
  863. } finally {
  864. await ctx.fiber.dispose()
  865. }
  866. })
  867. it('rejects unknown JSON-RPC runtime methods', async () => {
  868. const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-'))
  869. const ctx = await makeHarness(storageDir)
  870. try {
  871. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  872. await expect(server.handleRequest('does/not/exist', {}))
  873. .rejects
  874. .toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist')
  875. await server.shutdown()
  876. } finally {
  877. await ctx.fiber.dispose()
  878. await rm(storageDir, { recursive: true, force: true })
  879. }
  880. })
  881. it('coalesces concurrent session creation and retries a failed creation', async () => {
  882. let resolveShared: ((handle: AgentHandle) => void) | undefined
  883. const sharedCreation = new Promise<AgentHandle>((resolve) => { resolveShared = resolve })
  884. const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
  885. const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) }
  886. const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
  887. .mockReturnValueOnce(sharedCreation)
  888. .mockRejectedValueOnce(new Error('creation failed'))
  889. .mockResolvedValueOnce(retryHandle)
  890. const ctx = {
  891. on: vi.fn(() => () => undefined),
  892. agents: { create, get: () => undefined },
  893. get: () => undefined,
  894. } as unknown as Context
  895. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
  896. getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }>
  897. shutdown(): Promise<Record<string, never>>
  898. }
  899. const first = server.getOrCreateSession('shared')
  900. const second = server.getOrCreateSession('shared')
  901. expect(create).toHaveBeenCalledTimes(1)
  902. resolveShared?.(sharedHandle)
  903. const [firstRecord, secondRecord] = await Promise.all([first, second])
  904. expect(firstRecord).toBe(secondRecord)
  905. await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed')
  906. await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle })
  907. expect(create).toHaveBeenCalledTimes(3)
  908. await server.shutdown()
  909. expect(sharedHandle.dispose).toHaveBeenCalledOnce()
  910. expect(retryHandle.dispose).toHaveBeenCalledOnce()
  911. await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down')
  912. })
  913. it('resolves a relative cwd before creating the session', async () => {
  914. const create = vi.fn<(options: unknown) => Promise<AgentHandle>>()
  915. .mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() })
  916. const ctx = {
  917. on: vi.fn(() => () => undefined),
  918. agents: { create, get: () => undefined },
  919. get: () => ({ listProviders: () => [{ id: 'mock', name: 'Mock' }] }),
  920. } as unknown as Context
  921. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
  922. initialize(params: { cwd: string; provider: string; model: string; maxTokens?: number }): Promise<unknown>
  923. getOrCreateSession(sessionId: string): Promise<unknown>
  924. shutdown(): Promise<Record<string, never>>
  925. }
  926. await server.initialize({ cwd: '.', provider: 'mock', model: 'model', maxTokens: 123 })
  927. await server.getOrCreateSession('relative')
  928. expect(create).toHaveBeenCalledWith(expect.objectContaining({
  929. meta: { cwd: process.cwd() },
  930. agentOptions: { provider: 'mock', model: 'model', maxTokens: 123 },
  931. }))
  932. await server.shutdown()
  933. })
  934. it('settles every teardown and aggregates multiple failures', async () => {
  935. const firstDispose = vi.fn(() => { throw new Error('first teardown failed') })
  936. const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed')))
  937. const ctx = {
  938. on: vi.fn(() => () => undefined),
  939. agents: { create: vi.fn(), get: () => undefined },
  940. get: () => undefined,
  941. } as unknown as Context
  942. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport()) as unknown as {
  943. sessions: Map<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
  944. shutdown(): Promise<Record<string, never>>
  945. }
  946. server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false })
  947. server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false })
  948. await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed')
  949. expect(firstDispose).toHaveBeenCalledOnce()
  950. expect(secondDispose).toHaveBeenCalledOnce()
  951. })
  952. it('continues teardown after a subscription disposer fails', async () => {
  953. let subscription = 0
  954. const listenerFailure = new Error('listener teardown failed')
  955. const on = vi.fn(() => {
  956. subscription += 1
  957. return subscription === 1 ? () => { throw listenerFailure } : () => undefined
  958. })
  959. const ctx = {
  960. on,
  961. agents: { create: vi.fn(), get: () => undefined },
  962. get: () => undefined,
  963. } as unknown as Context
  964. const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport())
  965. await expect(server.shutdown()).rejects.toBe(listenerFailure)
  966. expect(on).toHaveBeenCalledTimes(4)
  967. })
  968. })