host-runtime.spec.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  5. import type { Context } from 'cordis'
  6. import type { Agent } from '@deepseek-ai/dsh-agent'
  7. import { agentEvents } from '@deepseek-ai/dsh-agent'
  8. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  9. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  10. import type { SessionId } from '@deepseek-ai/dsh-session'
  11. import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  12. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  13. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  14. import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
  15. /** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
  16. class ScriptedAdapter extends LlmAdapter {
  17. readonly requests: GenerateOptions[] = []
  18. constructor(private script: (StreamChunk[] | 'hang')[]) {
  19. super()
  20. }
  21. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  22. this.requests.push(options)
  23. const entry = this.script.shift()
  24. if (!entry) throw new Error('ScriptedAdapter: script exhausted')
  25. if (entry === 'hang') {
  26. yield { type: 'block-start', index: 0, blockType: 'text' }
  27. await new Promise<void>((_resolve, reject) => {
  28. options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  29. })
  30. return
  31. }
  32. yield * entry
  33. }
  34. }
  35. function textResponse(text: string): StreamChunk[] {
  36. return [
  37. { type: 'block-start', index: 0, blockType: 'text' },
  38. { type: 'text-delta', index: 0, text },
  39. { type: 'block-end', index: 0, block: { type: 'text', text } },
  40. { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
  41. { type: 'finish', reason: { kind: 'stop' } },
  42. ]
  43. }
  44. function request<P>(payload: P): RpcRequest<P> {
  45. return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
  46. }
  47. let nextRpc = 1
  48. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  49. return new Promise((resolve) => {
  50. const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
  51. if (subject === agent && status === 'idle') {
  52. dispose()
  53. resolve()
  54. }
  55. })
  56. })
  57. }
  58. function expectOk<T>(response: RpcResponse<T>): T {
  59. expect(response.result.ok).toBe(true)
  60. if (!response.result.ok) throw new Error('unreachable')
  61. return response.result.value
  62. }
  63. let host: RunningHost | undefined
  64. beforeEach(() => {
  65. vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
  66. })
  67. afterEach(async () => {
  68. await host?.dispose()
  69. host = undefined
  70. vi.unstubAllEnvs()
  71. })
  72. async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
  73. host = await startHost({
  74. boot: {
  75. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
  76. workspaceContext: false,
  77. provider: 'scripted',
  78. model: 'test-model',
  79. },
  80. })
  81. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
  82. return host
  83. }
  84. describe('bootHost / startHost', () => {
  85. it('falls back to the deepseek defaults and disposes idempotently', async () => {
  86. const handle: HostHandle = await bootHost({
  87. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')),
  88. workspaceContext: false,
  89. })
  90. expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
  91. expect(typeof handle.defaults.cwd).toBe('string')
  92. await handle.dispose()
  93. })
  94. it('uses the JSONL backend compressed default', async () => {
  95. const handle: HostHandle = await bootHost({
  96. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')),
  97. workspaceContext: false,
  98. })
  99. const session = handle.ctx.sessions.create()
  100. expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/)
  101. await handle.dispose()
  102. })
  103. it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
  104. const running = await boot()
  105. expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
  106. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
  107. const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
  108. const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
  109. expect(parsed.result.value.provider).toBe('scripted')
  110. const first = running.dispose()
  111. expect(running.dispose()).toBe(first)
  112. await first
  113. host = undefined
  114. })
  115. it('routes workspace instructions through the assembled agent request prefix', async () => {
  116. const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-'))
  117. mkdirSync(join(workspace, '.git'))
  118. writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n')
  119. const adapter = new ScriptedAdapter([textResponse('done')])
  120. host = await startHost({
  121. boot: {
  122. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')),
  123. workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 },
  124. provider: 'scripted',
  125. model: 'test-model',
  126. cwd: workspace,
  127. },
  128. })
  129. host.ctx.llm.registerAdapter(['scripted'], adapter)
  130. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  131. const agent = host.ctx.agents.get(sessionId) as Agent
  132. const idle = waitForIdle(host.ctx, agent)
  133. expectOk(await host.api.sessions.prompt(request({
  134. sessionId,
  135. mode: 'queue' as const,
  136. content: [{ type: 'text' as const, text: 'go' }],
  137. })))
  138. await idle
  139. const requestText = adapter.requests[0]?.messages
  140. .flatMap(message => message.content)
  141. .filter(block => block.type === 'text')
  142. .map(block => block.text)
  143. .join('\n') ?? ''
  144. expect(requestText).toContain('Instructions from: AGENTS.md')
  145. expect(requestText).toContain('host-workspace-context-probe')
  146. })
  147. })
  148. describe('host.describe', () => {
  149. it('reports version, cwd, defaults, and the attached count', async () => {
  150. const { api } = await boot()
  151. const value = expectOk(await api.host.describe(request({})))
  152. expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
  153. })
  154. })
  155. describe('sessions.create / list', () => {
  156. it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
  157. const { api } = await boot()
  158. const created = await api.sessions.create(request({ cwd: '/tmp' }))
  159. const { sessionId } = expectOk(created)
  160. expect(created.rpcId).toMatch(/^req-/)
  161. const second = expectOk(await api.sessions.create(request({}))).sessionId
  162. const { items } = expectOk(await api.sessions.list(request({})))
  163. expect(items.map(item => item.sessionId)).toContain(sessionId)
  164. expect(items.map(item => item.sessionId)).toContain(second)
  165. const first = items.find(item => item.sessionId === sessionId)
  166. expect(first?.cwd).toBe('/tmp')
  167. expect(first?.running).toBe(false)
  168. expect(first?.parentSessionId).toBeUndefined()
  169. })
  170. })
  171. describe('sessions.prompt / cancel', () => {
  172. it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
  173. const running = await boot([textResponse('pong')])
  174. const { api, ctx } = running
  175. const { sessionId } = expectOk(await api.sessions.create(request({})))
  176. const agent = ctx.agents.get(sessionId)
  177. expect(agent).toBeDefined()
  178. const idle = waitForIdle(ctx, agent as Agent)
  179. const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
  180. expectOk(await api.sessions.prompt(promptRequest))
  181. await idle
  182. const value = expectOk(await api.sessions.history(request({ sessionId })))
  183. const events = value.events.map(entry => entry.event)
  184. const userEvent = events.find(event => event.type === 'user/message') as
  185. | { data: { source?: { rpcId?: string } } } | undefined
  186. expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
  187. const reply = events.find(event => event.type === 'assistant/message')
  188. expect(reply).toBeDefined()
  189. })
  190. it('steer on an idle agent falls through to send', async () => {
  191. const running = await boot([textResponse('steered')])
  192. const { api, ctx } = running
  193. const { sessionId } = expectOk(await api.sessions.create(request({})))
  194. const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
  195. expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
  196. await idle
  197. })
  198. it('errors session-not-found on a ghost session', async () => {
  199. const { api } = await boot()
  200. const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
  201. expect(response.result.ok).toBe(false)
  202. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  203. })
  204. it('maps a synchronous send throw to agent-busy', async () => {
  205. const { api } = await boot()
  206. const { sessionId } = expectOk(await api.sessions.create(request({})))
  207. const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
  208. const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
  209. expect(response.result.ok).toBe(false)
  210. if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
  211. })
  212. it('cancels an attached agent and rejects an unattached one', async () => {
  213. const running = await boot(['hang'])
  214. const { api, ctx } = running
  215. const { sessionId } = expectOk(await api.sessions.create(request({})))
  216. const agent = ctx.agents.get(sessionId) as Agent
  217. agent.send([{ type: 'text', text: 'run forever' }])
  218. expectOk(await api.sessions.cancel(request({ sessionId })))
  219. const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
  220. expect(missing.result.ok).toBe(false)
  221. if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
  222. })
  223. })
  224. describe('sessions.history', () => {
  225. it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
  226. const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
  227. const first = await startHost({
  228. boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
  229. })
  230. first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
  231. const { sessionId } = expectOk(await first.api.sessions.create(request({})))
  232. const agent = first.ctx.agents.get(sessionId) as Agent
  233. const idle = waitForIdle(first.ctx, agent)
  234. agent.send([{ type: 'text', text: 'save me' }])
  235. await idle
  236. await first.dispose()
  237. host = await startHost({
  238. boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
  239. })
  240. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
  241. expect(host.ctx.agents.get(sessionId)).toBeUndefined()
  242. const [a, b] = await Promise.all([
  243. host.api.sessions.history(request({ sessionId })),
  244. host.api.sessions.history(request({ sessionId })),
  245. ])
  246. for (const response of [a, b]) {
  247. const value = expectOk(response)
  248. expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
  249. }
  250. expect(host.ctx.agents.get(sessionId)).toBeDefined()
  251. expect(host.ctx.agents.list()).toHaveLength(1)
  252. })
  253. it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
  254. const { api } = await boot()
  255. const ghost = 'session-ghost' as SessionId
  256. const [first, second] = await Promise.all([
  257. api.sessions.history(request({ sessionId: ghost })),
  258. api.sessions.history(request({ sessionId: ghost })),
  259. ])
  260. for (const response of [first, second]) {
  261. expect(response.result.ok).toBe(false)
  262. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  263. }
  264. })
  265. it('paginates backwards on message boundaries with hasMore', async () => {
  266. const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
  267. const { api, ctx } = running
  268. const { sessionId } = expectOk(await api.sessions.create(request({})))
  269. const agent = ctx.agents.get(sessionId) as Agent
  270. for (const text of ['q1', 'q2', 'q3']) {
  271. const idle = waitForIdle(ctx, agent)
  272. agent.send([{ type: 'text', text }])
  273. await idle
  274. }
  275. const all = expectOk(await api.sessions.history(request({ sessionId })))
  276. expect(all.hasMore).toBe(false)
  277. const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
  278. expect(messageCount).toBe(6)
  279. const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
  280. expect(lastPage.hasMore).toBe(true)
  281. expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
  282. expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
  283. const firstSeq = lastPage.events[0]?.event.seq as number
  284. const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
  285. expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
  286. expect(olderPage.hasMore).toBe(true)
  287. expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
  288. })
  289. })
  290. describe('events streams', () => {
  291. it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
  292. const running = await boot()
  293. const { api } = running
  294. const ac = new AbortController()
  295. const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
  296. // no sessions yet: next() must pend on the queue's waiter, not the buffer
  297. const pending = stream.next()
  298. const { sessionId } = expectOk(await api.sessions.create(request({})))
  299. const frame = (await pending).value as RpcRequest<MuxFrame>
  300. expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
  301. ac.abort()
  302. expect((await stream.next()).done).toBe(true)
  303. })
  304. it('lists fork lineage and announces it on the host stream', async () => {
  305. const running = await boot()
  306. const { api, ctx } = running
  307. const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
  308. const ac = new AbortController()
  309. const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
  310. const child = `session-child-${String(Date.now())}` as SessionId
  311. const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
  312. expect(handle.agent.id).toBe(child)
  313. const added = (await stream.next()).value as RpcRequest<HostFrame>
  314. expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
  315. const { items } = expectOk(await api.sessions.list(request({})))
  316. expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
  317. await handle.dispose()
  318. let frame: RpcRequest<HostFrame>
  319. do frame = (await stream.next()).value as RpcRequest<HostFrame>
  320. while (frame.payload.type !== 'host/session-removed')
  321. expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
  322. ac.abort()
  323. })
  324. it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
  325. const running = await boot([textResponse('live')])
  326. const { api, ctx } = running
  327. const { sessionId } = expectOk(await api.sessions.create(request({})))
  328. const ac = new AbortController()
  329. const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
  330. const baseline = await stream.next()
  331. expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
  332. const agent = ctx.agents.get(sessionId) as Agent
  333. const idle = waitForIdle(ctx, agent)
  334. agent.send([{ type: 'text', text: 'go' }])
  335. await idle
  336. const live = await stream.next()
  337. expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
  338. const other = expectOk(await api.sessions.create(request({}))).sessionId
  339. let frame: RpcRequest<MuxFrame>
  340. do frame = (await stream.next()).value as RpcRequest<MuxFrame>
  341. while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
  342. ac.abort()
  343. expect((await stream.next()).done).toBe(true)
  344. })
  345. it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
  346. const running = await boot([textResponse('x')])
  347. const { api, ctx } = running
  348. const ac = new AbortController()
  349. const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
  350. const { sessionId } = expectOk(await api.sessions.create(request({})))
  351. const added = await stream.next()
  352. expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
  353. const agent = ctx.agents.get(sessionId) as Agent
  354. const idle = waitForIdle(ctx, agent)
  355. agent.send([{ type: 'text', text: 'run' }])
  356. await idle
  357. const runningFrame = await stream.next()
  358. expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
  359. const idleFrame = await stream.next()
  360. expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
  361. // Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
  362. // enforces; dispatch the way the loop does.
  363. agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
  364. const errorFrame = await stream.next()
  365. expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
  366. ac.abort()
  367. // Push-after-done: an event landing between abort and generator wind-down
  368. // must be dropped silently, not crash the queue.
  369. agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
  370. expect((await stream.next()).done).toBe(true)
  371. })
  372. })
  373. describe('respond stub', () => {
  374. it('always reports not-pending (step2 registry pending)', async () => {
  375. const { api } = await boot()
  376. const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
  377. expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
  378. })
  379. })