server.spec.ts 48 KB

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