ui-stdio.spec.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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, StreamChunk } 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. // A minimal session stub: the UI reads only `session.header.id` (to map the
  55. // session back to its agent id for the turn-boundary label).
  56. session: { header: { id: `${id}-session` } },
  57. send: (content: ContentBlock[]) => void sent.push(content),
  58. steer: (content: ContentBlock[]) => void steered.push(content),
  59. } as never
  60. }
  61. /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
  62. function makeSession(agentId: string): Session {
  63. return { header: { id: `${agentId}-session` } } as Session
  64. }
  65. /** An `assistant/chunk` session event carrying one raw stream chunk. */
  66. function chunkEvent(chunk: StreamChunk): SessionEvent {
  67. return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
  68. }
  69. const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
  70. async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
  71. const ctx = new Context()
  72. await ctx.plugin(AgentRegistry)
  73. const { runtime, input, out, exit } = makeRuntime(runtimeOver)
  74. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  75. createStdioChat(inner, config, runtime)
  76. }, { inject: ['agents'] }))
  77. return { ctx, fiber, input, out, exit }
  78. }
  79. /** Drive a fake idle timer past the 200ms flush delay. */
  80. function flushExit(): Promise<void> {
  81. return new Promise(resolve => setTimeout(resolve, 250))
  82. }
  83. describe('createStdioChat rendering', () => {
  84. it('writes the welcome banner and prompt on start', async () => {
  85. const { out } = await setup()
  86. expect(out.text()).toBe('hi there\n> ')
  87. })
  88. it('falls back to default welcome/agent when called with empty config', async () => {
  89. // createStdioChat is exported and may be driven directly (bypassing the
  90. // Loader's schemastery validation), so it must default welcome/agent itself.
  91. const { out } = await setup({})
  92. expect(out.text()).toBe('ready.\n> ')
  93. // And it drives the default agent id 'main'.
  94. })
  95. it('renders text-delta chunks verbatim', async () => {
  96. const { ctx, out } = await setup()
  97. ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
  98. expect(out.text()).toContain('hello')
  99. })
  100. it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
  101. const { ctx, out } = await setup()
  102. const session = makeSession('main')
  103. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' }))
  104. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' }))
  105. ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' }))
  106. expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
  107. })
  108. it('ignores stream-chunk types it does not render', async () => {
  109. const { ctx, out } = await setup()
  110. const before = out.text()
  111. ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' }))
  112. expect(out.text()).toBe(before)
  113. })
  114. it('renders turn/start and turn/end markers from the session feed', async () => {
  115. const { ctx, out } = await setup()
  116. const agent = makeAgent('main')
  117. // agent/created populates the session-id → agent-id label map.
  118. ctx.emit('agent/created', agent)
  119. const session = makeSession('main')
  120. ctx.emit('session/event', session, {
  121. type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
  122. } as SessionEvent)
  123. expect(out.text()).toContain('[main turn 3] ')
  124. ctx.emit('session/event', session, {
  125. type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } },
  126. } as SessionEvent)
  127. expect(out.text()).toContain('\n> ')
  128. })
  129. it('falls back to the session id as the label when no agent is mapped', async () => {
  130. const { ctx, out } = await setup()
  131. // No agent/created emitted, so the label map is empty — the header id shows.
  132. ctx.emit('session/event', makeSession('orphan'), {
  133. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  134. } as SessionEvent)
  135. expect(out.text()).toContain('[orphan-session turn 1] ')
  136. })
  137. it('seeds labels for agents already registered before the UI installs', async () => {
  138. // The pre-created `main` agent (and any agent surviving an HMR reload of just
  139. // this fiber) fired its `agent/created` before the UI's listener existed, so
  140. // the live listener alone would miss it. Seeding from `ctx.agents.list()` at
  141. // install time is what keeps its turn header showing `[main turn N]` instead
  142. // of the raw session id.
  143. const ctx = new Context()
  144. await ctx.plugin(AgentRegistry)
  145. const agent = makeAgent('main')
  146. ctx.agents.register(agent) // registered BEFORE the UI plugin below
  147. const { runtime, out } = makeRuntime()
  148. await ctx.plugin(Object.assign((inner: Context) => {
  149. createStdioChat(inner, CONFIG, runtime)
  150. }, { inject: ['agents'] }))
  151. ctx.emit('session/event', makeSession('main'), {
  152. type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
  153. } as SessionEvent)
  154. expect(out.text()).toContain('[main turn 5] ')
  155. })
  156. it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
  157. const { ctx, out } = await setup()
  158. const session = makeSession('main')
  159. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' }))
  160. ctx.emit('session/event', session, {
  161. type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
  162. } as SessionEvent)
  163. expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
  164. })
  165. it('drops the label mapping on agent/disposed', async () => {
  166. const { ctx, out } = await setup()
  167. const agent = makeAgent('main')
  168. ctx.emit('agent/created', agent)
  169. ctx.emit('agent/disposed', agent)
  170. // After disposal the map no longer resolves the agent id — fall back to the
  171. // session header id.
  172. ctx.emit('session/event', makeSession('main'), {
  173. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  174. } as SessionEvent)
  175. expect(out.text()).toContain('[main-session turn 1] ')
  176. })
  177. it('renders tool/call and tool/result session events', async () => {
  178. const { ctx, out } = await setup()
  179. const session = {} as Session
  180. const callEvent = {
  181. type: 'tool/call', seq: 1, time: 0,
  182. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
  183. } as SessionEvent
  184. ctx.emit('session/event', session, callEvent)
  185. expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
  186. const resultEvent = {
  187. type: 'tool/result', seq: 2, time: 0,
  188. data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
  189. } as SessionEvent
  190. ctx.emit('session/event', session, resultEvent)
  191. expect(out.text()).toContain('[tool result] file.txt')
  192. })
  193. it('renders a todo/write session event as a glyphed checklist', async () => {
  194. const { ctx, out } = await setup()
  195. const session = {} as Session
  196. ctx.emit('session/event', session, {
  197. type: 'todo/write', seq: 1, time: 0,
  198. data: { todos: [
  199. { content: 'read the code', status: 'completed' },
  200. { content: 'write the fix', status: 'in_progress' },
  201. { content: 'run the tests', status: 'pending' },
  202. ] },
  203. } as SessionEvent)
  204. const text = out.text()
  205. expect(text).toContain('[todos]')
  206. expect(text).toContain('[x] read the code')
  207. expect(text).toContain('[~] write the fix')
  208. expect(text).toContain('[ ] run the tests')
  209. })
  210. it('resets dim styling when a todo/write interrupts reasoning', async () => {
  211. const { ctx, out } = await setup()
  212. ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  213. ctx.emit('session/event', {} as Session, {
  214. type: 'todo/write', seq: 1, time: 0,
  215. data: { todos: [{ content: 'a task', status: 'pending' }] },
  216. } as SessionEvent)
  217. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  218. })
  219. it('resets dim styling when a tool/call interrupts reasoning', async () => {
  220. const { ctx, out } = await setup()
  221. const session = {} as Session
  222. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  223. ctx.emit('session/event', session, {
  224. type: 'tool/call', seq: 1, time: 0,
  225. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
  226. } as SessionEvent)
  227. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  228. })
  229. it('ignores session events it does not render', async () => {
  230. const { ctx, out } = await setup()
  231. const before = out.text()
  232. ctx.emit('session/event', {} as Session, {
  233. type: 'user/message', seq: 1, time: 0,
  234. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  235. } as SessionEvent)
  236. expect(out.text()).toBe(before)
  237. })
  238. })
  239. describe('createStdioChat input', () => {
  240. it('sends a typed line to an idle agent', async () => {
  241. const { ctx, input } = await setup()
  242. const agent = makeAgent('main', 'idle')
  243. ctx.agents.register(agent)
  244. input.feed('do a thing')
  245. await new Promise(r => setImmediate(r))
  246. expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
  247. expect(agent.steered).toEqual([])
  248. })
  249. it('steers a typed line into a running agent', async () => {
  250. const { ctx, input } = await setup()
  251. const agent = makeAgent('main', 'running')
  252. ctx.agents.register(agent)
  253. input.feed('steer me')
  254. await new Promise(r => setImmediate(r))
  255. expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
  256. expect(agent.sent).toEqual([])
  257. })
  258. it('ignores blank lines', async () => {
  259. const { ctx, input } = await setup()
  260. const agent = makeAgent('main')
  261. ctx.agents.register(agent)
  262. input.feed(' ')
  263. await new Promise(r => setImmediate(r))
  264. expect(agent.sent).toEqual([])
  265. })
  266. it('logs and drops a line when the target agent is not running', async () => {
  267. const { ctx, input } = await setup()
  268. const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  269. input.feed('nobody home')
  270. await new Promise(r => setImmediate(r))
  271. expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
  272. })
  273. it('drives the agent named in config, not a hardcoded id', async () => {
  274. const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
  275. const agent = makeAgent('worker')
  276. ctx.agents.register(agent)
  277. input.feed('hi')
  278. await new Promise(r => setImmediate(r))
  279. expect(agent.sent).toHaveLength(1)
  280. })
  281. })
  282. describe('createStdioChat EOF exit', () => {
  283. it('exits immediately on EOF when no work was submitted', async () => {
  284. const { input, exit } = await setup()
  285. input.finish()
  286. await flushExit()
  287. expect(exit).toHaveBeenCalledWith(0)
  288. })
  289. it('waits for the agent to settle idle after running before exiting', async () => {
  290. const { ctx, input, exit } = await setup()
  291. const agent = makeAgent('main', 'idle')
  292. ctx.agents.register(agent)
  293. input.feed('work')
  294. await new Promise(r => setImmediate(r))
  295. input.finish()
  296. await new Promise(r => setImmediate(r))
  297. // Work submitted but no 'running' observed yet — must NOT exit.
  298. expect(exit).not.toHaveBeenCalled()
  299. // The turn starts, then settles.
  300. ctx.emit('agent/status', agent, 'running')
  301. ;(agent as { status: AgentStatus }).status = 'idle'
  302. ctx.emit('agent/status', agent, 'idle')
  303. await flushExit()
  304. expect(exit).toHaveBeenCalledWith(0)
  305. })
  306. it('schedules the exit only once when idle fires repeatedly', async () => {
  307. const { ctx, input, exit } = await setup()
  308. const agent = makeAgent('main', 'running')
  309. ctx.agents.register(agent)
  310. input.feed('work')
  311. await new Promise(r => setImmediate(r))
  312. ctx.emit('agent/status', agent, 'running') // sawRunning = true
  313. input.finish()
  314. await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
  315. ;(agent as { status: AgentStatus }).status = 'idle'
  316. // Two idle signals while stdin is already closed: the first arms the timer,
  317. // the second must hit the already-scheduled guard, not arm a second.
  318. ctx.emit('agent/status', agent, 'idle')
  319. ctx.emit('agent/status', agent, 'idle')
  320. await flushExit()
  321. expect(exit).toHaveBeenCalledTimes(1)
  322. })
  323. it('does not exit on an idle transition for a different agent', async () => {
  324. const { ctx, input, exit } = await setup()
  325. const agent = makeAgent('main', 'idle')
  326. ctx.agents.register(agent)
  327. input.feed('work')
  328. await new Promise(r => setImmediate(r))
  329. input.finish()
  330. const other = makeAgent('other')
  331. ctx.emit('agent/status', other, 'running')
  332. ctx.emit('agent/status', other, 'idle')
  333. await flushExit()
  334. expect(exit).not.toHaveBeenCalled()
  335. })
  336. it('does not exit while a turn is still running at EOF', async () => {
  337. const { ctx, input, exit } = await setup()
  338. const agent = makeAgent('main', 'idle')
  339. ctx.agents.register(agent)
  340. input.feed('work')
  341. await new Promise(r => setImmediate(r))
  342. ctx.emit('agent/status', agent, 'running')
  343. ;(agent as { status: AgentStatus }).status = 'running'
  344. input.finish()
  345. // sawRunning is true, but the agent is still running — the idle gate holds.
  346. ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running'
  347. await flushExit()
  348. expect(exit).not.toHaveBeenCalled()
  349. })
  350. })
  351. describe('createStdioChat disposal (HMR safety)', () => {
  352. it('never exits the process when EOF arrives after fiber dispose', async () => {
  353. const { fiber, input, exit } = await setup()
  354. await fiber.dispose()
  355. // A late EOF after disposal (reader.close() also fires 'close') must not exit.
  356. input.finish()
  357. await flushExit()
  358. expect(exit).not.toHaveBeenCalled()
  359. })
  360. it('cancels a scheduled exit if disposed within the flush window', async () => {
  361. const { fiber, input, exit } = await setup()
  362. // EOF with no work submitted schedules the 200ms flush-then-exit timer.
  363. input.finish()
  364. await new Promise(r => setImmediate(r))
  365. expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
  366. // Dispose BEFORE the timer fires: the tracked handle must be cleared.
  367. await fiber.dispose()
  368. await flushExit()
  369. expect(exit).not.toHaveBeenCalled()
  370. })
  371. it('stops handling input after dispose', async () => {
  372. const { ctx, fiber, input } = await setup()
  373. const agent = makeAgent('main')
  374. ctx.agents.register(agent)
  375. await fiber.dispose()
  376. // The readline interface is closed on dispose; a late line reaches no handler.
  377. input.feed('too late')
  378. await new Promise(r => setImmediate(r))
  379. expect(agent.sent).toEqual([])
  380. })
  381. it('removes the agent/status listener on dispose', async () => {
  382. const { ctx, fiber, input, exit } = await setup()
  383. const agent = makeAgent('main', 'idle')
  384. ctx.agents.register(agent)
  385. input.feed('work')
  386. await new Promise(r => setImmediate(r))
  387. await fiber.dispose()
  388. // After dispose, status transitions must neither throw nor schedule an exit
  389. // (the listener and the EOF-exit path are both torn down).
  390. expect(() => {
  391. ctx.emit('agent/status', agent, 'running')
  392. ctx.emit('agent/status', agent, 'idle')
  393. }).not.toThrow()
  394. await flushExit()
  395. expect(exit).not.toHaveBeenCalled()
  396. })
  397. })