headless.spec.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424
  1. /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */
  2. import { afterEach, 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, AssistantStreamFrame, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
  6. import AgentDefaultModelConfig from '@deepseek-ai/dsh-agent-default-model'
  7. import { LlmAttemptId, createAssistantMessage, type StreamChunk } 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, internals } from '../src/index.ts'
  11. const originalInternals = { ...internals }
  12. afterEach(() => { Object.assign(internals, originalInternals) })
  13. interface Script {
  14. before?(session: Session): void
  15. afterPrompt(session: Session, message: UserMessage, agent: Agent): Promise<void> | void
  16. }
  17. const frameStates = new WeakMap<Agent, { attemptId: ReturnType<typeof LlmAttemptId>; revision: number; index: number }>()
  18. function startFrames(agent: Agent, turn = 1, step = 1): void {
  19. const state = { attemptId: LlmAttemptId(`${agent.id}:test`), revision: 1, index: 0 }
  20. frameStates.set(agent, state)
  21. agent.ctx.emit('agent/assistant-stream', {
  22. agent,
  23. frame: {
  24. type: 'start', attemptId: state.attemptId, revision: state.revision, turn, step,
  25. },
  26. })
  27. }
  28. function emitChunk(agent: Agent, chunk: StreamChunk): void {
  29. const state = frameStates.get(agent)
  30. if (state === undefined) throw new Error('test Assistant frames have not started')
  31. const frame: AssistantStreamFrame = {
  32. type: 'chunk', attemptId: state.attemptId, revision: ++state.revision,
  33. index: state.index++, time: Date.now(), chunk,
  34. }
  35. agent.ctx.emit('agent/assistant-stream', { agent, frame })
  36. }
  37. function appendTurn(
  38. session: Session,
  39. turn: number,
  40. message: UserMessage,
  41. text: string | undefined,
  42. completed: boolean,
  43. ): void {
  44. session.append('turn/start', { turn })
  45. session.append('step/start', { turn, step: 1 })
  46. session.append('user/message', message, { surfaceOp: 'append' })
  47. if (text !== undefined) {
  48. session.append('assistant/message', {
  49. stream: [],
  50. turn,
  51. step: 1,
  52. message: createAssistantMessage({
  53. content: [{ type: 'text', text }],
  54. source: { provider: 'test-provider', model: 'test-model' },
  55. }),
  56. }, { surfaceOp: 'append' })
  57. }
  58. session.append('step/end', { turn, step: 1 })
  59. session.append('turn/end', {
  60. turn,
  61. reason: completed
  62. ? { kind: 'completed' }
  63. : { kind: 'aborted', reason: { kind: 'user' } },
  64. })
  65. }
  66. /** Mount the real registries around a small scripted Agent factory. */
  67. async function bench(script: Script): Promise<{
  68. ctx: Context
  69. output(): { out: string; err: string; order: string[] }
  70. run(): Promise<{ code: number; out: string; err: string; order: string[] }>
  71. }> {
  72. const ctx = new Context()
  73. let out = ''
  74. let err = ''
  75. const order: string[] = []
  76. await ctx.plugin(SessionStore)
  77. await ctx.plugin(AgentRegistry)
  78. await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' })
  79. ctx.agents.setFactory({
  80. async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise<AgentHandle> {
  81. const session = ctx.sessions.create(options.sessionId, {
  82. ...options.meta === undefined ? {} : { meta: options.meta },
  83. })
  84. let idle = Promise.resolve()
  85. const agent = {} as Agent
  86. const agentCtx = ownerCtx.extend({ agent })
  87. Object.assign(agent, {
  88. id: session.id,
  89. options: options.agentOptions ?? {},
  90. session,
  91. inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
  92. status: 'idle',
  93. ctx: agentCtx,
  94. cancel: () => {},
  95. runMaintenance: () => Promise.reject(new Error('not used')),
  96. send: () => {},
  97. followup: (message: UserMessage) => {
  98. agent.inbox.append('next-turn', message)
  99. idle = Promise.resolve().then(() => script.afterPrompt(session, message, agent))
  100. },
  101. steer: () => {},
  102. inject: () => {},
  103. whenIdle: () => idle,
  104. } satisfies Partial<Agent>)
  105. await options.setup?.(agentCtx)
  106. script.before?.(session)
  107. ctx.agents.register(agent)
  108. return { agent, dispose: () => Promise.resolve() }
  109. },
  110. resume: () => Promise.reject(new Error('not used')),
  111. })
  112. return {
  113. ctx,
  114. output: () => ({ out, err, order: [...order] }),
  115. run: async () => {
  116. ctx.on('session/flush', () => { order.push('flush') })
  117. internals.stdout = { write: (chunk: string) => { out += chunk; return true } }
  118. internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
  119. const exited = new Promise<number>((resolve) => {
  120. ctx.provide('appExit', (code: number) => { order.push('exit'); resolve(code) })
  121. })
  122. apply(ctx, { task: 'do the thing' })
  123. return { code: await exited, out, err, order }
  124. },
  125. }
  126. }
  127. describe('headless runner', () => {
  128. it('aggregates the final text across the complete idle-to-idle interval and flushes before exit', async () => {
  129. const test = await bench({
  130. before(session) {
  131. const setupMessage = {
  132. role: 'user', content: [{ type: 'text', text: 'setup' }], source: { kind: 'user' }, id: 'setup',
  133. } as UserMessage
  134. appendTurn(session, 0, setupMessage, 'pre-task noise', true)
  135. },
  136. async afterPrompt(session, message) {
  137. await Promise.resolve()
  138. appendTurn(session, 1, message, '', true)
  139. appendTurn(session, 2, message, 'final answer', true)
  140. },
  141. })
  142. const result = await test.run()
  143. expect(result).toEqual({
  144. code: 0,
  145. out: 'final answer\n',
  146. err: '',
  147. order: ['flush', 'exit'],
  148. })
  149. await test.ctx.fiber.dispose()
  150. })
  151. it('waits for asynchronously appended events instead of racing Agent idleness', async () => {
  152. const test = await bench({
  153. afterPrompt: async (session, message) => {
  154. await new Promise(resolve => setTimeout(resolve, 5))
  155. appendTurn(session, 1, message, 'race-free answer', true)
  156. },
  157. })
  158. expect(await test.run()).toMatchObject({ code: 0, out: 'race-free answer\n', err: '' })
  159. await test.ctx.fiber.dispose()
  160. })
  161. it('streams reasoning before the Agent becomes idle and terminates its stderr line', async () => {
  162. const reasoningAppended = Promise.withResolvers<undefined>()
  163. const release = Promise.withResolvers<undefined>()
  164. const test = await bench({
  165. async afterPrompt(session, message, agent) {
  166. session.append('turn/start', { turn: 1 })
  167. session.append('step/start', { turn: 1, step: 1 })
  168. session.append('user/message', message, { surfaceOp: 'append' })
  169. startFrames(agent)
  170. emitChunk(agent, { type: 'block-start', index: 0, blockType: 'reasoning' })
  171. emitChunk(agent, { type: 'reasoning-delta', index: 0, text: '' })
  172. emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'checking the workspace' })
  173. emitChunk(agent, { type: 'reasoning-delta', index: 0, text: ' safely\n' })
  174. emitChunk(agent, { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } })
  175. emitChunk(agent, { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } })
  176. emitChunk(agent, { type: 'block-start', index: 1, blockType: 'reasoning' })
  177. emitChunk(agent, { type: 'reasoning-delta', index: 1, text: 'second pass\n' })
  178. reasoningAppended.resolve(undefined)
  179. await release.promise
  180. emitChunk(agent, { type: 'block-start', index: 2, blockType: 'text' })
  181. emitChunk(agent, { type: 'text-delta', index: 2, text: 'done' })
  182. emitChunk(agent, { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } })
  183. session.append('assistant/message', {
  184. stream: [],
  185. turn: 1,
  186. step: 1,
  187. message: createAssistantMessage({
  188. content: [{ type: 'text', text: 'done' }],
  189. source: { provider: 'test-provider', model: 'test-model' },
  190. }),
  191. }, { surfaceOp: 'append' })
  192. session.append('step/end', { turn: 1, step: 1 })
  193. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  194. },
  195. })
  196. const running = test.run()
  197. await reasoningAppended.promise
  198. const other = test.ctx.sessions.create()
  199. other.append('turn/start', { turn: 1 })
  200. other.append('step/start', { turn: 1, step: 1 })
  201. test.ctx.emit('agent/assistant-stream', {
  202. agent: { session: other } as Agent,
  203. frame: {
  204. type: 'chunk', attemptId: LlmAttemptId('other'), revision: 1,
  205. index: 0, time: Date.now(), chunk: { type: 'reasoning-delta', index: 0, text: 'other session' },
  206. },
  207. })
  208. const streamed = test.output()
  209. release.resolve(undefined)
  210. const result = await running
  211. expect(streamed).toEqual({
  212. out: '',
  213. err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
  214. order: [],
  215. })
  216. expect(result).toEqual({
  217. code: 0,
  218. out: 'done\n',
  219. err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n',
  220. order: ['flush', 'exit'],
  221. })
  222. await test.ctx.fiber.dispose()
  223. })
  224. it('closes an unterminated reasoning line as soon as the attempt ends', async () => {
  225. const reasoningAppended = Promise.withResolvers<undefined>()
  226. const releaseEnd = Promise.withResolvers<undefined>()
  227. const ended = Promise.withResolvers<undefined>()
  228. const finish = Promise.withResolvers<undefined>()
  229. const test = await bench({
  230. async afterPrompt(session, message, agent) {
  231. session.append('turn/start', { turn: 1 })
  232. session.append('step/start', { turn: 1, step: 1 })
  233. session.append('user/message', message, { surfaceOp: 'append' })
  234. startFrames(agent)
  235. emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'unfinished reasoning' })
  236. reasoningAppended.resolve(undefined)
  237. await releaseEnd.promise
  238. const state = frameStates.get(agent)
  239. if (state === undefined) throw new Error('test Assistant frames have not started')
  240. agent.ctx.emit('agent/assistant-stream', {
  241. agent,
  242. frame: {
  243. type: 'end', attemptId: state.attemptId, revision: ++state.revision,
  244. index: state.index, outcome: { kind: 'abandoned' },
  245. },
  246. })
  247. ended.resolve(undefined)
  248. await finish.promise
  249. session.append('step/end', { turn: 1, step: 1 })
  250. session.append('turn/end', {
  251. turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } },
  252. })
  253. },
  254. })
  255. const running = test.run()
  256. await reasoningAppended.promise
  257. expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning')
  258. releaseEnd.resolve(undefined)
  259. await ended.promise
  260. expect(test.output().err).toBe('dsh: reasoning:\nunfinished reasoning\n')
  261. finish.resolve(undefined)
  262. await expect(running).resolves.toMatchObject({ code: 1 })
  263. await test.ctx.fiber.dispose()
  264. })
  265. it('exits 1 when the final turn does not complete', async () => {
  266. const test = await bench({
  267. afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) },
  268. })
  269. expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
  270. await test.ctx.fiber.dispose()
  271. })
  272. it('prints the durable model failure when the final turn ends in error', async () => {
  273. const test = await bench({
  274. afterPrompt(session, message) {
  275. session.append('turn/start', { turn: 1 })
  276. session.append('step/start', { turn: 1, step: 1 })
  277. session.append('user/message', message, { surfaceOp: 'append' })
  278. session.append('step/end', { turn: 1, step: 1 })
  279. session.append('turn/end', {
  280. turn: 1,
  281. reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
  282. })
  283. },
  284. })
  285. expect(await test.run()).toMatchObject({
  286. code: 1,
  287. out: '\n',
  288. err: 'dsh: SERVER: provider unavailable\n',
  289. })
  290. await test.ctx.fiber.dispose()
  291. })
  292. it('separates an unterminated reasoning prefix from the terminal model failure', async () => {
  293. const test = await bench({
  294. afterPrompt(session, message, agent) {
  295. session.append('turn/start', { turn: 1 })
  296. session.append('step/start', { turn: 1, step: 1 })
  297. session.append('user/message', message, { surfaceOp: 'append' })
  298. startFrames(agent)
  299. emitChunk(agent, { type: 'reasoning-delta', index: 0, text: 'trying recovery' })
  300. session.append('step/end', { turn: 1, step: 1 })
  301. session.append('turn/end', {
  302. turn: 1,
  303. reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } },
  304. })
  305. },
  306. })
  307. expect(await test.run()).toMatchObject({
  308. code: 1,
  309. out: '\n',
  310. err: 'dsh: reasoning:\ntrying recovery\ndsh: SERVER: provider unavailable\n',
  311. })
  312. await test.ctx.fiber.dispose()
  313. })
  314. it('exits 1 when the owned interval contains no turn', async () => {
  315. const test = await bench({ afterPrompt: () => {} })
  316. expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' })
  317. await test.ctx.fiber.dispose()
  318. })
  319. it('fails when an event below the captured Session length cannot be read', async () => {
  320. const test = await bench({
  321. afterPrompt(session, message) {
  322. appendTurn(session, 1, message, 'unreachable', true)
  323. Object.defineProperty(session, 'eventAt', { value: () => undefined })
  324. },
  325. })
  326. expect(await test.run()).toMatchObject({
  327. code: 1,
  328. out: '',
  329. err: 'dsh: headless summary cannot read seq 0 below captured length 7\n',
  330. })
  331. await test.ctx.fiber.dispose()
  332. })
  333. it('reports a direct Agent creation failure', async () => {
  334. const ctx = new Context()
  335. let err = ''
  336. internals.stdout = { write: () => true }
  337. internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
  338. const exited = new Promise<number>((resolve) => {
  339. ctx.provide('appExit', resolve)
  340. })
  341. ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  342. ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
  343. ctx.provide('agents', { create: () => Promise.reject(new Error('factory exploded')) } as never)
  344. apply(ctx, { task: 't' })
  345. expect(await exited).toBe(1)
  346. expect(err).toBe('dsh: factory exploded\n')
  347. await ctx.fiber.dispose()
  348. })
  349. it('stringifies a non-Error Agent creation failure', async () => {
  350. const ctx = new Context()
  351. let err = ''
  352. internals.stdout = { write: () => true }
  353. internals.stderr = { write: (chunk: string) => { err += chunk; return true } }
  354. const exited = new Promise<number>((resolve) => {
  355. ctx.provide('appExit', resolve)
  356. })
  357. ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  358. ctx.provide('sessions', { flush: () => Promise.resolve(true) } as never)
  359. const rejected = {
  360. then(_resolve: (value: never) => void, reject: (reason: unknown) => void): void {
  361. reject('factory exploded')
  362. },
  363. }
  364. ctx.provide('agents', { create: () => rejected } as never)
  365. apply(ctx, { task: 't' })
  366. expect(await exited).toBe(1)
  367. expect(err).toBe('dsh: factory exploded\n')
  368. await ctx.fiber.dispose()
  369. })
  370. it('abandons a run when the tree is disposed during Loader settlement', async () => {
  371. const ctx = new Context()
  372. let exited = false
  373. internals.stdout = { write: () => true }
  374. internals.stderr = { write: () => true }
  375. ctx.provide('appExit', () => { exited = true })
  376. const services = ctx.plugin((child: Context) => {
  377. child.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'p', model: 'm' }) } as never)
  378. child.provide('sessions', {} as never)
  379. child.provide('agents', {} as never)
  380. })
  381. await services
  382. let release: () => void
  383. const settlement = new Promise<void>((resolve) => { release = resolve })
  384. ctx.provide('loader', { await: () => settlement } as never)
  385. apply(ctx, { task: 't' })
  386. await services.dispose()
  387. release!()
  388. await new Promise(resolve => setTimeout(resolve, 10))
  389. expect(exited).toBe(false)
  390. await ctx.fiber.dispose()
  391. })
  392. it('fails loud without the launcher-provided exit request', () => {
  393. const ctx = new Context()
  394. expect(() => { apply(ctx, { task: 't' }) }).toThrow('must provide ctx.appExit')
  395. })
  396. it('validates config: the task is required', () => {
  397. expect(() => new Config({} as never)).toThrow()
  398. expect(new Config({ task: 'x' })).toEqual({ task: 'x' })
  399. })
  400. })