server.spec.ts 39 KB

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