stdio.spec.ts 42 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  1. import { Readable, Writable } from 'node:stream'
  2. import { describe, expect, it, vi } from 'vitest'
  3. import { Context } from 'cordis'
  4. import AgentRegistry, { agentEvents, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent'
  5. import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
  6. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  7. import { SessionId, type Session, type 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 with the agent's shared durable identity.
  56. session: { id, header: { id } },
  57. send: (content: ContentBlock[]) => void sent.push(content),
  58. steer: (content: ContentBlock[]) => void steered.push(content),
  59. } as never
  60. }
  61. /** Register a fake configured agent and cross the supported startup-work boundary. */
  62. function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void {
  63. const dispose = ctx.agents.register(agent)
  64. agentEvents(ctx, agent).emit('agent/session-start', source)
  65. return dispose
  66. }
  67. function emitAgentSessionStart(ctx: Context, agent: Agent, source: 'startup' | 'resume'): void {
  68. agentEvents(ctx, agent).emit('agent/session-start', source)
  69. }
  70. function emitAgentStatus(ctx: Context, agent: Agent, status: AgentStatus): void {
  71. agentEvents(ctx, agent).emit('agent/status', status)
  72. }
  73. function emitAgentDisposed(ctx: Context, agent: Agent): void {
  74. agentEvents(ctx, agent).emit('agent/disposed')
  75. }
  76. function emitSessionEvent(ctx: Context, session: Session, event: SessionEvent): void {
  77. ctx.emit(scopeTarget(session, undefined), 'session/event', session, event)
  78. }
  79. /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */
  80. function makeSession(id: string): Session {
  81. return { id, header: { id } } as Session
  82. }
  83. /** An `assistant/chunk` session event carrying one raw stream chunk. */
  84. function chunkEvent(chunk: StreamChunk): SessionEvent {
  85. return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } }
  86. }
  87. const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' }
  88. function unrenderableFailure(): unknown {
  89. return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } }
  90. }
  91. async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
  92. const ctx = new Context()
  93. await ctx.plugin(AgentRegistry)
  94. await ctx.plugin(UserInteractionService)
  95. const { runtime, input, out, exit } = makeRuntime(runtimeOver)
  96. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  97. createStdioChat(inner, config, runtime)
  98. }, { inject: ['agents', 'userInteraction'] }))
  99. return { ctx, fiber, input, out, exit }
  100. }
  101. /** Drive a fake idle timer past the 200ms flush delay. */
  102. function flushExit(): Promise<void> {
  103. return new Promise(resolve => setTimeout(resolve, 250))
  104. }
  105. describe('mountStdio readiness', () => {
  106. it('opens before the configured agent is created so startup input can queue', async () => {
  107. const ctx = new Context()
  108. await ctx.plugin(AgentRegistry)
  109. await ctx.plugin(UserInteractionService)
  110. const { runtime, out } = makeRuntime()
  111. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  112. mountStdio(inner, CONFIG, runtime)
  113. }, { inject: ['agents', 'userInteraction'] }))
  114. expect(out.text()).toBe('hi there\n> ')
  115. ctx.agents.register(makeAgent('other'))
  116. expect(out.text()).toBe('hi there\n> ')
  117. ctx.agents.register(makeAgent('main'))
  118. expect(out.text()).toBe('hi there\n> ')
  119. await fiber.dispose()
  120. })
  121. it('opens immediately when the configured agent already exists', async () => {
  122. const ctx = new Context()
  123. await ctx.plugin(AgentRegistry)
  124. await ctx.plugin(UserInteractionService)
  125. ctx.agents.register(makeAgent('main'))
  126. const { runtime, out } = makeRuntime()
  127. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  128. mountStdio(inner, CONFIG, runtime)
  129. }, { inject: ['agents', 'userInteraction'] }))
  130. expect(out.text()).toBe('hi there\n> ')
  131. await fiber.dispose()
  132. })
  133. it('opens for the default main identity when no target is configured', async () => {
  134. const ctx = new Context()
  135. await ctx.plugin(AgentRegistry)
  136. await ctx.plugin(UserInteractionService)
  137. const { runtime, out } = makeRuntime()
  138. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  139. mountStdio(inner, { welcome: 'ready' }, runtime)
  140. }, { inject: ['agents', 'userInteraction'] }))
  141. expect(out.text()).toBe('ready\n> ')
  142. ctx.agents.register(makeAgent('other'))
  143. expect(out.text()).toBe('ready\n> ')
  144. ctx.agents.register(makeAgent('main'))
  145. expect(out.text()).toBe('ready\n> ')
  146. await fiber.dispose()
  147. })
  148. })
  149. describe('createStdioChat rendering', () => {
  150. it('writes the welcome banner and prompt on start', async () => {
  151. const { out } = await setup()
  152. expect(out.text()).toBe('hi there\n> ')
  153. })
  154. it('falls back to the default welcome when called with empty config', async () => {
  155. // createStdioChat is exported and may be driven directly (bypassing the
  156. // Loader's schemastery validation), so it must default the welcome itself.
  157. const { out } = await setup({})
  158. expect(out.text()).toBe('ready.\n> ')
  159. })
  160. it('detects readline terminal mode from both stream TTY flags', async () => {
  161. for (const [inputTTY, outputTTY] of [[true, false], [true, true]] as const) {
  162. const ctx = new Context()
  163. await ctx.plugin(AgentRegistry)
  164. await ctx.plugin(UserInteractionService)
  165. let text = ''
  166. const output = new Writable({
  167. write(chunk, _encoding, callback) {
  168. text += String(chunk)
  169. callback()
  170. },
  171. }) as Writable & { isTTY?: boolean }
  172. const { runtime } = makeRuntime({ output })
  173. ;(runtime.input as Readable & { isTTY?: boolean }).isTTY = inputTTY
  174. output.isTTY = outputTTY
  175. const fiber = await ctx.plugin(Object.assign((inner: Context) => {
  176. createStdioChat(inner, CONFIG, runtime)
  177. }, { inject: ['agents', 'userInteraction'] }))
  178. expect(text).toContain('hi there')
  179. await fiber.dispose()
  180. }
  181. })
  182. it('renders text-delta chunks verbatim', async () => {
  183. const { ctx, out } = await setup()
  184. emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'text-delta', index: 0, text: 'hello' }))
  185. expect(out.text()).toContain('hello')
  186. })
  187. it('wraps reasoning-delta in the dim SGR and resets on the following text-delta', async () => {
  188. const { ctx, out } = await setup()
  189. const session = makeSession('main')
  190. emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'think' }))
  191. emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'more' }))
  192. emitSessionEvent(ctx, session, chunkEvent({ type: 'text-delta', index: 0, text: 'answer' }))
  193. expect(out.text()).toContain('\x1B[2mthinkmore\x1B[0m\nanswer')
  194. })
  195. it('ignores stream-chunk types it does not render', async () => {
  196. const { ctx, out } = await setup()
  197. const before = out.text()
  198. emitSessionEvent(ctx, makeSession('main'), chunkEvent({ type: 'block-start', index: 0, blockType: 'text' }))
  199. expect(out.text()).toBe(before)
  200. })
  201. it('renders turn/start and turn/end markers from the session feed', async () => {
  202. const { ctx, out } = await setup()
  203. const agent = makeAgent('main')
  204. ctx.agents.register(agent)
  205. const session = agent.session
  206. emitSessionEvent(ctx, session, {
  207. type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } },
  208. } as SessionEvent)
  209. expect(out.text()).toContain('[main turn 3] ')
  210. emitSessionEvent(ctx, session, {
  211. type: 'turn/end', seq: 2, time: 0, data: { turn: 3, reason: { kind: 'completed' } },
  212. })
  213. expect(out.text()).toContain('\n> ')
  214. })
  215. it('renders failure turn/end reasons so a failed turn is not silent', async () => {
  216. const { ctx, out } = await setup()
  217. const session = makeSession('main')
  218. ctx.emit('session/event', session, {
  219. type: 'turn/end', seq: 1, time: 0,
  220. data: { turn: 1, reason: { kind: 'error', step: 1, message: 'fetch failed: connect ECONNREFUSED', code: 'NETWORK' } },
  221. } as SessionEvent)
  222. expect(out.text()).toContain('[turn failed NETWORK] fetch failed: connect ECONNREFUSED')
  223. ctx.emit('session/event', session, {
  224. type: 'turn/end', seq: 2, time: 0,
  225. data: { turn: 2, reason: { kind: 'error', step: 1, message: 'uncoded failure' } },
  226. } as SessionEvent)
  227. expect(out.text()).toContain('[turn failed] uncoded failure')
  228. ctx.emit('session/event', session, {
  229. type: 'turn/end', seq: 3, time: 0, data: { turn: 3, reason: { kind: 'aborted', reason: 'user cancelled' } },
  230. } as SessionEvent)
  231. expect(out.text()).toContain('[turn aborted] user cancelled')
  232. ctx.emit('session/event', session, {
  233. type: 'turn/end', seq: 4, time: 0, data: { turn: 4, reason: { kind: 'aborted' } },
  234. } as SessionEvent)
  235. expect(out.text()).toContain('[turn aborted]\n> ')
  236. ctx.emit('session/event', session, {
  237. type: 'turn/end', seq: 5, time: 0, data: { turn: 5, reason: { kind: 'rejected', reason: 'policy veto' } },
  238. } as SessionEvent)
  239. expect(out.text()).toContain('[turn rejected] policy veto')
  240. ctx.emit('session/event', session, {
  241. type: 'turn/end', seq: 6, time: 0, data: { turn: 6, reason: { kind: 'max-tokens' } },
  242. } as SessionEvent)
  243. expect(out.text()).toContain('[turn hit the output-token limit]')
  244. ctx.emit('session/event', session, {
  245. type: 'turn/end', seq: 7, time: 0, data: { turn: 7, reason: { kind: 'interrupted' } },
  246. } as SessionEvent)
  247. expect(out.text()).toContain('[turn interrupted by a previous process exit]')
  248. })
  249. it('uses the session id as the label for a non-target session', async () => {
  250. const { ctx, out } = await setup()
  251. // No target exists, so the event's durable identity is the label.
  252. emitSessionEvent(ctx, makeSession('orphan'), {
  253. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  254. } as SessionEvent)
  255. expect(out.text()).toContain('[orphan turn 1] ')
  256. })
  257. it('uses an agent already registered before the UI installs as its target', async () => {
  258. // The pre-created `main` agent (and any agent surviving an HMR reload of just
  259. // this fiber) fired its `agent/created` before the UI's listener existed, so
  260. // the live listener alone would miss it. Seeding from `ctx.agents.list()` at
  261. // install time preserves the terminal's fixed `[main turn N]` label.
  262. const ctx = new Context()
  263. await ctx.plugin(AgentRegistry)
  264. await ctx.plugin(UserInteractionService)
  265. const agent = makeAgent('main')
  266. // Durable lineage does not imply runtime child ownership: the stdio app
  267. // may explicitly resume a persisted fork as its one configured agent.
  268. ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
  269. ctx.agents.register(agent) // registered BEFORE the UI plugin below
  270. const { runtime, out } = makeRuntime()
  271. await ctx.plugin(Object.assign((inner: Context) => {
  272. createStdioChat(inner, CONFIG, runtime)
  273. }, { inject: ['agents', 'userInteraction'] }))
  274. emitSessionEvent(ctx, agent.session, {
  275. type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } },
  276. } as SessionEvent)
  277. expect(out.text()).toContain('[main turn 5] ')
  278. })
  279. it('buffers input for a lineage-bearing configured agent until its session starts', async () => {
  280. const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' })
  281. input.feed('continue')
  282. await new Promise(resolve => setImmediate(resolve))
  283. const unrelated = makeAgent('unrelated')
  284. ctx.agents.register(unrelated)
  285. emitAgentSessionStart(ctx, unrelated, 'startup')
  286. const resumed = makeAgent('resumed')
  287. ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent'
  288. ctx.agents.register(resumed)
  289. await new Promise(resolve => setImmediate(resolve))
  290. expect(resumed.sent).toEqual([])
  291. emitAgentSessionStart(ctx, resumed, 'resume')
  292. await new Promise(resolve => setImmediate(resolve))
  293. expect(unrelated.sent).toEqual([])
  294. expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]])
  295. })
  296. it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => {
  297. const { ctx, out } = await setup()
  298. const session = makeSession('main')
  299. emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'mid' }))
  300. emitSessionEvent(ctx, session, {
  301. type: 'turn/end', seq: 1, time: 0, data: { turn: 1, reason: { kind: 'completed' } },
  302. })
  303. expect(out.text()).toContain('\x1B[2mmid\x1B[0m')
  304. })
  305. it('drops the target object on agent/disposed', async () => {
  306. const { ctx, out } = await setup()
  307. const agent = makeAgent('main')
  308. const dispose = ctx.agents.register(agent)
  309. dispose()
  310. // After disposal the event belongs to a non-target session, so its durable
  311. // identity is rendered directly.
  312. emitSessionEvent(ctx, agent.session, {
  313. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  314. } as SessionEvent)
  315. expect(out.text()).toContain('[main turn 1] ')
  316. })
  317. it('keeps the target when a different agent is disposed', async () => {
  318. const { ctx, out } = await setup()
  319. const target = makeAgent('main')
  320. ctx.agents.register(target)
  321. emitAgentDisposed(ctx, makeAgent('other'))
  322. emitSessionEvent(ctx, target.session, {
  323. type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } },
  324. } as SessionEvent)
  325. expect(out.text()).toContain('[main turn 1] ')
  326. })
  327. it('retargets only the exact identity after loop HMR recreation', async () => {
  328. const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' })
  329. const oldRoot = makeAgent('main-session-fixed')
  330. const prefixCollision = makeAgent('main-session-unrelated')
  331. const disposeOld = ctx.agents.register(oldRoot)
  332. ctx.agents.register(prefixCollision)
  333. disposeOld()
  334. const replacement = makeAgent('main-session-fixed')
  335. ctx.agents.register(replacement)
  336. input.feed('after hmr')
  337. await new Promise(resolve => setImmediate(resolve))
  338. expect(replacement.sent).toEqual([])
  339. emitAgentSessionStart(ctx, replacement, 'resume')
  340. await new Promise(resolve => setImmediate(resolve))
  341. expect(prefixCollision.sent).toEqual([])
  342. expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]])
  343. })
  344. it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => {
  345. const { ctx, input } = await setup()
  346. const unrelated = makeAgent('unrelated')
  347. ctx.agents.register(unrelated)
  348. const configured = makeAgent('main')
  349. const disposeConfigured = registerReady(ctx, configured)
  350. const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  351. disposeConfigured()
  352. input.feed('must not leak')
  353. await new Promise(resolve => setImmediate(resolve))
  354. expect(unrelated.sent).toEqual([])
  355. expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running')
  356. })
  357. it('renders tool/call and tool/result session events', async () => {
  358. const { ctx, out } = await setup()
  359. const session = {} as Session
  360. const callEvent = {
  361. type: 'tool/call', seq: 1, time: 0,
  362. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' },
  363. } as SessionEvent
  364. emitSessionEvent(ctx, session, callEvent)
  365. expect(out.text()).toContain('[tool call] bash({"command":"ls"})')
  366. const resultEvent = {
  367. type: 'tool/result', seq: 2, time: 0,
  368. data: { turn: 1, step: 0, callId: 'c1', content: [{ type: 'text', text: 'file.txt' }], isError: false },
  369. } as SessionEvent
  370. emitSessionEvent(ctx, session, resultEvent)
  371. expect(out.text()).toContain('[tool result] file.txt')
  372. })
  373. it('renders one full-fidelity result whether the event feed is live or replayed', async () => {
  374. const { ctx, out } = await setup()
  375. const session = makeSession('main')
  376. const original = {
  377. type: 'tool/result',
  378. seq: 2,
  379. time: 0,
  380. data: {
  381. turn: 1,
  382. step: 1,
  383. callId: 'c1',
  384. content: [{ type: 'text', text: 'full terminal output' }],
  385. isError: false,
  386. meta: { terminal: { output: 'full terminal output' } },
  387. },
  388. surfaceOp: 'append',
  389. } as SessionEvent
  390. const replacement = {
  391. ...original,
  392. seq: 3,
  393. data: {
  394. ...original.data,
  395. content: [{ type: 'text', text: '[... tool result middle pruned ...]' }],
  396. },
  397. surfaceOp: { op: 'replace', start: 2, end: 2 },
  398. sourceEventSeqs: [2],
  399. } as SessionEvent
  400. // Stdio consumes the same session/event shape whether a host forwards a
  401. // live append or replays a stored log through the rendering feed.
  402. for (const event of [original, replacement]) ctx.emit('session/event', session, event)
  403. expect(out.text().match(/\[tool result\]/g)).toHaveLength(1)
  404. expect(out.text()).toContain('full terminal output')
  405. expect(out.text()).not.toContain('tool result middle pruned')
  406. })
  407. it('renders a todo/write session event as a glyphed checklist', async () => {
  408. const { ctx, out } = await setup()
  409. const session = {} as Session
  410. emitSessionEvent(ctx, session, {
  411. type: 'todo/write', seq: 1, time: 0,
  412. data: { todos: [
  413. { content: 'read the code', status: 'completed' },
  414. { content: 'write the fix', status: 'in_progress' },
  415. { content: 'run the tests', status: 'pending' },
  416. ] },
  417. })
  418. const text = out.text()
  419. expect(text).toContain('[todos]')
  420. expect(text).toContain('[x] read the code')
  421. expect(text).toContain('[~] write the fix')
  422. expect(text).toContain('[ ] run the tests')
  423. })
  424. it('resets dim styling when a todo/write interrupts reasoning', async () => {
  425. const { ctx, out } = await setup()
  426. emitSessionEvent(ctx, {} as Session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  427. emitSessionEvent(ctx, {} as Session, {
  428. type: 'todo/write', seq: 1, time: 0,
  429. data: { todos: [{ content: 'a task', status: 'pending' }] },
  430. })
  431. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  432. })
  433. it('resets dim styling when a tool/call interrupts reasoning', async () => {
  434. const { ctx, out } = await setup()
  435. const session = {} as Session
  436. emitSessionEvent(ctx, session, chunkEvent({ type: 'reasoning-delta', index: 0, text: 'r' }))
  437. emitSessionEvent(ctx, session, {
  438. type: 'tool/call', seq: 1, time: 0,
  439. data: { turn: 1, step: 0, callId: 'c1', name: 'bash', arguments: '{}' },
  440. } as SessionEvent)
  441. expect(out.text()).toContain('\x1B[2mr\x1B[0m')
  442. })
  443. it('ignores session events it does not render', async () => {
  444. const { ctx, out } = await setup()
  445. const before = out.text()
  446. emitSessionEvent(ctx, {} as Session, {
  447. type: 'user/message', seq: 1, time: 0,
  448. data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
  449. })
  450. expect(out.text()).toBe(before)
  451. })
  452. })
  453. describe('createStdioChat input', () => {
  454. it('answers a pending user question instead of sending the line to the agent', async () => {
  455. const { ctx, input, out } = await setup()
  456. const agent = makeAgent('main', 'idle')
  457. ctx.agents.register(agent)
  458. const answer = ctx.userInteraction.ask({
  459. questions: [{
  460. id: 'confirm',
  461. header: 'Confirm',
  462. question: 'Proceed with the edit?',
  463. options: [{ label: 'Yes', description: 'Apply the edit now.' }],
  464. }],
  465. })
  466. await new Promise(r => setImmediate(r))
  467. input.feed('Use a smaller change')
  468. await expect(answer).resolves.toEqual({ answers: [{ id: 'confirm', selected: [], custom: 'Use a smaller change' }] })
  469. expect(agent.sent).toEqual([])
  470. expect(out.text()).toContain('[Confirm] Proceed with the edit?')
  471. expect(out.text()).toContain('1. Yes')
  472. expect(out.text()).toContain('Apply the edit now.')
  473. })
  474. it('answers a pending user question by numeric option selection', async () => {
  475. const { ctx, input } = await setup()
  476. const answer = ctx.userInteraction.ask({
  477. questions: [{
  478. id: 'mode',
  479. question: 'Which mode?',
  480. options: [
  481. { label: 'Safe' },
  482. { label: 'Fast' },
  483. ],
  484. }],
  485. })
  486. await new Promise(r => setImmediate(r))
  487. input.feed('2')
  488. await expect(answer).resolves.toEqual({
  489. answers: [{ id: 'mode', selected: ['Fast'] }],
  490. })
  491. })
  492. it('renders options in input order and selects by displayed number', async () => {
  493. const { ctx, input, out } = await setup()
  494. const answer = ctx.userInteraction.ask({
  495. questions: [{
  496. id: 'topic',
  497. question: 'Which topic?',
  498. options: [
  499. { label: 'Hobbies' },
  500. { label: 'Work', description: 'Questions about current projects.' },
  501. { label: 'Casual', description: 'Easy conversation.' },
  502. ],
  503. }],
  504. })
  505. await new Promise(r => setImmediate(r))
  506. expect(out.text()).toContain([
  507. 'Which topic?',
  508. ' 1. Hobbies',
  509. ' 2. Work',
  510. ' Questions about current projects.',
  511. ' 3. Casual',
  512. ' Easy conversation.',
  513. ].join('\n'))
  514. input.feed('3')
  515. await expect(answer).resolves.toEqual({
  516. answers: [{ id: 'topic', selected: ['Casual'] }],
  517. })
  518. })
  519. it('answers a multi-select question with multiple numeric selections', async () => {
  520. const { ctx, input } = await setup()
  521. const answer = ctx.userInteraction.ask({
  522. questions: [{
  523. id: 'targets',
  524. question: 'What should I update?',
  525. options: [{ label: 'Tests' }, { label: 'Docs' }, { label: 'Code' }],
  526. multiSelect: true,
  527. }],
  528. })
  529. await new Promise(r => setImmediate(r))
  530. input.feed('1 1, 3')
  531. await expect(answer).resolves.toEqual({
  532. answers: [{ id: 'targets', selected: ['Tests', 'Code'] }],
  533. })
  534. })
  535. it('accepts non-numeric multi-select input as a custom answer', async () => {
  536. const { ctx, input } = await setup()
  537. const answer = ctx.userInteraction.ask({
  538. questions: [{
  539. id: 'targets',
  540. question: 'What should I update?',
  541. options: [{ label: 'Tests' }, { label: 'Docs' }],
  542. multiSelect: true,
  543. }],
  544. })
  545. await new Promise(r => setImmediate(r))
  546. input.feed('the release notes')
  547. await expect(answer).resolves.toEqual({
  548. answers: [{ id: 'targets', selected: [], custom: 'the release notes' }],
  549. })
  550. })
  551. it('asks every question in a batch and returns answers by id', async () => {
  552. const { ctx, input, out } = await setup()
  553. const answer = ctx.userInteraction.ask({
  554. questions: [
  555. { id: 'language', question: 'Which language?', options: [{ label: 'Python' }, { label: 'TypeScript' }] },
  556. { id: 'note', question: 'Any note?' },
  557. ],
  558. })
  559. await new Promise(r => setImmediate(r))
  560. input.feed('2')
  561. await new Promise(r => setImmediate(r))
  562. expect(out.text()).toContain('\nAny note?\n')
  563. input.feed('ship today')
  564. await expect(answer).resolves.toEqual({
  565. answers: [
  566. { id: 'language', selected: ['TypeScript'] },
  567. { id: 'note', selected: [], custom: 'ship today' },
  568. ],
  569. })
  570. })
  571. it('re-prompts when option input is invalid', async () => {
  572. const { ctx, input, out } = await setup()
  573. const answer = ctx.userInteraction.ask({
  574. questions: [{
  575. id: 'mode',
  576. question: 'Which mode?',
  577. options: [{ label: 'Safe' }],
  578. multiSelect: true,
  579. }],
  580. })
  581. await new Promise(r => setImmediate(r))
  582. input.feed('2')
  583. await new Promise(r => setImmediate(r))
  584. expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
  585. input.feed('1')
  586. await expect(answer).resolves.toEqual({
  587. answers: [{ id: 'mode', selected: ['Safe'] }],
  588. })
  589. })
  590. it('re-prompts when single-select option input is out of range', async () => {
  591. const { ctx, input, out } = await setup()
  592. const answer = ctx.userInteraction.ask({
  593. questions: [{
  594. id: 'mode',
  595. question: 'Which mode?',
  596. options: [{ label: 'Safe' }],
  597. }],
  598. })
  599. await new Promise(r => setImmediate(r))
  600. input.feed('2')
  601. await new Promise(r => setImmediate(r))
  602. expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
  603. input.feed('1')
  604. await expect(answer).resolves.toEqual({
  605. answers: [{ id: 'mode', selected: ['Safe'] }],
  606. })
  607. })
  608. it('re-prompts when multi-select input contains no option numbers', async () => {
  609. const { ctx, input, out } = await setup()
  610. const answer = ctx.userInteraction.ask({
  611. questions: [{
  612. id: 'mode',
  613. question: 'Which mode?',
  614. options: [{ label: 'Safe' }],
  615. multiSelect: true,
  616. }],
  617. })
  618. await new Promise(r => setImmediate(r))
  619. input.feed(',')
  620. await new Promise(r => setImmediate(r))
  621. expect(out.text()).toContain('Please enter one of the option numbers (comma or space separated) or a custom answer.')
  622. input.feed('1')
  623. await expect(answer).resolves.toEqual({
  624. answers: [{ id: 'mode', selected: ['Safe'] }],
  625. })
  626. })
  627. it('re-prompts when an option question receives an empty answer', async () => {
  628. const { ctx, input, out } = await setup()
  629. const answer = ctx.userInteraction.ask({
  630. questions: [{
  631. id: 'mode',
  632. question: 'Which mode?',
  633. options: [{ label: 'Safe' }],
  634. }],
  635. })
  636. await new Promise(r => setImmediate(r))
  637. input.feed('')
  638. await new Promise(r => setImmediate(r))
  639. expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
  640. input.feed('1')
  641. await expect(answer).resolves.toEqual({
  642. answers: [{ id: 'mode', selected: ['Safe'] }],
  643. })
  644. })
  645. it('re-prompts when a question receives an empty answer', async () => {
  646. const { ctx, input, out } = await setup()
  647. const answer = ctx.userInteraction.ask({ questions: [{ id: 'path', question: 'What should I use?' }] })
  648. await new Promise(r => setImmediate(r))
  649. input.feed('')
  650. await new Promise(r => setImmediate(r))
  651. expect(out.text()).toContain('Please enter an answer.')
  652. input.feed('Use defaults')
  653. await expect(answer).resolves.toEqual({ answers: [{ id: 'path', selected: [], custom: 'Use defaults' }] })
  654. })
  655. it('rejects an active question when its signal aborts', async () => {
  656. const { ctx } = await setup()
  657. const controller = new AbortController()
  658. const answer = ctx.userInteraction.ask({ questions: [{ id: 'continue', question: 'Continue?' }], signal: controller.signal })
  659. const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  660. await new Promise(r => setImmediate(r))
  661. controller.abort()
  662. await rejected
  663. })
  664. it('continues to the next queued question when the active question aborts', async () => {
  665. const { ctx, input, out } = await setup()
  666. const controller = new AbortController()
  667. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }], signal: controller.signal })
  668. const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  669. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }] })
  670. await new Promise(r => setImmediate(r))
  671. controller.abort()
  672. await firstRejected
  673. await new Promise(r => setImmediate(r))
  674. expect(out.text()).toContain('\nSecond?\n')
  675. input.feed('second answer')
  676. await expect(second).resolves.toEqual({ answers: [{ id: 'second', selected: [], custom: 'second answer' }] })
  677. })
  678. it('skips a queued question whose signal aborted before it became active', async () => {
  679. const { ctx, input, out } = await setup()
  680. const controller = new AbortController()
  681. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
  682. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
  683. await new Promise(r => setImmediate(r))
  684. controller.abort()
  685. await expect(Promise.race([
  686. second.then(
  687. () => 'resolved',
  688. (error: unknown) => (error as { code?: string }).code,
  689. ),
  690. new Promise<string>((resolve) => { setImmediate(() => { resolve('pending') }) }),
  691. ])).resolves.toBe('ASK_ABORTED')
  692. expect(out.text()).not.toContain('\nSecond?\n')
  693. input.feed('first answer')
  694. await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
  695. })
  696. it('removes an aborted queued question without promoting later queued work early', async () => {
  697. const { ctx, input, out } = await setup()
  698. const controller = new AbortController()
  699. const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] })
  700. const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal })
  701. const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] })
  702. await new Promise(r => setImmediate(r))
  703. controller.abort()
  704. await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  705. expect(out.text()).toContain('\nFirst?\n')
  706. expect(out.text()).not.toContain('\nSecond?\n')
  707. expect(out.text()).not.toContain('\nThird?\n')
  708. input.feed('first answer')
  709. await new Promise(r => setImmediate(r))
  710. expect(out.text()).toContain('\nThird?\n')
  711. input.feed('third answer')
  712. await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] })
  713. await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] })
  714. })
  715. it('rejects active and queued questions when the UI is disposed', async () => {
  716. const { ctx, fiber } = await setup()
  717. const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
  718. const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
  719. const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  720. const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  721. await new Promise(r => setImmediate(r))
  722. await fiber.dispose()
  723. await activeRejected
  724. await queuedRejected
  725. })
  726. it('rejects active and queued questions when stdin closes before the user answers', async () => {
  727. const { ctx, input, exit } = await setup()
  728. const active = ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] })
  729. const queued = ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] })
  730. const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  731. const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  732. await new Promise(r => setImmediate(r))
  733. input.finish()
  734. await new Promise(r => setImmediate(r))
  735. await activeRejected
  736. await queuedRejected
  737. expect(exit).not.toHaveBeenCalled()
  738. })
  739. it('rejects new questions immediately after stdin has closed', async () => {
  740. const { ctx, input, out } = await setup()
  741. input.finish()
  742. await new Promise(r => setImmediate(r))
  743. const before = out.text()
  744. const answer = ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Too late?' }] })
  745. await expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
  746. expect(out.text()).toBe(before)
  747. })
  748. it('sends a typed line to an idle agent', async () => {
  749. const { ctx, input } = await setup()
  750. const agent = makeAgent('main', 'idle')
  751. registerReady(ctx, agent)
  752. input.feed('do a thing')
  753. await new Promise(r => setImmediate(r))
  754. expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]])
  755. expect(agent.steered).toEqual([])
  756. })
  757. it('steers a typed line into a running agent', async () => {
  758. const { ctx, input } = await setup()
  759. const agent = makeAgent('main', 'running')
  760. registerReady(ctx, agent)
  761. input.feed('steer me')
  762. await new Promise(r => setImmediate(r))
  763. expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]])
  764. expect(agent.sent).toEqual([])
  765. })
  766. it('ignores blank lines', async () => {
  767. const { ctx, input } = await setup()
  768. const agent = makeAgent('main')
  769. ctx.agents.register(agent)
  770. input.feed(' ')
  771. await new Promise(r => setImmediate(r))
  772. expect(agent.sent).toEqual([])
  773. })
  774. it('buffers a line until the initial target session starts', async () => {
  775. const { ctx, input } = await setup()
  776. const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  777. input.feed('nobody home')
  778. await new Promise(r => setImmediate(r))
  779. expect(spy).not.toHaveBeenCalled()
  780. const agent = makeAgent('main')
  781. ctx.agents.register(agent)
  782. await new Promise(r => setImmediate(r))
  783. expect(agent.sent).toEqual([])
  784. emitAgentSessionStart(ctx, agent, 'startup')
  785. await new Promise(r => setImmediate(r))
  786. expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]])
  787. })
  788. it('drops later input after the configured startup fails', async () => {
  789. const { ctx, input } = await setup()
  790. const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  791. const failure = unrenderableFailure()
  792. ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure)
  793. input.feed('cannot run')
  794. await new Promise(r => setImmediate(r))
  795. expect(error).toHaveBeenCalledWith(
  796. 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable value>',
  797. )
  798. })
  799. it('ignores a stale config-start failure after the exact target is ready', async () => {
  800. const { ctx, input } = await setup()
  801. const agent = makeAgent('main')
  802. registerReady(ctx, agent)
  803. ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale'))
  804. input.feed('still live')
  805. await new Promise(r => setImmediate(r))
  806. expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]])
  807. })
  808. it('drives the exact app-configured resumed session', async () => {
  809. const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' })
  810. const agent = makeAgent('worker')
  811. registerReady(ctx, agent, 'resume')
  812. input.feed('hi')
  813. await new Promise(r => setImmediate(r))
  814. expect(agent.sent).toHaveLength(1)
  815. })
  816. })
  817. describe('createStdioChat EOF exit', () => {
  818. it('exits immediately on EOF when no work was submitted', async () => {
  819. const { input, exit } = await setup()
  820. input.finish()
  821. await flushExit()
  822. expect(exit).toHaveBeenCalledWith(0)
  823. })
  824. it('waits for the agent to settle idle after running before exiting', async () => {
  825. const { ctx, input, exit } = await setup()
  826. const agent = makeAgent('main', 'idle')
  827. registerReady(ctx, agent)
  828. input.feed('work')
  829. await new Promise(r => setImmediate(r))
  830. input.finish()
  831. await new Promise(r => setImmediate(r))
  832. // Work submitted but no 'running' observed yet — must NOT exit.
  833. expect(exit).not.toHaveBeenCalled()
  834. // The turn starts, then settles.
  835. emitAgentStatus(ctx, agent, 'running')
  836. ;(agent as { status: AgentStatus }).status = 'idle'
  837. emitAgentStatus(ctx, agent, 'idle')
  838. await flushExit()
  839. expect(exit).toHaveBeenCalledWith(0)
  840. })
  841. it('keeps piped EOF pending until buffered startup input runs', async () => {
  842. const { ctx, input, exit } = await setup()
  843. input.feed('work')
  844. input.finish()
  845. await flushExit()
  846. expect(exit).not.toHaveBeenCalled()
  847. const agent = makeAgent('main', 'idle')
  848. ctx.agents.register(agent)
  849. await new Promise(r => setImmediate(r))
  850. expect(agent.sent).toEqual([])
  851. emitAgentSessionStart(ctx, agent, 'startup')
  852. await new Promise(r => setImmediate(r))
  853. expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]])
  854. emitAgentStatus(ctx, agent, 'running')
  855. ;(agent as { status: AgentStatus }).status = 'idle'
  856. emitAgentStatus(ctx, agent, 'idle')
  857. await flushExit()
  858. expect(exit).toHaveBeenCalledWith(0)
  859. })
  860. it('drains buffered piped input and exits when configured startup fails', async () => {
  861. const { ctx, input, exit } = await setup()
  862. const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
  863. input.feed('work')
  864. input.finish()
  865. await new Promise(r => setImmediate(r))
  866. ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated'))
  867. await flushExit()
  868. expect(exit).not.toHaveBeenCalled()
  869. ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure())
  870. await flushExit()
  871. expect(error).toHaveBeenCalledWith(
  872. 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): <unrenderable value>',
  873. )
  874. expect(exit).toHaveBeenCalledWith(0)
  875. })
  876. it('schedules the exit only once when idle fires repeatedly', async () => {
  877. const { ctx, input, exit } = await setup()
  878. const agent = makeAgent('main', 'running')
  879. registerReady(ctx, agent)
  880. input.feed('work')
  881. await new Promise(r => setImmediate(r))
  882. emitAgentStatus(ctx, agent, 'running') // sawRunning = true
  883. input.finish()
  884. await new Promise(r => setImmediate(r)) // let readline 'close' set stdinClosed
  885. ;(agent as { status: AgentStatus }).status = 'idle'
  886. // Two idle signals while stdin is already closed: the first arms the timer,
  887. // the second must hit the already-scheduled guard, not arm a second.
  888. emitAgentStatus(ctx, agent, 'idle')
  889. emitAgentStatus(ctx, agent, 'idle')
  890. await flushExit()
  891. expect(exit).toHaveBeenCalledTimes(1)
  892. })
  893. it('does not exit on an idle transition for a different agent', async () => {
  894. const { ctx, input, exit } = await setup()
  895. const agent = makeAgent('main', 'idle')
  896. registerReady(ctx, agent)
  897. input.feed('work')
  898. await new Promise(r => setImmediate(r))
  899. input.finish()
  900. const other = makeAgent('other')
  901. emitAgentStatus(ctx, other, 'running')
  902. emitAgentStatus(ctx, other, 'idle')
  903. await flushExit()
  904. expect(exit).not.toHaveBeenCalled()
  905. })
  906. it('does not exit while a turn is still running at EOF', async () => {
  907. const { ctx, input, exit } = await setup()
  908. const agent = makeAgent('main', 'idle')
  909. registerReady(ctx, agent)
  910. input.feed('work')
  911. await new Promise(r => setImmediate(r))
  912. emitAgentStatus(ctx, agent, 'running')
  913. ;(agent as { status: AgentStatus }).status = 'running'
  914. input.finish()
  915. // sawRunning is true, but the agent is still running — the idle gate holds.
  916. emitAgentStatus(ctx, agent, 'idle') // a stale/duplicate signal while status stays 'running'
  917. await flushExit()
  918. expect(exit).not.toHaveBeenCalled()
  919. })
  920. })
  921. describe('createStdioChat disposal (HMR safety)', () => {
  922. it('never exits the process when EOF arrives after fiber dispose', async () => {
  923. const { fiber, input, exit } = await setup()
  924. await fiber.dispose()
  925. // A late EOF after disposal (reader.close() also fires 'close') must not exit.
  926. input.finish()
  927. await flushExit()
  928. expect(exit).not.toHaveBeenCalled()
  929. })
  930. it('cancels a scheduled exit if disposed within the flush window', async () => {
  931. const { fiber, input, exit } = await setup()
  932. // EOF with no work submitted schedules the 200ms flush-then-exit timer.
  933. input.finish()
  934. await new Promise(r => setImmediate(r))
  935. expect(exit).not.toHaveBeenCalled() // not yet — still inside the window
  936. // Dispose BEFORE the timer fires: the tracked handle must be cleared.
  937. await fiber.dispose()
  938. await flushExit()
  939. expect(exit).not.toHaveBeenCalled()
  940. })
  941. it('stops handling input after dispose', async () => {
  942. const { ctx, fiber, input } = await setup()
  943. const agent = makeAgent('main')
  944. ctx.agents.register(agent)
  945. await fiber.dispose()
  946. // The readline interface is closed on dispose; a late line reaches no handler.
  947. input.feed('too late')
  948. await new Promise(r => setImmediate(r))
  949. expect(agent.sent).toEqual([])
  950. })
  951. it('removes the agent/status listener on dispose', async () => {
  952. const { ctx, fiber, input, exit } = await setup()
  953. const agent = makeAgent('main', 'idle')
  954. registerReady(ctx, agent)
  955. input.feed('work')
  956. await new Promise(r => setImmediate(r))
  957. await fiber.dispose()
  958. // After dispose, status transitions must neither throw nor schedule an exit
  959. // (the listener and the EOF-exit path are both torn down).
  960. expect(() => {
  961. emitAgentStatus(ctx, agent, 'running')
  962. emitAgentStatus(ctx, agent, 'idle')
  963. }).not.toThrow()
  964. await flushExit()
  965. expect(exit).not.toHaveBeenCalled()
  966. })
  967. })