host-runtime.spec.ts 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import { existsSync, mkdirSync, mkdtempSync, readFileSync, 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 { ContentBlock, GenerateOptions, LlmModelInfo, ModelModality, 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(
  19. private script: (StreamChunk[] | 'hang')[],
  20. private readonly inputModalities: readonly ModelModality[] = ['text', 'image'],
  21. private readonly model = 'test-model',
  22. ) {
  23. super()
  24. }
  25. override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
  26. return Promise.resolve([{
  27. provider, id: this.model, name: this.model,
  28. inputModalities: this.inputModalities, outputModalities: ['text'],
  29. }])
  30. }
  31. async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  32. this.requests.push(options)
  33. const entry = this.script.shift()
  34. if (!entry) throw new Error('ScriptedAdapter: script exhausted')
  35. if (entry === 'hang') {
  36. yield { type: 'block-start', index: 0, blockType: 'text' }
  37. await new Promise<void>((_resolve, reject) => {
  38. options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  39. })
  40. return
  41. }
  42. yield * entry
  43. }
  44. }
  45. function textResponse(text: string): StreamChunk[] {
  46. return [
  47. { type: 'block-start', index: 0, blockType: 'text' },
  48. { type: 'text-delta', index: 0, text },
  49. { type: 'block-end', index: 0, block: { type: 'text', text } },
  50. { type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
  51. { type: 'finish', reason: { kind: 'stop' } },
  52. ]
  53. }
  54. function request<P>(payload: P): RpcRequest<P> {
  55. return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
  56. }
  57. let nextRpc = 1
  58. const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='
  59. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  60. return new Promise((resolve) => {
  61. const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
  62. if (subject === agent && status === 'idle') {
  63. dispose()
  64. resolve()
  65. }
  66. })
  67. })
  68. }
  69. function expectOk<T>(response: RpcResponse<T>): T {
  70. expect(response.result.ok).toBe(true)
  71. if (!response.result.ok) throw new Error('unreachable')
  72. return response.result.value
  73. }
  74. let host: RunningHost | undefined
  75. beforeEach(() => {
  76. vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
  77. })
  78. afterEach(async () => {
  79. await host?.dispose()
  80. host = undefined
  81. vi.unstubAllEnvs()
  82. })
  83. async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
  84. host = await startHost({
  85. boot: {
  86. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')),
  87. workspaceContext: false,
  88. provider: 'scripted',
  89. model: 'test-model',
  90. },
  91. })
  92. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
  93. return host
  94. }
  95. describe('bootHost / startHost', () => {
  96. it('falls back to the deepseek defaults and disposes idempotently', async () => {
  97. const handle: HostHandle = await bootHost({
  98. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')),
  99. workspaceContext: false,
  100. })
  101. expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
  102. expect(typeof handle.defaults.cwd).toBe('string')
  103. await handle.dispose()
  104. })
  105. it('uses the JSONL backend compressed default', async () => {
  106. const handle: HostHandle = await bootHost({
  107. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-zstd-')),
  108. workspaceContext: false,
  109. })
  110. const session = handle.ctx.sessions.create()
  111. expect(handle.ctx.sessionPersistence.locate(session.header)?.path).toMatch(/\.jsonl\.zstd$/)
  112. await handle.dispose()
  113. })
  114. it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
  115. const running = await boot()
  116. expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
  117. const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
  118. const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
  119. const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
  120. expect(parsed.result.value.provider).toBe('scripted')
  121. const attachmentBody = JSON.stringify({
  122. type: 'client-request',
  123. rpcId: 'r-attachment',
  124. method: 'session.attachment',
  125. payload: { sessionId: 'session-missing', attachmentId: 'sha256:missing' },
  126. })
  127. const attachmentResponse = await running.handler.fetch(new Request('http://x/api/session.attachment', {
  128. method: 'POST',
  129. body: attachmentBody,
  130. }))
  131. expect((await attachmentResponse.json() as { result: { ok: boolean } }).result.ok).toBe(false)
  132. const first = running.dispose()
  133. expect(running.dispose()).toBe(first)
  134. await first
  135. host = undefined
  136. })
  137. it('mounts configured pi-ai providers while accepting an explicit empty list', async () => {
  138. const empty = await bootHost({
  139. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-empty-')),
  140. workspaceContext: false,
  141. piAiProviders: [],
  142. })
  143. expect(empty.ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
  144. await empty.dispose()
  145. const configured = await bootHost({
  146. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-pi-')),
  147. workspaceContext: false,
  148. piAiProviders: [{ provider: 'openai' }],
  149. })
  150. expect(configured.ctx.llm.listProviders()).toContainEqual({ id: 'openai', name: 'openai' })
  151. await configured.dispose()
  152. })
  153. it('routes workspace instructions through the assembled agent request prefix', async () => {
  154. const workspace = mkdtempSync(join(tmpdir(), 'dsh-host-workspace-'))
  155. mkdirSync(join(workspace, '.git'))
  156. writeFileSync(join(workspace, 'AGENTS.md'), 'host-workspace-context-probe\n')
  157. const adapter = new ScriptedAdapter([textResponse('done')])
  158. host = await startHost({
  159. boot: {
  160. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-workspace-sessions-')),
  161. workspaceContext: { dshHome: join(workspace, '.dsh'), maxBytes: 65_536 },
  162. provider: 'scripted',
  163. model: 'test-model',
  164. cwd: workspace,
  165. },
  166. })
  167. host.ctx.llm.registerAdapter(['scripted'], adapter)
  168. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  169. const agent = host.ctx.agents.get(sessionId) as Agent
  170. const idle = waitForIdle(host.ctx, agent)
  171. expectOk(await host.api.sessions.prompt(request({
  172. sessionId,
  173. mode: 'queue' as const,
  174. content: [{ type: 'text' as const, text: 'go' }],
  175. })))
  176. await idle
  177. const requestText = adapter.requests[0]?.messages
  178. .flatMap(message => message.content)
  179. .filter(block => block.type === 'text')
  180. .map(block => block.text)
  181. .join('\n') ?? ''
  182. expect(requestText).toContain('Instructions from: AGENTS.md')
  183. expect(requestText).toContain('host-workspace-context-probe')
  184. })
  185. })
  186. describe('host.describe', () => {
  187. it('reports version, cwd, defaults, and the attached count', async () => {
  188. const { api } = await boot()
  189. const value = expectOk(await api.host.describe(request({})))
  190. expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
  191. })
  192. it('omits activeModel when the configured model is absent from the provider catalog', async () => {
  193. host = await startHost({
  194. boot: {
  195. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-describe-missing-model-')),
  196. workspaceContext: false,
  197. provider: 'scripted',
  198. model: 'missing-model',
  199. },
  200. })
  201. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text'], 'other-model'))
  202. expect(expectOk(await host.api.host.describe(request({})))).not.toHaveProperty('activeModel')
  203. })
  204. })
  205. describe('sessions.create / list', () => {
  206. it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
  207. const { api } = await boot()
  208. const created = await api.sessions.create(request({ cwd: '/tmp' }))
  209. const { sessionId } = expectOk(created)
  210. expect(created.rpcId).toMatch(/^req-/)
  211. const second = expectOk(await api.sessions.create(request({}))).sessionId
  212. const { items } = expectOk(await api.sessions.list(request({})))
  213. expect(items.map(item => item.sessionId)).toContain(sessionId)
  214. expect(items.map(item => item.sessionId)).toContain(second)
  215. const first = items.find(item => item.sessionId === sessionId)
  216. expect(first?.cwd).toBe('/tmp')
  217. expect(first?.running).toBe(false)
  218. expect(first?.parentSessionId).toBeUndefined()
  219. })
  220. })
  221. describe('sessions.prompt / cancel', () => {
  222. it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
  223. const running = await boot([textResponse('pong')])
  224. const { api, ctx } = running
  225. const { sessionId } = expectOk(await api.sessions.create(request({})))
  226. const agent = ctx.agents.get(sessionId)
  227. expect(agent).toBeDefined()
  228. const idle = waitForIdle(ctx, agent as Agent)
  229. const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
  230. expectOk(await api.sessions.prompt(promptRequest))
  231. await idle
  232. const value = expectOk(await api.sessions.history(request({ sessionId })))
  233. const events = value.events.map(entry => entry.event)
  234. const userEvent = events.find(event => event.type === 'user/message') as
  235. | { data: { source?: { rpcId?: string } } } | undefined
  236. expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
  237. const reply = events.find(event => event.type === 'assistant/message')
  238. expect(reply).toBeDefined()
  239. })
  240. it('steer on an idle agent falls through to send', async () => {
  241. const running = await boot([textResponse('steered')])
  242. const { api, ctx } = running
  243. const { sessionId } = expectOk(await api.sessions.create(request({})))
  244. const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
  245. expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
  246. await idle
  247. })
  248. it('errors session-not-found on a ghost session', async () => {
  249. const { api } = await boot()
  250. const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
  251. expect(response.result.ok).toBe(false)
  252. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  253. })
  254. it('maps a synchronous send throw to agent-busy', async () => {
  255. const { api, ctx } = await boot()
  256. const { sessionId } = expectOk(await api.sessions.create(request({})))
  257. vi.spyOn(ctx.agents.get(sessionId) as Agent, 'send').mockImplementation(() => {
  258. throw new Error('disposed during prompt')
  259. })
  260. const response = await api.sessions.prompt(request({
  261. sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }],
  262. }))
  263. expect(response.result.ok).toBe(false)
  264. if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
  265. })
  266. it('persists uploaded bytes before the user event and serves them only through the owning session', async () => {
  267. const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-image-session-'))
  268. const dshHome = mkdtempSync(join(tmpdir(), 'dsh-image-home-'))
  269. host = await startHost({
  270. boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' },
  271. })
  272. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('seen')]))
  273. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  274. const agent = host.ctx.agents.get(sessionId) as Agent
  275. const idle = waitForIdle(host.ctx, agent)
  276. const response = await host.api.sessions.prompt(request({
  277. sessionId,
  278. mode: 'queue' as const,
  279. content: [
  280. { type: 'text' as const, text: 'describe' },
  281. { type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64, name: '/tmp/pixel.png' },
  282. ],
  283. }))
  284. expectOk(response)
  285. await idle
  286. const user = agent.session.events.find(event => event.type === 'user/message')
  287. const content = (user?.data as { content?: ContentBlock[] } | undefined)?.content ?? []
  288. const image = content.find(block => block.type === 'image')
  289. expect(image?.type).toBe('image')
  290. if (image?.type !== 'image') throw new Error('image block missing')
  291. expect(JSON.stringify(user)).not.toContain(PNG_BASE64)
  292. expect(image.attachment.name).toBe('pixel.png')
  293. const sha256 = String(image.attachment.attachmentId).slice('sha256:'.length)
  294. const object = join(dshHome, 'attachments', 'v1', 'objects', sha256.slice(0, 2), sha256)
  295. expect(existsSync(object)).toBe(true)
  296. expect(readFileSync(object).toString('base64')).toBe(PNG_BASE64)
  297. const loaded = expectOk(await host.api.sessions.attachment(request({
  298. sessionId, attachmentId: image.attachment.attachmentId,
  299. })))
  300. expect(loaded).toEqual({ attachment: image.attachment, data: PNG_BASE64 })
  301. const { sessionId: other } = expectOk(await host.api.sessions.create(request({})))
  302. const denied = await host.api.sessions.attachment(request({
  303. sessionId: other, attachmentId: image.attachment.attachmentId,
  304. }))
  305. expect(denied.result).toMatchObject({
  306. ok: false, error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } },
  307. })
  308. const { sessionId: nestedSession } = expectOk(await host.api.sessions.create(request({})))
  309. const nestedAgent = host.ctx.agents.get(nestedSession) as Agent
  310. nestedAgent.session.append('context/message', {
  311. content: [
  312. null,
  313. [],
  314. {
  315. type: 'tool-result',
  316. toolCallId: 'nested-text' as never,
  317. content: [{ type: 'text', text: 'no image here' }],
  318. },
  319. {
  320. type: 'tool-result',
  321. toolCallId: 'nested-image' as never,
  322. content: [{ type: 'image', attachment: image.attachment }],
  323. },
  324. ] as never,
  325. source: { kind: 'user' },
  326. }, { surfaceOp: 'append' })
  327. expectOk(await host.api.sessions.attachment(request({
  328. sessionId: nestedSession,
  329. attachmentId: image.attachment.attachmentId,
  330. })))
  331. const { sessionId: streamedSession } = expectOk(await host.api.sessions.create(request({})))
  332. const streamedAgent = host.ctx.agents.get(streamedSession) as Agent
  333. streamedAgent.session.append('assistant/chunk', {
  334. turn: 1,
  335. step: 1,
  336. chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: image.attachment } },
  337. })
  338. expectOk(await host.api.sessions.attachment(request({
  339. sessionId: streamedSession,
  340. attachmentId: image.attachment.attachmentId,
  341. })))
  342. const missingRef = {
  343. ...image.attachment,
  344. attachmentId: `sha256:${'b'.repeat(64)}` as never,
  345. }
  346. streamedAgent.session.append('context/message', {
  347. content: [{ type: 'image', attachment: missingRef }],
  348. source: { kind: 'user' },
  349. }, { surfaceOp: 'append' })
  350. const missing = await host.api.sessions.attachment(request({
  351. sessionId: streamedSession,
  352. attachmentId: missingRef.attachmentId,
  353. }))
  354. expect(missing.result).toMatchObject({
  355. ok: false, error: { details: { reason: 'ATTACHMENT_NOT_FOUND' } },
  356. })
  357. const read = vi.spyOn(host.ctx.attachments, 'readImage').mockRejectedValueOnce(new Error('read failed'))
  358. const internal = await host.api.sessions.attachment(request({
  359. sessionId: nestedSession,
  360. attachmentId: image.attachment.attachmentId,
  361. }))
  362. expect(internal.result).toMatchObject({ ok: false, error: { code: 'internal' } })
  363. read.mockRestore()
  364. const ghost = await host.api.sessions.attachment(request({
  365. sessionId: 'session-ghost' as SessionId,
  366. attachmentId: image.attachment.attachmentId,
  367. }))
  368. expect(ghost.result).toMatchObject({ ok: false, error: { code: 'session-not-found' } })
  369. })
  370. it('rejects non-canonical, excessive-count, and excessive-byte image prompts', async () => {
  371. const running = await boot()
  372. const { sessionId } = expectOk(await running.api.sessions.create(request({})))
  373. for (const data of ['', 'AB==']) {
  374. const invalid = await running.api.sessions.prompt(request({
  375. sessionId,
  376. mode: 'queue' as const,
  377. content: [{ type: 'image' as const, mediaType: 'image/png' as const, data }],
  378. }))
  379. expect(invalid.result).toMatchObject({
  380. ok: false, error: { details: { reason: 'INVALID_IMAGE_BASE64' } },
  381. })
  382. }
  383. const attachmentService = running.ctx.attachments as unknown as {
  384. imageLimits: typeof running.ctx.attachments.imageLimits
  385. }
  386. attachmentService.imageLimits = {
  387. ...running.ctx.attachments.imageLimits,
  388. maxImagesPerMessage: 1,
  389. }
  390. const tooMany = await running.api.sessions.prompt(request({
  391. sessionId,
  392. mode: 'queue' as const,
  393. content: Array.from({ length: 2 }, () => ({
  394. type: 'image' as const,
  395. mediaType: 'image/png' as const,
  396. data: PNG_BASE64,
  397. })),
  398. }))
  399. expect(tooMany.result).toMatchObject({
  400. ok: false, error: { details: { reason: 'TOO_MANY_IMAGES' } },
  401. })
  402. attachmentService.imageLimits = {
  403. ...running.ctx.attachments.imageLimits,
  404. maxImagesPerMessage: 10,
  405. maxMessageImageBytes: 100,
  406. }
  407. const excessiveBytes = await running.api.sessions.prompt(request({
  408. sessionId,
  409. mode: 'queue' as const,
  410. content: Array.from({ length: 2 }, () => ({
  411. type: 'image' as const,
  412. mediaType: 'image/png' as const,
  413. data: PNG_BASE64,
  414. })),
  415. }))
  416. expect(excessiveBytes.result).toMatchObject({
  417. ok: false, error: { details: { reason: 'IMAGES_TOO_LARGE' } },
  418. })
  419. })
  420. it('rejects images for an explicitly text-only model without creating a session event', async () => {
  421. const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-text-session-'))
  422. const dshHome = mkdtempSync(join(tmpdir(), 'dsh-text-home-'))
  423. host = await startHost({
  424. boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' },
  425. })
  426. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
  427. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  428. const response = await host.api.sessions.prompt(request({
  429. sessionId, mode: 'queue' as const,
  430. content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
  431. }))
  432. expect(response.result).toMatchObject({
  433. ok: false, error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
  434. })
  435. expect(host.ctx.agents.get(sessionId)?.session.events.some(event => event.type === 'user/message')).toBe(false)
  436. expect(existsSync(join(dshHome, 'attachments'))).toBe(false)
  437. })
  438. it('preflights the session route instead of the host default model', async () => {
  439. const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-routed-session-'))
  440. const dshHome = mkdtempSync(join(tmpdir(), 'dsh-routed-home-'))
  441. host = await startHost({
  442. boot: { persistenceRoot, workspaceContext: false, dshHome, provider: 'scripted', model: 'test-model' },
  443. })
  444. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
  445. host.ctx.llm.registerAdapter(
  446. ['visual'],
  447. new ScriptedAdapter([textResponse('seen')], ['text', 'image'], 'visual-model'),
  448. )
  449. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  450. const agent = host.ctx.agents.get(sessionId) as Agent
  451. agent.options.provider = 'visual'
  452. agent.options.model = 'visual-model'
  453. const idle = waitForIdle(host.ctx, agent)
  454. expectOk(await host.api.sessions.prompt(request({
  455. sessionId,
  456. mode: 'queue' as const,
  457. content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
  458. })))
  459. await idle
  460. expect(agent.session.events.some(event => event.type === 'user/message')).toBe(true)
  461. expect(existsSync(join(dshHome, 'attachments'))).toBe(true)
  462. })
  463. it('falls back to host routing when a session has no routed or agent model options', async () => {
  464. host = await startHost({
  465. boot: {
  466. persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-route-default-')),
  467. workspaceContext: false,
  468. provider: 'scripted',
  469. model: 'test-model',
  470. },
  471. })
  472. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([], ['text']))
  473. const { sessionId } = expectOk(await host.api.sessions.create(request({})))
  474. const agent = host.ctx.agents.get(sessionId) as Agent
  475. agent.options.provider = undefined as never
  476. agent.options.model = undefined as never
  477. const response = await host.api.sessions.prompt(request({
  478. sessionId,
  479. mode: 'queue' as const,
  480. content: [{ type: 'image' as const, mediaType: 'image/png' as const, data: PNG_BASE64 }],
  481. }))
  482. expect(response.result).toMatchObject({
  483. ok: false, error: { details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } },
  484. })
  485. })
  486. it('cancels an attached agent and rejects an unattached one', async () => {
  487. const running = await boot(['hang'])
  488. const { api, ctx } = running
  489. const { sessionId } = expectOk(await api.sessions.create(request({})))
  490. const agent = ctx.agents.get(sessionId) as Agent
  491. agent.send([{ type: 'text', text: 'run forever' }])
  492. expectOk(await api.sessions.cancel(request({ sessionId })))
  493. const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
  494. expect(missing.result.ok).toBe(false)
  495. if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
  496. })
  497. })
  498. describe('sessions.history', () => {
  499. it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
  500. const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
  501. const first = await startHost({
  502. boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
  503. })
  504. first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
  505. const { sessionId } = expectOk(await first.api.sessions.create(request({})))
  506. const agent = first.ctx.agents.get(sessionId) as Agent
  507. const idle = waitForIdle(first.ctx, agent)
  508. agent.send([{ type: 'text', text: 'save me' }])
  509. await idle
  510. await first.dispose()
  511. host = await startHost({
  512. boot: { persistenceRoot, workspaceContext: false, provider: 'scripted', model: 'test-model' },
  513. })
  514. host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
  515. expect(host.ctx.agents.get(sessionId)).toBeUndefined()
  516. const [a, b] = await Promise.all([
  517. host.api.sessions.history(request({ sessionId })),
  518. host.api.sessions.history(request({ sessionId })),
  519. ])
  520. for (const response of [a, b]) {
  521. const value = expectOk(response)
  522. expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
  523. }
  524. expect(host.ctx.agents.get(sessionId)).toBeDefined()
  525. expect(host.ctx.agents.list()).toHaveLength(1)
  526. })
  527. it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
  528. const { api } = await boot()
  529. const ghost = 'session-ghost' as SessionId
  530. const [first, second] = await Promise.all([
  531. api.sessions.history(request({ sessionId: ghost })),
  532. api.sessions.history(request({ sessionId: ghost })),
  533. ])
  534. for (const response of [first, second]) {
  535. expect(response.result.ok).toBe(false)
  536. if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
  537. }
  538. })
  539. it('paginates backwards on message boundaries with hasMore', async () => {
  540. const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
  541. const { api, ctx } = running
  542. const { sessionId } = expectOk(await api.sessions.create(request({})))
  543. const agent = ctx.agents.get(sessionId) as Agent
  544. for (const text of ['q1', 'q2', 'q3']) {
  545. const idle = waitForIdle(ctx, agent)
  546. agent.send([{ type: 'text', text }])
  547. await idle
  548. }
  549. const all = expectOk(await api.sessions.history(request({ sessionId })))
  550. expect(all.hasMore).toBe(false)
  551. const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
  552. expect(messageCount).toBe(6)
  553. const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
  554. expect(lastPage.hasMore).toBe(true)
  555. expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
  556. expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
  557. const firstSeq = lastPage.events[0]?.event.seq as number
  558. const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
  559. expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
  560. expect(olderPage.hasMore).toBe(true)
  561. expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
  562. })
  563. })
  564. describe('events streams', () => {
  565. it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
  566. const running = await boot()
  567. const { api } = running
  568. const ac = new AbortController()
  569. const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
  570. // no sessions yet: next() must pend on the queue's waiter, not the buffer
  571. const pending = stream.next()
  572. const { sessionId } = expectOk(await api.sessions.create(request({})))
  573. const frame = (await pending).value as RpcRequest<MuxFrame>
  574. expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
  575. ac.abort()
  576. expect((await stream.next()).done).toBe(true)
  577. })
  578. it('lists fork lineage and announces it on the host stream', async () => {
  579. const running = await boot()
  580. const { api, ctx } = running
  581. const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
  582. const ac = new AbortController()
  583. const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
  584. const child = `session-child-${String(Date.now())}` as SessionId
  585. const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
  586. expect(handle.agent.id).toBe(child)
  587. const added = (await stream.next()).value as RpcRequest<HostFrame>
  588. expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
  589. const { items } = expectOk(await api.sessions.list(request({})))
  590. expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
  591. await handle.dispose()
  592. let frame: RpcRequest<HostFrame>
  593. do frame = (await stream.next()).value as RpcRequest<HostFrame>
  594. while (frame.payload.type !== 'host/session-removed')
  595. expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
  596. ac.abort()
  597. })
  598. it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
  599. const running = await boot([textResponse('live')])
  600. const { api, ctx } = running
  601. const { sessionId } = expectOk(await api.sessions.create(request({})))
  602. const ac = new AbortController()
  603. const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
  604. const baseline = await stream.next()
  605. expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
  606. const agent = ctx.agents.get(sessionId) as Agent
  607. const idle = waitForIdle(ctx, agent)
  608. agent.send([{ type: 'text', text: 'go' }])
  609. await idle
  610. const live = await stream.next()
  611. expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
  612. const other = expectOk(await api.sessions.create(request({}))).sessionId
  613. let frame: RpcRequest<MuxFrame>
  614. do frame = (await stream.next()).value as RpcRequest<MuxFrame>
  615. while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
  616. ac.abort()
  617. expect((await stream.next()).done).toBe(true)
  618. })
  619. it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
  620. const running = await boot([textResponse('x')])
  621. const { api, ctx } = running
  622. const ac = new AbortController()
  623. const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
  624. const { sessionId } = expectOk(await api.sessions.create(request({})))
  625. const added = await stream.next()
  626. expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
  627. const agent = ctx.agents.get(sessionId) as Agent
  628. const idle = waitForIdle(ctx, agent)
  629. agent.send([{ type: 'text', text: 'run' }])
  630. await idle
  631. const runningFrame = await stream.next()
  632. expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
  633. const idleFrame = await stream.next()
  634. expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
  635. // Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
  636. // enforces; dispatch the way the loop does.
  637. agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
  638. const errorFrame = await stream.next()
  639. expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
  640. ac.abort()
  641. // Push-after-done: an event landing between abort and generator wind-down
  642. // must be dropped silently, not crash the queue.
  643. agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
  644. expect((await stream.next()).done).toBe(true)
  645. })
  646. })
  647. describe('respond stub', () => {
  648. it('always reports not-pending (step2 registry pending)', async () => {
  649. const { api } = await boot()
  650. const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
  651. expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
  652. })
  653. })