headless.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
  2. import { describe, expect, it } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
  5. import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
  6. import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model'
  7. import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
  8. import SessionStore from '@deepseek-ai/dsh-session'
  9. import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
  10. import { apply, Config, type HeadlessIo } from '../src/index.ts'
  11. interface Script {
  12. before?(session: Session): void
  13. afterPrompt(session: Session, message: UserMessage): Promise<void> | void
  14. }
  15. function appendTurn(
  16. session: Session,
  17. turn: number,
  18. message: UserMessage,
  19. text: string | undefined,
  20. completed: boolean,
  21. ): void {
  22. session.append('turn/start', { turn })
  23. session.append('step/start', { turn, step: 1 })
  24. session.append('user/message', message, { surfaceOp: 'append' })
  25. if (text !== undefined) {
  26. session.append('assistant/message', {
  27. turn,
  28. step: 1,
  29. message: createAssistantMessage({
  30. content: [{ type: 'text', text }],
  31. source: { provider: 'test-provider', model: 'test-model' },
  32. }),
  33. }, { surfaceOp: 'append' })
  34. }
  35. session.append('step/end', { turn, step: 1 })
  36. session.append('turn/end', {
  37. turn,
  38. reason: completed
  39. ? { kind: 'completed' }
  40. : { kind: 'aborted', reason: { kind: 'user' } },
  41. })
  42. }
  43. /** Mount the real registries around a small scripted Agent factory. */
  44. async function bench(script: Script): Promise<{
  45. ctx: Context
  46. run(): Promise<{ code: number; out: string; err: string; order: string[] }>
  47. }> {
  48. const ctx = new Context()
  49. await ctx.plugin(SessionStore)
  50. await ctx.plugin(AgentRegistry)
  51. await ctx.plugin(AgentDefaultModelService, { provider: 'test-provider', model: 'test-model' })
  52. ctx.agents.setFactory({
  53. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  54. const session = ctx.sessions.create(options.sessionId, {
  55. ...options.meta === undefined ? {} : { meta: options.meta },
  56. })
  57. let idle = Promise.resolve()
  58. const agent = {} as Agent
  59. const agentCtx = ownerCtx.extend({ agent })
  60. Object.assign(agent, {
  61. id: session.id,
  62. options: options.agentOptions ?? {},
  63. session,
  64. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  65. status: 'idle',
  66. ctx: agentCtx,
  67. cancel: () => {},
  68. runMaintenance: () => Promise.reject(new Error('not used')),
  69. send: () => {},
  70. followup: (message: UserMessage) => {
  71. agent.inbox.append('next-turn', message)
  72. idle = Promise.resolve().then(() => script.afterPrompt(session, message))
  73. },
  74. steer: () => {},
  75. inject: () => {},
  76. whenIdle: () => idle,
  77. } satisfies Partial<Agent>)
  78. await options.setup?.(agentCtx)
  79. script.before?.(session)
  80. ctx.agents.register(agent)
  81. return { agent, dispose: () => Promise.resolve() }
  82. },
  83. resume: () => Promise.reject(new Error('not used')),
  84. })
  85. return {
  86. ctx,
  87. run: async () => {
  88. let out = ''
  89. let err = ''
  90. const order: string[] = []
  91. ctx.on('session/flush', () => { order.push('flush') })
  92. const exited = new Promise<number>((resolve) => {
  93. const io: HeadlessIo = {
  94. stdout: { write: (chunk: string) => { out += chunk; return true } },
  95. stderr: { write: (chunk: string) => { err += chunk; return true } },
  96. exit: (code) => { order.push('exit'); resolve(code) },
  97. }
  98. ctx.provide('headlessIo', io)
  99. })
  100. apply(ctx, { task: 'do the thing' })
  101. return { code: await exited, out, err, order }
  102. },
  103. }
  104. }
  105. describe('headless runner', () => {
  106. it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
  107. const test = await bench({
  108. before(session) {
  109. const setupMessage = {
  110. role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
  111. } as UserMessage
  112. appendTurn(session, 0, setupMessage, 'pre-task noise', true)
  113. },
  114. async afterPrompt(session, message) {
  115. await Promise.resolve()
  116. appendTurn(session, 1, message, '', true)
  117. appendTurn(session, 2, message, 'final answer', true)
  118. },
  119. })
  120. const result = await test.run()
  121. expect(result).toEqual({
  122. code: 0,
  123. out: 'final answer\n',
  124. err: '',
  125. order: ['flush', 'exit'],
  126. })
  127. await test.ctx.fiber.dispose()
  128. })
  129. it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
  130. const test = await bench({
  131. afterPrompt: async (session, message) => {
  132. await new Promise(resolve => setTimeout(resolve, 5))
  133. appendTurn(session, 1, message, 'race-free answer', true)
  134. },
  135. })
  136. expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' })
  137. await test.ctx.fiber.dispose()
  138. })
  139. it('exits 1 when the final turn does not complete', async () => {
  140. const test = await bench({
  141. afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
  142. })
  143. expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
  144. await test.ctx.fiber.dispose()
  145. })
  146. it('prints the durable model failure when the final turn ends in error', async () => {
  147. const test = await bench({
  148. afterPrompt(session, message) {
  149. session.append('turn/start', { turn: 1 })
  150. session.append('step/start', { turn: 1, step: 1 })
  151. session.append('user/message', message, { surfaceOp: 'append' })
  152. session.append('step/end', { turn: 1, step: 1 })
  153. session.append('turn/end', {
  154. turn: 1,
  155. reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
  156. })
  157. },
  158. })
  159. expect(await test.run()).toMatchObject({
  160. code: 1,
  161. out: '\n',
  162. err: 'dsh: SERVER: provider unavailable\n',
  163. })
  164. await test.ctx.fiber.dispose()
  165. })
  166. it('exits 1 when the owned interval contains no turn', async () => {
  167. const test = await bench({ afterPrompt: () => {} })
  168. expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
  169. await test.ctx.fiber.dispose()
  170. })
  171. it('reports a direct Agent creation failure', async () => {
  172. const ctx = new Context()
  173. let err = ''
  174. const exited = new Promise<number>((resolve) => {
  175. ctx.provide('headlessIo', {
  176. stdout: { write: () => true },
  177. stderr: { write: (chunk: string) => { err += chunk; return true } },
  178. exit: resolve,
  179. } satisfies HeadlessIo)
  180. })
  181. ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  182. ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
  183. ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never)
  184. apply(ctx, { task: 't' })
  185. expect(await exited).toBe(1)
  186. expect(err).toBe('dsh: factory exploded\n')
  187. await ctx.fiber.dispose()
  188. })
  189. it('stringifies a non-Error Agent creation failure', async () => {
  190. const ctx = new Context()
  191. let err = ''
  192. const exited = new Promise<number>((resolve) => {
  193. ctx.provide('headlessIo', {
  194. stdout: { write: () => true },
  195. stderr: { write: (chunk: string) => { err += chunk; return true } },
  196. exit: resolve,
  197. } satisfies HeadlessIo)
  198. })
  199. ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  200. ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
  201. const rejected = {
  202. then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
  203. reject('factory exploded')
  204. },
  205. }
  206. ctx.provide('agents', { create: () => rejected } as never)
  207. apply(ctx, { task: 't' })
  208. expect(await exited).toBe(1)
  209. expect(err).toBe('dsh: factory exploded\n')
  210. await ctx.fiber.dispose()
  211. })
  212. it('abandons a run when the tree is disposed during Loader settlement', async () => {
  213. const ctx = new Context()
  214. let exited = false
  215. ctx.provide('headlessIo', {
  216. stdout: { write: () => true },
  217. stderr: { write: () => true },
  218. exit: () => { exited = true },
  219. } satisfies HeadlessIo)
  220. const services = ctx.plugin((child: Context) => {
  221. child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  222. child.provide('sessions', {} as never)
  223. child.provide('agents', {} as never)
  224. })
  225. await services
  226. let release: () => void
  227. const settlement = new Promise<void>((resolve) => { release = resolve })
  228. ctx.provide('loader', { await: () => settlement } as never)
  229. apply(ctx, { task: 't' })
  230. await services.dispose()
  231. release!()
  232. await new Promise(resolve => setTimeout(resolve, 10))
  233. expect(exited).toBe(false)
  234. await ctx.fiber.dispose()
  235. })
  236. it('fails loud without the launcher-owned headlessIo seam', () => {
  237. const ctx = new Context()
  238. expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.headlessIo')
  239. })
  240. it('validates config: the task is required', () => {
  241. expect(() => new Config({} as never)).toThrow()
  242. expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
  243. })
  244. })