ui-stdio.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. import { Readable } from 'node:stream'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from 'cordis'
  4. import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
  5. import AgentRegistry from '@deepseek-ai/dsh-agent'
  6. import type { ContentBlock } from '@deepseek-ai/dsh-llm'
  7. import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
  8. import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
  9. /**
  10. * Unit tests for the stdio UI plugin. They drive the REAL plugin body
  11. * (`createStdioChat`) with an injected {@link StdioRuntime} so every render,
  12. * input, EOF, and disposal branch runs without touching the real `process`
  13. * streams — the I/O seam is what makes the per-file gate reachable. The
  14. * `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent`
  15. * stands in for the loop, since the loop is the genuinely expensive collaborator
  16. * and we only need its `status` + `send`/`steer` surface here.
  17. */
  18. /** A controllable stdin: a Readable we push lines into and can end on demand. */
  19. function makeInput(): Readable & { feed(line: string): void; finish(): void } {
  20. const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void }
  21. stream.feed = (line: string) => stream.push(`${line}\n`)
  22. stream.finish = () => stream.push(null)
  23. return stream
  24. }
  25. /** A stdout sink that accumulates everything written, for assertions. */
  26. function makeOutput(): { write: (s: string) => boolean; text: () => string } {
  27. let buf = ''
  28. return { write: (s: string) => { buf += s; return true }, text: () => buf }
  29. }
  30. function makeRuntime(over: Partial<StdioRuntime> = {}): {
  31. runtime: StdioRuntime
  32. input: ReturnType<typeof makeInput>
  33. out: ReturnType<typeof makeOutput>
  34. exit: ReturnType<typeof vi.fn>
  35. } {
  36. const input = makeInput()
  37. const out = makeOutput()
  38. const exit = vi.fn()
  39. return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit }
  40. }
  41. /** A minimal Agent fake exposing the surface the UI touches. */
  42. function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
  43. status: AgentStatus
  44. sent: ContentBlock[][]
  45. steered: ContentBlock[][]
  46. } {
  47. const sent: ContentBlock[][] = []
  48. const steered: ContentBlock[][] = []
  49. return {
  50. id: id as Agent['id'],
  51. status,
  52. sent,
  53. steered,
  54. send: (content: ContentBlock[]) => void sent.push(content),
  55. steer: (content: ContentBlock[]) => void steered.push(content),
  56. } as never
  57. }
  58. const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
  59. async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
  60. const ctx = new Context()
  61. await ctx.plugin(AgentRegistry)
  62. const { runtime, input, out, exit } = makeRuntime(runtimeOver)
  63. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  64. createStdioChat(inner, config, runtime)
  65. }, { inject: ['agents'] }))
  66. return { ctx, fiber, input, out, exit }
  67. }
  68. /** Drive a fake idle timer past the 200ms flush delay. */
  69. function flushExit(): Promise<void> {
  70. return new Promise(resolve => setTimeout(resolve, 250))
  71. }
  72. describe('createStdioChat rendering', () => {
  73. it('writes the welcome banner and prompt on start', async () => {
  74. const { out } = await setup()
  75. expect(out.text()).toBe('hi there\n> ')
  76. })
  77. it('falls back to default welcome/agent when called with empty config', async () => {
  78. // createStdioChat is exported and may be driven directly (bypassing the
  79. // Loader's schemastery validation), so it must default welcome/agent itself.
  80. const { out } = await setup({})
  81. expect(out.text()).toBe('ready.\n> ')
  82. // And it drives the default agent id 'main'.
  83. })
  84. it('renders text-delta chunks verbatim', async () => {
  85. const { ctx, out } = await setup()
  86. const agent = makeAgent('main')
  87. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'hello' })
  88. expect(out.text()).toContain('hello')
  89. })
  90. it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
  91. const { ctx, out } = await setup()
  92. const agent = makeAgent('main')
  93. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'think' })
  94. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'more' })
  95. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'text-delta', index: 0, text: 'answer' })
  96. expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
  97. })
  98. it('ignores stream-chunk types it does not render', async () => {
  99. const { ctx, out } = await setup()
  100. const before = out.text()
  101. const agent = makeAgent('main')
  102. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'block-start', index: 0, blockType: 'text' })
  103. expect(out.text()).toBe(before)
  104. })
  105. it('renders turn-start and turn-end markers', async () => {
  106. const { ctx, out } = await setup()
  107. const agent = makeAgent('main')
  108. ctx.emit('agent/turn-start', agent, 3)
  109. expect(out.text()).toContain('[main turn 3] ')
  110. ctx.emit('agent/turn-end', agent, 3, { kind: 'completed' })
  111. expect(out.text()).toContain('\n> ')
  112. })
  113. it('resets dim styling at turn-end if a turn ends mid-reasoning', async () => {
  114. const { ctx, out } = await setup()
  115. const agent = makeAgent('main')
  116. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'mid' })
  117. ctx.emit('agent/turn-end', agent, 1, { kind: 'completed' })
  118. expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
  119. })
  120. it('renders tool/call and tool/result session events', async () => {
  121. const { ctx, out } = await setup()
  122. const session = {} as Session
  123. const callEvent = {
  124. type: 'tool/call', seq: 1, time: 0,
  125. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
  126. } as SessionEvent
  127. ctx.emit('session/event', session, callEvent)
  128. expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
  129. const resultEvent = {
  130. type: 'tool/result', seq: 2, time: 0,
  131. data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
  132. } as SessionEvent
  133. ctx.emit('session/event', session, resultEvent)
  134. expect(out.text()).toContain('[tool result] file.txt')
  135. })
  136. it('resets dim styling when a tool/call interrupts reasoning', async () => {
  137. const { ctx, out } = await setup()
  138. const agent = makeAgent('main')
  139. ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'r' })
  140. const session = {} as Session
  141. ctx.emit('session/event', session, {
  142. type: 'tool/call', seq: 1, time: 0,
  143. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
  144. } as SessionEvent)
  145. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  146. })
  147. it('ignores session events it does not render', async () => {
  148. const { ctx, out } = await setup()
  149. const before = out.text()
  150. ctx.emit('session/event', {} as Session, {
  151. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } },
  152. } as SessionEvent)
  153. expect(out.text()).toBe(before)
  154. })
  155. })
  156. describe('createStdioChat input', () => {
  157. it('sends a typed line to an idle agent', async () => {
  158. const { ctx, input } = await setup()
  159. const agent = makeAgent('main', 'idle')
  160. ctx.agents.register(agent)
  161. input.feed('do a thing')
  162. await new Promise(r => setImmediate(r))
  163. expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
  164. expect(agent.steered).toEqual([])
  165. })
  166. it('steers a typed line into a running agent', async () => {
  167. const { ctx, input } = await setup()
  168. const agent = makeAgent('main', 'running')
  169. ctx.agents.register(agent)
  170. input.feed('steer me')
  171. await new Promise(r => setImmediate(r))
  172. expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
  173. expect(agent.sent).toEqual([])
  174. })
  175. it('ignores blank lines', async () => {
  176. const { ctx, input } = await setup()
  177. const agent = makeAgent('main')
  178. ctx.agents.register(agent)
  179. input.feed(' ')
  180. await new Promise(r => setImmediate(r))
  181. expect(agent.sent).toEqual([])
  182. })
  183. it('logs and drops a line when the target agent is not running', async () => {
  184. const { ctx, input } = await setup()
  185. const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  186. input.feed('nobody home')
  187. await new Promise(r => setImmediate(r))
  188. expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
  189. })
  190. it('drives the agent named in config, not a hardcoded id', async () => {
  191. const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
  192. const agent = makeAgent('worker')
  193. ctx.agents.register(agent)
  194. input.feed('hi')
  195. await new Promise(r => setImmediate(r))
  196. expect(agent.sent).toHaveLength(1)
  197. })
  198. })
  199. describe('createStdioChat EOF exit', () => {
  200. it('exits immediately on EOF when no work was submitted', async () => {
  201. const { input, exit } = await setup()
  202. input.finish()
  203. await flushExit()
  204. expect(exit).toHaveBeenCalledWith(0)
  205. })
  206. it('waits for the agent to settle idle after running before exiting', async () => {
  207. const { ctx, input, exit } = await setup()
  208. const agent = makeAgent('main', 'idle')
  209. ctx.agents.register(agent)
  210. input.feed('work')
  211. await new Promise(r => setImmediate(r))
  212. input.finish()
  213. await new Promise(r => setImmediate(r))
  214. // Work submitted but no 'running' observed yet — must NOT exit.
  215. expect(exit).not.toHaveBeenCalled()
  216. // The turn starts, then settles.
  217. ctx.emit('agent/status', agent, 'running')
  218. ;(agent as { status: AgentStatus }).status = 'idle'
  219. ctx.emit('agent/status', agent, 'idle')
  220. await flushExit()
  221. expect(exit).toHaveBeenCalledWith(0)
  222. })
  223. it('schedules the exit only once when idle fires repeatedly', async () => {
  224. const { ctx, input, exit } = await setup()
  225. const agent = makeAgent('main', 'running')
  226. ctx.agents.register(agent)
  227. input.feed('work')
  228. await new Promise(r => setImmediate(r))
  229. ctx.emit('agent/status', agent, 'running') // sawRunning = true
  230. input.finish()
  231. await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
  232. ;(agent as { status: AgentStatus }).status = 'idle'
  233. // Two idle signals while stdin is already closed: the first arms the timer,
  234. // the second must hit the already-scheduled guard, not arm a second.
  235. ctx.emit('agent/status', agent, 'idle')
  236. ctx.emit('agent/status', agent, 'idle')
  237. await flushExit()
  238. expect(exit).toHaveBeenCalledTimes(1)
  239. })
  240. it('does not exit on an idle transition for a different agent', async () => {
  241. const { ctx, input, exit } = await setup()
  242. const agent = makeAgent('main', 'idle')
  243. ctx.agents.register(agent)
  244. input.feed('work')
  245. await new Promise(r => setImmediate(r))
  246. input.finish()
  247. const other = makeAgent('other')
  248. ctx.emit('agent/status', other, 'running')
  249. ctx.emit('agent/status', other, 'idle')
  250. await flushExit()
  251. expect(exit).not.toHaveBeenCalled()
  252. })
  253. it('does not exit while a turn is still running at EOF', async () => {
  254. const { ctx, input, exit } = await setup()
  255. const agent = makeAgent('main', 'idle')
  256. ctx.agents.register(agent)
  257. input.feed('work')
  258. await new Promise(r => setImmediate(r))
  259. ctx.emit('agent/status', agent, 'running')
  260. ;(agent as { status: AgentStatus }).status = 'running'
  261. input.finish()
  262. // sawRunning is true, but the agent is still running — the idle gate holds.
  263. ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running'
  264. await flushExit()
  265. expect(exit).not.toHaveBeenCalled()
  266. })
  267. })
  268. describe('createStdioChat disposal (HMR safety)', () => {
  269. it('never exits the process when EOF arrives after fiber dispose', async () => {
  270. const { fiber, input, exit } = await setup()
  271. await fiber.dispose()
  272. // A late EOF after disposal (reader.close() also fires 'close') must not exit.
  273. input.finish()
  274. await flushExit()
  275. expect(exit).not.toHaveBeenCalled()
  276. })
  277. it('cancels a scheduled exit if disposed within the flush window', async () => {
  278. const { fiber, input, exit } = await setup()
  279. // EOF with no work submitted schedules the 200ms flush-then-exit timer.
  280. input.finish()
  281. await new Promise(r => setImmediate(r))
  282. expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
  283. // Dispose BEFORE the timer fires: the tracked handle must be cleared.
  284. await fiber.dispose()
  285. await flushExit()
  286. expect(exit).not.toHaveBeenCalled()
  287. })
  288. it('stops handling input after dispose', async () => {
  289. const { ctx, fiber, input } = await setup()
  290. const agent = makeAgent('main')
  291. ctx.agents.register(agent)
  292. await fiber.dispose()
  293. // The readline interface is closed on dispose; a late line reaches no handler.
  294. input.feed('too late')
  295. await new Promise(r => setImmediate(r))
  296. expect(agent.sent).toEqual([])
  297. })
  298. it('removes the agent/status listener on dispose', async () => {
  299. const { ctx, fiber, input, exit } = await setup()
  300. const agent = makeAgent('main', 'idle')
  301. ctx.agents.register(agent)
  302. input.feed('work')
  303. await new Promise(r => setImmediate(r))
  304. await fiber.dispose()
  305. // After dispose, status transitions must neither throw nor schedule an exit
  306. // (the listener and the EOF-exit path are both torn down).
  307. expect(() => {
  308. ctx.emit('agent/status', agent, 'running')
  309. ctx.emit('agent/status', agent, 'idle')
  310. }).not.toThrow()
  311. await flushExit()
  312. expect(exit).not.toHaveBeenCalled()
  313. })
  314. })