stdio.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853
  1. import { Readable, Writable } 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 UserInteractionService from '@deepseek-ai/dsh-user-interaction'
  9. import { createStdioChat, mountStdio, type Config, type StdioRuntime } from '../src/index.ts'
  10. /**
  11. * Unit tests for the stdio UI plugin. They drive the REAL plugin body
  12. * (`createStdioChat`) with an injected {@link StdioRuntime} so every render,
  13. * input, EOF, and disposal branch runs without touching the real `process`
  14. * streams — the I/O seam is what makes the per-file gate reachable. The
  15. * `agents` service is real (`@deepseek-ai/dsh-agent`); a minimal fake `Agent`
  16. * stands in for the loop, since the loop is the genuinely expensive collaborator
  17. * and we only need its `status` + `send`/`steer` surface here.
  18. */
  19. /** A controllable stdin: a Readable we push lines into and can end on demand. */
  20. function makeInput(): Readable & { feed(line: string): void; finish(): void } {
  21. const stream = new Readable({ read() {} }) as Readable & { feed(line: string): void; finish(): void }
  22. stream.feed = (line: string) => stream.push(`${line}\n`)
  23. stream.finish = () => stream.push(null)
  24. return stream
  25. }
  26. /** A stdout sink that accumulates everything written, for assertions. */
  27. function makeOutput(): { write: (s: string) => boolean; text: () => string } {
  28. let buf = ''
  29. return { write: (s: string) => { buf += s; return true }, text: () => buf }
  30. }
  31. function makeRuntime(over: Partial<StdioRuntime> = {}): {
  32. runtime: StdioRuntime
  33. input: ReturnType<typeof makeInput>
  34. out: ReturnType<typeof makeOutput>
  35. exit: ReturnType<typeof vi.fn>
  36. } {
  37. const input = makeInput()
  38. const out = makeOutput()
  39. const exit = vi.fn()
  40. return { runtime: { input, output: { write: out.write } as never, exit, ...over }, input, out, exit }
  41. }
  42. /** A minimal Agent fake exposing the surface the UI touches. */
  43. function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & {
  44. status: AgentStatus
  45. sent: ContentBlock[][]
  46. steered: ContentBlock[][]
  47. } {
  48. const sent: ContentBlock[][] = []
  49. const steered: ContentBlock[][] = []
  50. return {
  51. id: id as Agent['id'],
  52. status,
  53. sent,
  54. steered,
  55. // A minimal session stub: the UI reads only `session.header.id` (to map the
  56. // session back to its agent id for the turn-boundary label).
  57. session: { header: { id: `${id}-session` } },
  58. send: (content: ContentBlock[]) => void sent.push(content),
  59. steer: (content: ContentBlock[]) => void steered.push(content),
  60. } as never
  61. }
  62. /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
  63. function makeSession(agentId: string): Session {
  64. return { header: { id: `${agentId}-session` } } as Session
  65. }
  66. /** An `assistant/chunk` session event carrying one raw stream chunk. */
  67. function chunkEvent(chunk: StreamChunk): SessionEvent {
  68. return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
  69. }
  70. const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
  71. async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
  72. const ctx = new Context()
  73. await ctx.plugin(AgentRegistry)
  74. await ctx.plugin(UserInteractionService)
  75. const { runtime, input, out, exit } = makeRuntime(runtimeOver)
  76. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  77. createStdioChat(inner, config, runtime)
  78. }, { inject: ['agents', 'userInteraction'] }))
  79. return { ctx, fiber, input, out, exit }
  80. }
  81. /** Drive a fake idle timer past the 200ms flush delay. */
  82. function flushExit(): Promise<void> {
  83. return new Promise(resolve => setTimeout(resolve, 250))
  84. }
  85. describe('mountStdio readiness', () => {
  86. it('leaves stdin untouched until the configured agent is created', async () => {
  87. const ctx = new Context()
  88. await ctx.plugin(AgentRegistry)
  89. await ctx.plugin(UserInteractionService)
  90. const { runtime, out } = makeRuntime()
  91. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  92. mountStdio(inner, CONFIG, runtime)
  93. }, { inject: ['agents', 'userInteraction'] }))
  94. expect(out.text()).toBe('')
  95. ctx.agents.register(makeAgent('other'))
  96. expect(out.text()).toBe('')
  97. ctx.agents.register(makeAgent('main'))
  98. expect(out.text()).toBe('hi there\n> ')
  99. await fiber.dispose()
  100. })
  101. it('opens immediately when the configured agent already exists', async () => {
  102. const ctx = new Context()
  103. await ctx.plugin(AgentRegistry)
  104. await ctx.plugin(UserInteractionService)
  105. ctx.agents.register(makeAgent('main'))
  106. const { runtime, out } = makeRuntime()
  107. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  108. mountStdio(inner, CONFIG, runtime)
  109. }, { inject: ['agents', 'userInteraction'] }))
  110. expect(out.text()).toBe('hi there\n> ')
  111. await fiber.dispose()
  112. })
  113. it('waits for main when no target agent is configured', async () => {
  114. const ctx = new Context()
  115. await ctx.plugin(AgentRegistry)
  116. await ctx.plugin(UserInteractionService)
  117. const { runtime, out } = makeRuntime()
  118. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  119. mountStdio(inner, { welcome: 'ready' }, runtime)
  120. }, { inject: ['agents', 'userInteraction'] }))
  121. ctx.agents.register(makeAgent('other'))
  122. expect(out.text()).toBe('')
  123. ctx.agents.register(makeAgent('main'))
  124. expect(out.text()).toBe('ready\n> ')
  125. await fiber.dispose()
  126. })
  127. })
  128. describe('createStdioChat rendering', () => {
  129. it('writes the welcome banner and prompt on start', async () => {
  130. const { out } = await setup()
  131. expect(out.text()).toBe('hi there\n> ')
  132. })
  133. it('falls back to default welcome/agent when called with empty config', async () => {
  134. // createStdioChat is exported and may be driven directly (bypassing the
  135. // Loader's schemastery validation), so it must default welcome/agent itself.
  136. const { out } = await setup({})
  137. expect(out.text()).toBe('ready.\n> ')
  138. // And it drives the default agent id 'main'.
  139. })
  140. it('detects readline terminal mode from both stream TTY flags', async () => {
  141. for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
  142. const ctx = new Context()
  143. await ctx.plugin(AgentRegistry)
  144. await ctx.plugin(UserInteractionService)
  145. let text = ''
  146. const output = new Writable({
  147. write(chunk, _encoding, callback) {
  148. text += String(chunk)
  149. callback()
  150. },
  151. }) as Writable & { isTTY?: boolean }
  152. const { runtime } = makeRuntime({ output })
  153. ;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
  154. output.isTTY = outputTTY
  155. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  156. createStdioChat(inner, CONFIG, runtime)
  157. }, { inject: ['agents', 'userInteraction'] }))
  158. expect(text).toContain('hi there')
  159. await fiber.dispose()
  160. }
  161. })
  162. it('renders text-delta chunks verbatim', async () => {
  163. const { ctx, out } = await setup()
  164. ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
  165. expect(out.text()).toContain('hello')
  166. })
  167. it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
  168. const { ctx, out } = await setup()
  169. const session = makeSession('main')
  170. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' }))
  171. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' }))
  172. ctx.emit('session/event', session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' }))
  173. expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
  174. })
  175. it('ignores stream-chunk types it does not render', async () => {
  176. const { ctx, out } = await setup()
  177. const before = out.text()
  178. ctx.emit('session/event', makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' }))
  179. expect(out.text()).toBe(before)
  180. })
  181. it('renders turn/start and turn/end markers from the session feed', async () => {
  182. const { ctx, out } = await setup()
  183. const agent = makeAgent('main')
  184. // agent/created populates the session-id → agent-id label map.
  185. ctx.emit('agent/created', agent)
  186. const session = makeSession('main')
  187. ctx.emit('session/event', session, {
  188. type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
  189. } as SessionEvent)
  190. expect(out.text()).toContain('[main turn 3] ')
  191. ctx.emit('session/event', session, {
  192. type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } },
  193. } as SessionEvent)
  194. expect(out.text()).toContain('\n> ')
  195. })
  196. it('falls back to the session id as the label when no agent is mapped', async () => {
  197. const { ctx, out } = await setup()
  198. // No agent/created emitted, so the label map is empty — the header id shows.
  199. ctx.emit('session/event', makeSession('orphan'), {
  200. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  201. } as SessionEvent)
  202. expect(out.text()).toContain('[orphan-session turn 1] ')
  203. })
  204. it('seeds labels for agents already registered before the UI installs', async () => {
  205. // The pre-created `main` agent (and any agent surviving an HMR reload of just this fiber)
  206. // fired its `agent/created` before the UI's listener existed, so the live listener alone
  207. // would miss it. Seeding from `ctx.agents.list()` preserves the `[main turn N]` label instead
  208. // of falling back to the raw session id.
  209. const ctx = new Context()
  210. await ctx.plugin(AgentRegistry)
  211. await ctx.plugin(UserInteractionService)
  212. const agent = makeAgent('main')
  213. ctx.agents.register(agent) // registered BEFORE the UI plugin below
  214. const { runtime, out } = makeRuntime()
  215. await ctx.plugin(Object.assign((inner: Context) => {
  216. createStdioChat(inner, CONFIG, runtime)
  217. }, { inject: ['agents', 'userInteraction'] }))
  218. ctx.emit('session/event', makeSession('main'), {
  219. type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
  220. } as SessionEvent)
  221. expect(out.text()).toContain('[main turn 5] ')
  222. })
  223. it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
  224. const { ctx, out } = await setup()
  225. const session = makeSession('main')
  226. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' }))
  227. ctx.emit('session/event', session, {
  228. type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
  229. } as SessionEvent)
  230. expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
  231. })
  232. it('drops the label mapping on agent/disposed', async () => {
  233. const { ctx, out } = await setup()
  234. const agent = makeAgent('main')
  235. ctx.emit('agent/created', agent)
  236. ctx.emit('agent/disposed', agent)
  237. // After disposal the map no longer resolves the agent id — fall back to the
  238. // session header id.
  239. ctx.emit('session/event', makeSession('main'), {
  240. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  241. } as SessionEvent)
  242. expect(out.text()).toContain('[main-session turn 1] ')
  243. })
  244. it('renders tool/call and tool/result session events', async () => {
  245. const { ctx, out } = await setup()
  246. const session = {} as Session
  247. const callEvent = {
  248. type: 'tool/call', seq: 1, time: 0,
  249. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
  250. } as SessionEvent
  251. ctx.emit('session/event', session, callEvent)
  252. expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
  253. const resultEvent = {
  254. type: 'tool/result', seq: 2, time: 0,
  255. data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
  256. } as SessionEvent
  257. ctx.emit('session/event', session, resultEvent)
  258. expect(out.text()).toContain('[tool result] file.txt')
  259. })
  260. it('renders a todo/write session event as a glyphed checklist', async () => {
  261. const { ctx, out } = await setup()
  262. const session = {} as Session
  263. ctx.emit('session/event', session, {
  264. type: 'todo/write', seq: 1, time: 0,
  265. data: { todos: [
  266. { content: 'read the code', status: 'completed' },
  267. { content: 'write the fix', status: 'in_progress' },
  268. { content: 'run the tests', status: 'pending' },
  269. ] },
  270. } as SessionEvent)
  271. const text = out.text()
  272. expect(text).toContain('[todos]')
  273. expect(text).toContain('[x] read the code')
  274. expect(text).toContain('[~] write the fix')
  275. expect(text).toContain('[ ] run the tests')
  276. })
  277. it('resets dim styling when a todo/write interrupts reasoning', async () => {
  278. const { ctx, out } = await setup()
  279. ctx.emit('session/event', {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  280. ctx.emit('session/event', {} as Session, {
  281. type: 'todo/write', seq: 1, time: 0,
  282. data: { todos: [{ content: 'a task', status: 'pending' }] },
  283. } as SessionEvent)
  284. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  285. })
  286. it('resets dim styling when a tool/call interrupts reasoning', async () => {
  287. const { ctx, out } = await setup()
  288. const session = {} as Session
  289. ctx.emit('session/event', session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  290. ctx.emit('session/event', session, {
  291. type: 'tool/call', seq: 1, time: 0,
  292. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
  293. } as SessionEvent)
  294. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  295. })
  296. it('ignores session events it does not render', async () => {
  297. const { ctx, out } = await setup()
  298. const before = out.text()
  299. ctx.emit('session/event', {} as Session, {
  300. type: 'user/message', seq: 1, time: 0,
  301. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  302. } as SessionEvent)
  303. expect(out.text()).toBe(before)
  304. })
  305. })
  306. describe('createStdioChat input', () => {
  307. it('answers a pending user question instead of sending the line to the agent', async () => {
  308. const { ctx, input, out } = await setup()
  309. const agent = makeAgent('main', 'idle')
  310. ctx.agents.register(agent)
  311. const answer = ctx.userInteraction.ask({
  312. questions: [{
  313. id: 'confirm',
  314. header: 'Confirm',
  315. question: 'Proceed with the edit?',
  316. options: [{ label: 'Yes', description: 'Apply the edit now.' }],
  317. }],
  318. })
  319. await new Promise(r => setImmediate(r))
  320. input.feed('Use a smaller change')
  321. await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
  322. expect(agent.sent).toEqual([])
  323. expect(out.text()).toContain('[Confirm] Proceed with the edit?')
  324. expect(out.text()).toContain('1. Yes')
  325. expect(out.text()).toContain('Apply the edit now.')
  326. })
  327. it('answers a pending user question by numeric option selection', async () => {
  328. const { ctx, input } = await setup()
  329. const answer = ctx.userInteraction.ask({
  330. questions: [{
  331. id: 'mode',
  332. question: 'Which mode?',
  333. options: [
  334. { label: 'Safe' },
  335. { label: 'Fast' },
  336. ],
  337. }],
  338. })
  339. await new Promise(r => setImmediate(r))
  340. input.feed('2')
  341. await expect(answer).resolves.toEqual({
  342. answers: [{ id: 'mode', selected: ['Fast'] }],
  343. })
  344. })
  345. it('renders options in input order and selects by displayed number', async () => {
  346. const { ctx, input, out } = await setup()
  347. const answer = ctx.userInteraction.ask({
  348. questions: [{
  349. id: 'topic',
  350. question: 'Which topic?',
  351. options: [
  352. { label: 'Hobbies' },
  353. { label: 'Work', description: 'Questions about current projects.' },
  354. { label: 'Casual', description: 'Easy conversation.' },
  355. ],
  356. }],
  357. })
  358. await new Promise(r => setImmediate(r))
  359. expect(out.text()).toContain([
  360. 'Which topic?',
  361. ' 1. Hobbies',
  362. ' 2. Work',
  363. ' Questions about current projects.',
  364. ' 3. Casual',
  365. ' Easy conversation.',
  366. ].join('\n'))
  367. input.feed('3')
  368. await expect(answer).resolves.toEqual({
  369. answers: [{ id: 'topic', selected: ['Casual'] }],
  370. })
  371. })
  372. it('answers a multi-select question with multiple numeric selections', async () => {
  373. const { ctx, input } = await setup()
  374. const answer = ctx.userInteraction.ask({
  375. questions: [{
  376. id: 'targets',
  377. question: 'What should I update?',
  378. options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
  379. multiSelect: true,
  380. }],
  381. })
  382. await new Promise(r => setImmediate(r))
  383. input.feed('1 1, 3')
  384. await expect(answer).resolves.toEqual({
  385. answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
  386. })
  387. })
  388. it('accepts non-numeric multi-select input as a custom answer', async () => {
  389. const { ctx, input } = await setup()
  390. const answer = ctx.userInteraction.ask({
  391. questions: [{
  392. id: 'targets',
  393. question: 'What should I update?',
  394. options: [{ label: 'Tests' }, { label: 'Docs' }],
  395. multiSelect: true,
  396. }],
  397. })
  398. await new Promise(r => setImmediate(r))
  399. input.feed('the release notes')
  400. await expect(answer).resolves.toEqual({
  401. answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
  402. })
  403. })
  404. it('asks every question in a batch and returns answers by id', async () => {
  405. const { ctx, input, out } = await setup()
  406. const answer = ctx.userInteraction.ask({
  407. questions: [
  408. { id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
  409. { id: 'note', question: 'Any note?' },
  410. ],
  411. })
  412. await new Promise(r => setImmediate(r))
  413. input.feed('2')
  414. await new Promise(r => setImmediate(r))
  415. expect(out.text()).toContain('\nAny note?\n')
  416. input.feed('ship today')
  417. await expect(answer).resolves.toEqual({
  418. answers: [
  419. { id: 'language', selected: ['TypeScript'] },
  420. { id: 'note', selected: [], custom: 'ship today' },
  421. ],
  422. })
  423. })
  424. it('re-prompts when option input is invalid', async () => {
  425. const { ctx, input, out } = await setup()
  426. const answer = ctx.userInteraction.ask({
  427. questions: [{
  428. id: 'mode',
  429. question: 'Which mode?',
  430. options: [{ label: 'Safe' }],
  431. multiSelect: true,
  432. }],
  433. })
  434. await new Promise(r => setImmediate(r))
  435. input.feed('2')
  436. await new Promise(r => setImmediate(r))
  437. expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
  438. input.feed('1')
  439. await expect(answer).resolves.toEqual({
  440. answers: [{ id: 'mode', selected: ['Safe'] }],
  441. })
  442. })
  443. it('re-prompts when single-select option input is out of range', async () => {
  444. const { ctx, input, out } = await setup()
  445. const answer = ctx.userInteraction.ask({
  446. questions: [{
  447. id: 'mode',
  448. question: 'Which mode?',
  449. options: [{ label: 'Safe' }],
  450. }],
  451. })
  452. await new Promise(r => setImmediate(r))
  453. input.feed('2')
  454. await new Promise(r => setImmediate(r))
  455. expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
  456. input.feed('1')
  457. await expect(answer).resolves.toEqual({
  458. answers: [{ id: 'mode', selected: ['Safe'] }],
  459. })
  460. })
  461. it('re-prompts when multi-select input contains no option numbers', async () => {
  462. const { ctx, input, out } = await setup()
  463. const answer = ctx.userInteraction.ask({
  464. questions: [{
  465. id: 'mode',
  466. question: 'Which mode?',
  467. options: [{ label: 'Safe' }],
  468. multiSelect: true,
  469. }],
  470. })
  471. await new Promise(r => setImmediate(r))
  472. input.feed(',')
  473. await new Promise(r => setImmediate(r))
  474. expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
  475. input.feed('1')
  476. await expect(answer).resolves.toEqual({
  477. answers: [{ id: 'mode', selected: ['Safe'] }],
  478. })
  479. })
  480. it('re-prompts when an option question receives an empty answer', async () => {
  481. const { ctx, input, out } = await setup()
  482. const answer = ctx.userInteraction.ask({
  483. questions: [{
  484. id: 'mode',
  485. question: 'Which mode?',
  486. options: [{ label: 'Safe' }],
  487. }],
  488. })
  489. await new Promise(r => setImmediate(r))
  490. input.feed('')
  491. await new Promise(r => setImmediate(r))
  492. expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
  493. input.feed('1')
  494. await expect(answer).resolves.toEqual({
  495. answers: [{ id: 'mode', selected: ['Safe'] }],
  496. })
  497. })
  498. it('re-prompts when a question receives an empty answer', async () => {
  499. const { ctx, input, out } = await setup()
  500. const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
  501. await new Promise(r => setImmediate(r))
  502. input.feed('')
  503. await new Promise(r => setImmediate(r))
  504. expect(out.text()).toContain('Please enter an answer.')
  505. input.feed('Use defaults')
  506. await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
  507. })
  508. it('rejects an active question when its signal aborts', async () => {
  509. const { ctx } = await setup()
  510. const controller = new AbortController()
  511. const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
  512. const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  513. await new Promise(r => setImmediate(r))
  514. controller.abort()
  515. await rejected
  516. })
  517. it('continues to the next queued question when the active question aborts', async () => {
  518. const { ctx, input, out } = await setup()
  519. const controller = new AbortController()
  520. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
  521. const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  522. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
  523. await new Promise(r => setImmediate(r))
  524. controller.abort()
  525. await firstRejected
  526. await new Promise(r => setImmediate(r))
  527. expect(out.text()).toContain('\nSecond?\n')
  528. input.feed('second answer')
  529. await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
  530. })
  531. it('skips a queued question whose signal aborted before it became active', async () => {
  532. const { ctx, input, out } = await setup()
  533. const controller = new AbortController()
  534. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
  535. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
  536. await new Promise(r => setImmediate(r))
  537. controller.abort()
  538. await expect(Promise.race([
  539. second.then(
  540. () => 'resolved',
  541. (error: unknown) => (error as { code?: string }).code,
  542. ),
  543. new Promise<string>((resolve) => { setImmediate(() => { resolve('pending') }) }),
  544. ])).resolves.toBe('ASK_ABORTED')
  545. expect(out.text()).not.toContain('\nSecond?\n')
  546. input.feed('first answer')
  547. await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
  548. })
  549. it('removes an aborted queued question without promoting later queued work early', async () => {
  550. const { ctx, input, out } = await setup()
  551. const controller = new AbortController()
  552. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
  553. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
  554. const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] })
  555. await new Promise(r => setImmediate(r))
  556. controller.abort()
  557. await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  558. expect(out.text()).toContain('\nFirst?\n')
  559. expect(out.text()).not.toContain('\nSecond?\n')
  560. expect(out.text()).not.toContain('\nThird?\n')
  561. input.feed('first answer')
  562. await new Promise(r => setImmediate(r))
  563. expect(out.text()).toContain('\nThird?\n')
  564. input.feed('third answer')
  565. await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
  566. await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] })
  567. })
  568. it('rejects active and queued questions when the UI is disposed', async () => {
  569. const { ctx, fiber } = await setup()
  570. const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
  571. const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
  572. const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  573. const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  574. await new Promise(r => setImmediate(r))
  575. await fiber.dispose()
  576. await activeRejected
  577. await queuedRejected
  578. })
  579. it('rejects active and queued questions when stdin closes before the user answers', async () => {
  580. const { ctx, input, exit } = await setup()
  581. const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
  582. const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
  583. const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  584. const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  585. await new Promise(r => setImmediate(r))
  586. input.finish()
  587. await new Promise(r => setImmediate(r))
  588. await activeRejected
  589. await queuedRejected
  590. expect(exit).not.toHaveBeenCalled()
  591. })
  592. it('rejects new questions immediately after stdin has closed', async () => {
  593. const { ctx, input, out } = await setup()
  594. input.finish()
  595. await new Promise(r => setImmediate(r))
  596. const before = out.text()
  597. const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
  598. await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  599. expect(out.text()).toBe(before)
  600. })
  601. it('sends a typed line to an idle agent', async () => {
  602. const { ctx, input } = await setup()
  603. const agent = makeAgent('main', 'idle')
  604. ctx.agents.register(agent)
  605. input.feed('do a thing')
  606. await new Promise(r => setImmediate(r))
  607. expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
  608. expect(agent.steered).toEqual([])
  609. })
  610. it('steers a typed line into a running agent', async () => {
  611. const { ctx, input } = await setup()
  612. const agent = makeAgent('main', 'running')
  613. ctx.agents.register(agent)
  614. input.feed('steer me')
  615. await new Promise(r => setImmediate(r))
  616. expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
  617. expect(agent.sent).toEqual([])
  618. })
  619. it('ignores blank lines', async () => {
  620. const { ctx, input } = await setup()
  621. const agent = makeAgent('main')
  622. ctx.agents.register(agent)
  623. input.feed(' ')
  624. await new Promise(r => setImmediate(r))
  625. expect(agent.sent).toEqual([])
  626. })
  627. it('logs and drops a line when the target agent is not running', async () => {
  628. const { ctx, input } = await setup()
  629. const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  630. input.feed('nobody home')
  631. await new Promise(r => setImmediate(r))
  632. expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main')
  633. })
  634. it('drives the agent named in config, not a hardcoded id', async () => {
  635. const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' })
  636. const agent = makeAgent('worker')
  637. ctx.agents.register(agent)
  638. input.feed('hi')
  639. await new Promise(r => setImmediate(r))
  640. expect(agent.sent).toHaveLength(1)
  641. })
  642. })
  643. describe('createStdioChat EOF exit', () => {
  644. it('exits immediately on EOF when no work was submitted', async () => {
  645. const { input, exit } = await setup()
  646. input.finish()
  647. await flushExit()
  648. expect(exit).toHaveBeenCalledWith(0)
  649. })
  650. it('waits for the agent to settle idle after running before exiting', async () => {
  651. const { ctx, input, exit } = await setup()
  652. const agent = makeAgent('main', 'idle')
  653. ctx.agents.register(agent)
  654. input.feed('work')
  655. await new Promise(r => setImmediate(r))
  656. input.finish()
  657. await new Promise(r => setImmediate(r))
  658. // Work submitted but no 'running' observed yet — must NOT exit.
  659. expect(exit).not.toHaveBeenCalled()
  660. // The turn starts, then settles.
  661. ctx.emit('agent/status', agent, 'running')
  662. ;(agent as { status: AgentStatus }).status = 'idle'
  663. ctx.emit('agent/status', agent, 'idle')
  664. await flushExit()
  665. expect(exit).toHaveBeenCalledWith(0)
  666. })
  667. it('schedules the exit only once when idle fires repeatedly', async () => {
  668. const { ctx, input, exit } = await setup()
  669. const agent = makeAgent('main', 'running')
  670. ctx.agents.register(agent)
  671. input.feed('work')
  672. await new Promise(r => setImmediate(r))
  673. ctx.emit('agent/status', agent, 'running') // sawRunning = true
  674. input.finish()
  675. await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
  676. ;(agent as { status: AgentStatus }).status = 'idle'
  677. // Two idle signals while stdin is already closed: the first arms the timer,
  678. // the second must hit the already-scheduled guard, not arm a second.
  679. ctx.emit('agent/status', agent, 'idle')
  680. ctx.emit('agent/status', agent, 'idle')
  681. await flushExit()
  682. expect(exit).toHaveBeenCalledTimes(1)
  683. })
  684. it('does not exit on an idle transition for a different agent', async () => {
  685. const { ctx, input, exit } = await setup()
  686. const agent = makeAgent('main', 'idle')
  687. ctx.agents.register(agent)
  688. input.feed('work')
  689. await new Promise(r => setImmediate(r))
  690. input.finish()
  691. const other = makeAgent('other')
  692. ctx.emit('agent/status', other, 'running')
  693. ctx.emit('agent/status', other, 'idle')
  694. await flushExit()
  695. expect(exit).not.toHaveBeenCalled()
  696. })
  697. it('does not exit while a turn is still running at EOF', async () => {
  698. const { ctx, input, exit } = await setup()
  699. const agent = makeAgent('main', 'idle')
  700. ctx.agents.register(agent)
  701. input.feed('work')
  702. await new Promise(r => setImmediate(r))
  703. ctx.emit('agent/status', agent, 'running')
  704. ;(agent as { status: AgentStatus }).status = 'running'
  705. input.finish()
  706. // sawRunning is true, but the agent is still running — the idle gate holds.
  707. ctx.emit('agent/status', agent, 'idle') // a stale/duplicate signal while status stays 'running'
  708. await flushExit()
  709. expect(exit).not.toHaveBeenCalled()
  710. })
  711. })
  712. describe('createStdioChat disposal (HMR safety)', () => {
  713. it('never exits the process when EOF arrives after fiber dispose', async () => {
  714. const { fiber, input, exit } = await setup()
  715. await fiber.dispose()
  716. // A late EOF after disposal (reader.close() also fires 'close') must not exit.
  717. input.finish()
  718. await flushExit()
  719. expect(exit).not.toHaveBeenCalled()
  720. })
  721. it('cancels a scheduled exit if disposed within the flush window', async () => {
  722. const { fiber, input, exit } = await setup()
  723. // EOF with no work submitted schedules the 200ms flush-then-exit timer.
  724. input.finish()
  725. await new Promise(r => setImmediate(r))
  726. expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
  727. // Dispose BEFORE the timer fires: the tracked handle must be cleared.
  728. await fiber.dispose()
  729. await flushExit()
  730. expect(exit).not.toHaveBeenCalled()
  731. })
  732. it('stops handling input after dispose', async () => {
  733. const { ctx, fiber, input } = await setup()
  734. const agent = makeAgent('main')
  735. ctx.agents.register(agent)
  736. await fiber.dispose()
  737. // The readline interface is closed on dispose; a late line reaches no handler.
  738. input.feed('too late')
  739. await new Promise(r => setImmediate(r))
  740. expect(agent.sent).toEqual([])
  741. })
  742. it('removes the agent/status listener on dispose', async () => {
  743. const { ctx, fiber, input, exit } = await setup()
  744. const agent = makeAgent('main', 'idle')
  745. ctx.agents.register(agent)
  746. input.feed('work')
  747. await new Promise(r => setImmediate(r))
  748. await fiber.dispose()
  749. // After dispose, status transitions must neither throw nor schedule an exit
  750. // (the listener and the EOF-exit path are both torn down).
  751. expect(() => {
  752. ctx.emit('agent/status', agent, 'running')
  753. ctx.emit('agent/status', agent, 'idle')
  754. }).not.toThrow()
  755. await flushExit()
  756. expect(exit).not.toHaveBeenCalled()
  757. })
  758. })