cancel.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753
  1. /**
  2. * Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the broad verb — it
  3. * clears queued + steering work, aborts the active turn, and drops work not yet claimed by the
  4. * driver without leaking cancellation into a replacement prompt. The suite covers every landing
  5. * window plus signal reset and `whenIdle()` quiescence.
  6. * @module dsh-agent-loop/tests/cancel
  7. */
  8. import { describe, expect, it, vi } from 'vitest'
  9. import { Context } from 'cordis'
  10. import LlmService from '@deepseek-ai/dsh-llm'
  11. import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  14. import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
  15. import AgentLoop from '@deepseek-ai/dsh-agent-loop'
  16. import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
  17. function driverDone(agent: Agent): Promise<void> {
  18. return (agent as Agent & { done: Promise<void> }).done
  19. }
  20. async function harness(adapter: MockAdapter) {
  21. const ctx = new Context()
  22. await ctx.plugin(LlmService)
  23. await ctx.plugin(SessionStore)
  24. await ctx.plugin(SystemPrompt)
  25. await ctx.plugin(ToolRegistry)
  26. await ctx.plugin(AgentRegistry)
  27. await ctx.plugin(AgentLoop, { agents: [] })
  28. ctx.llm.registerAdapter(['mock'], adapter)
  29. return ctx
  30. }
  31. function send(agent: Agent, text: string) {
  32. agent.followup({ content: [{ type: 'text', text }], source: { kind: 'user' } })
  33. }
  34. /** Resolve on the agent's next idle transition (event-based, not status poll). */
  35. function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
  36. return new Promise((resolve) => {
  37. const dispose = ctx.on('agent/status', (subject, status) => {
  38. if (subject === agent && status === 'idle') { dispose(); resolve() }
  39. })
  40. })
  41. }
  42. /** All user-message texts recorded in the log (to assert what actually ran). */
  43. function userTexts(agent: Agent): string[] {
  44. return agent.session.events
  45. .filter(e => e.type === 'user/message')
  46. .flatMap(e => e.type === 'user/message' ? e.data.content : [])
  47. .flatMap(b => b.type === 'text' ? [b.text] : [])
  48. }
  49. describe('Agent.cancel()', () => {
  50. it('notifies every observer before clearing work and contains listener failures', async () => {
  51. const adapter = new MockAdapter([textResponse('must remain unused')])
  52. const ctx = await harness(adapter)
  53. const agent = ctx.agentLoop.create(SessionId('cancel-event'), { provider: 'mock', model: 'mock' })
  54. const warned = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  55. const seen: string[] = []
  56. ctx.on('agent/cancel-requested', (subject, cause) => {
  57. if (subject !== agent) return
  58. seen.push(`first:${cause.kind}`)
  59. subject.followup({ content: [{ type: 'text', text: 'queued by cancel observer' }], source: { kind: 'user' } })
  60. throw new Error('observer failed')
  61. })
  62. ctx.on('agent/cancel-requested', (subject, cause) => {
  63. if (subject === agent) seen.push(`second:${cause.kind}`)
  64. })
  65. send(agent, 'drop me')
  66. agent.cancel({ kind: 'user' })
  67. await new Promise(resolve => setTimeout(resolve, 30))
  68. agent.cancel({ kind: 'parent' })
  69. expect(seen).toEqual(['first:user', 'second:user'])
  70. expect(userTexts(agent)).toEqual([])
  71. expect(adapter.requests).toHaveLength(0)
  72. expect(warned).toHaveBeenCalledWith(expect.stringContaining('agent/cancel-requested'))
  73. })
  74. it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
  75. const adapter = new MockAdapter([textResponse('reply')])
  76. const ctx = await harness(adapter)
  77. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  78. // The loop is parked at the idle wait with nothing queued. A cancel here must
  79. // NOT arm the marker — otherwise the next legitimate prompt would be dropped.
  80. agent.cancel({ kind: 'user' })
  81. send(agent, 'real prompt')
  82. await waitForIdle(ctx, agent)
  83. // The prompt ran: its user message is in the log and one turn completed.
  84. expect(userTexts(agent)).toEqual(['real prompt'])
  85. expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
  86. })
  87. it('cancel({ keepInbox: true }) preserves queued work and emits no discard', async () => {
  88. const adapter = new MockAdapter([textResponse('reply')])
  89. const ctx = await harness(adapter)
  90. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  91. const discards: unknown[] = []
  92. ctx.on('agent/inbox/discard', (subject, items) => { if (subject === agent) discards.push(items) })
  93. const cancelRequests: unknown[] = []
  94. ctx.on('agent/cancel-requested', (subject, cause) => { if (subject === agent) cancelRequests.push(cause) })
  95. // Queue a turn WITHOUT waking the driver, so it sits in the inbox.
  96. agent.send({ content: [{ type: 'text', text: 'preserved' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
  97. // keepInbox cancel: no active turn, work preserved, no discard event. With
  98. // nothing to abort and nothing discarded, the call is a documented no-op,
  99. // so it emits no cancel-requested either.
  100. agent.cancel({ kind: 'user' }, { keepInbox: true })
  101. expect(discards).toEqual([])
  102. expect(cancelRequests).toEqual([])
  103. // The preserved item still runs once the driver is woken by a later send.
  104. send(agent, 'wake it')
  105. await waitForIdle(ctx, agent)
  106. expect(userTexts(agent)).toEqual(['preserved', 'wake it'])
  107. })
  108. it('a lone quiet (wakeup:false) send leaves the agent parked at idle', async () => {
  109. const adapter = new MockAdapter([textResponse('reply')])
  110. const ctx = await harness(adapter)
  111. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  112. // A quiet item alone must NOT wake the driver: no turn runs and whenIdle
  113. // resolves (the agent is quiescent), leaving the item queued.
  114. agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
  115. await agent.whenIdle()
  116. expect(agent.status).toBe('idle')
  117. expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
  118. // A later waking send drives the loop, and the quiet item rides along first.
  119. send(agent, 'wake')
  120. await waitForIdle(ctx, agent)
  121. expect(userTexts(agent)).toEqual(['quiet', 'wake'])
  122. })
  123. it('cancelling a parked quiet item settles a pending whenIdle() without a later send', async () => {
  124. const adapter = new MockAdapter([textResponse('reply')])
  125. const ctx = await harness(adapter)
  126. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  127. agent.send({ content: [{ type: 'text', text: 'quiet' }], source: { kind: 'user' } }, { target: 'next-turn', wakeup: false })
  128. const idle = agent.whenIdle()
  129. agent.cancel({ kind: 'user' })
  130. await idle
  131. expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
  132. })
  133. it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
  134. const adapter = new MockAdapter([textResponse('should not run')])
  135. const ctx = await harness(adapter)
  136. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  137. // send() queues synchronously (status still idle, loop microtask not yet
  138. // resumed). Cancel in that pre-step window: the queued turn must not run.
  139. send(agent, 'drop me first')
  140. send(agent, 'drop me second')
  141. agent.cancel({ kind: 'user' })
  142. // Give the loop a chance to wake and process the cancel.
  143. await new Promise(r => setTimeout(r, 30))
  144. // No turn was opened — the queued prompt was dropped, never recorded.
  145. expect(userTexts(agent)).toEqual([])
  146. expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
  147. expect(agent.status).toBe('idle')
  148. })
  149. it('disposal from the running notification drops queued work before turn start', async () => {
  150. const adapter = new MockAdapter([textResponse('should not run')])
  151. const ctx = await harness(adapter)
  152. const handle = await ctx.agents.create({
  153. sessionId: SessionId('dispose-running-session'),
  154. agentOptions: { provider: 'mock', model: 'mock' },
  155. })
  156. const agent = handle.agent
  157. const running = Promise.withResolvers<undefined>()
  158. let disposalDone: Promise<void> | undefined
  159. ctx.on('agent/status', (subject, status) => {
  160. if (subject !== agent || status !== 'running') return
  161. disposalDone = handle.dispose()
  162. running.resolve(undefined)
  163. })
  164. send(agent, 'drop before claim')
  165. await running.promise
  166. if (disposalDone === undefined) throw new Error('running listener did not start disposal')
  167. await disposalDone
  168. await driverDone(agent)
  169. expect(agent.status).toBe('idle')
  170. expect(agent.session.events.some(event => event.type === 'turn/start')).toBe(false)
  171. expect(userTexts(agent)).toEqual([])
  172. expect(adapter.requests).toHaveLength(0)
  173. })
  174. it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
  175. const adapter = new MockAdapter([textResponse('x')])
  176. const ctx = await harness(adapter)
  177. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  178. // This waiter cannot rely on a running→idle transition because cancellation
  179. // drops the turn before it runs; the skip path must settle it directly.
  180. send(agent, 'q')
  181. const idle = agent.whenIdle()
  182. agent.cancel({ kind: 'user' })
  183. // Must resolve (not hang). A timeout makes the failure a clear test failure.
  184. await Promise.race([
  185. idle,
  186. new Promise((_r, reject) => setTimeout(() => { reject(new Error('whenIdle hung after pre-step cancel')) }, 1000)),
  187. ])
  188. expect(agent.status).toBe('idle')
  189. })
  190. it('idle-listener cancellation settles its waiter without cancelling later work', async () => {
  191. const adapter = new MockAdapter([textResponse('first reply'), textResponse('later reply')])
  192. const ctx = await harness(adapter)
  193. const agent = ctx.agentLoop.create(SessionId('idle-listener-cancel'), { provider: 'mock', model: 'mock' })
  194. const replacementRegistered = Promise.withResolvers<undefined>()
  195. let replacementObservation: Promise<{ status: string; requests: number; turns: number }> | undefined
  196. ctx.on('agent/status', (subject, status) => {
  197. if (subject !== agent || status !== 'idle' || replacementObservation !== undefined) return
  198. send(agent, 'cancelled replacement')
  199. replacementObservation = agent.whenIdle().then(() => ({
  200. status: agent.status,
  201. requests: adapter.requests.length,
  202. turns: agent.session.events.filter(event => event.type === 'turn/start').length,
  203. }))
  204. agent.cancel({ kind: 'user' })
  205. replacementRegistered.resolve(undefined)
  206. })
  207. send(agent, 'first')
  208. await replacementRegistered.promise
  209. if (replacementObservation === undefined) throw new Error('idle listener did not register replacement work')
  210. await expect(Promise.race([
  211. replacementObservation,
  212. new Promise((_resolve, reject) => setTimeout(() => { reject(new Error('whenIdle hung after idle-listener cancel')) }, 1000)),
  213. ])).resolves.toEqual({ status: 'idle', requests: 1, turns: 1 })
  214. const idle = waitForIdle(ctx, agent)
  215. send(agent, 'later')
  216. await idle
  217. expect(adapter.requests).toHaveLength(2)
  218. expect(userTexts(agent)).toEqual(['first', 'later'])
  219. })
  220. it('replacement work queued after idle-listener cancellation still runs', async () => {
  221. const adapter = new MockAdapter([textResponse('first reply'), textResponse('replacement reply')])
  222. const ctx = await harness(adapter)
  223. const agent = ctx.agentLoop.create(SessionId('idle-listener-post-cancel-send'), { provider: 'mock', model: 'mock' })
  224. const replacementRegistered = Promise.withResolvers<undefined>()
  225. let replacementIdle: Promise<void> | undefined
  226. ctx.on('agent/status', (subject, status) => {
  227. if (subject !== agent || status !== 'idle' || replacementIdle !== undefined) return
  228. send(agent, 'cancelled replacement')
  229. agent.cancel({ kind: 'user' })
  230. send(agent, 'surviving replacement')
  231. replacementIdle = agent.whenIdle()
  232. replacementRegistered.resolve(undefined)
  233. })
  234. send(agent, 'first')
  235. await replacementRegistered.promise
  236. if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work')
  237. await replacementIdle
  238. expect(adapter.requests).toHaveLength(2)
  239. expect(userTexts(agent)).toEqual(['first', 'surviving replacement'])
  240. })
  241. it('cancel() mid-step aborts the active turn and drops every queued tail item', async () => {
  242. const adapter = new MockAdapter(['hang'])
  243. const ctx = await harness(adapter)
  244. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  245. const reasons: TurnEndReason[] = []
  246. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  247. send(agent, 'go')
  248. await new Promise(r => setTimeout(r, 30))
  249. expect(agent.status).toBe('running')
  250. send(agent, 'queued tail')
  251. agent.cancel({ kind: 'user' })
  252. await waitForIdle(ctx, agent)
  253. expect(reasons).toEqual([{ kind: 'aborted' }])
  254. expect(userTexts(agent)).toEqual(['go'])
  255. expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
  256. expect(adapter.requests).toHaveLength(1)
  257. })
  258. it('cancel from an assistant/message observer skips execution but balances replay', async () => {
  259. const adapter = new MockAdapter([
  260. toolCallResponse('c1', 'danger', {}),
  261. textResponse('recovered after cancellation'),
  262. ])
  263. const ctx = await harness(adapter)
  264. let executions = 0
  265. ctx.tools.register(defineContentToolFixture({
  266. name: 'danger',
  267. description: 'must not run after cancellation',
  268. parameters: {},
  269. async execute() {
  270. executions += 1
  271. return [{ type: 'text', text: 'ran' }]
  272. },
  273. }))
  274. const agent = ctx.agentLoop.create(SessionId('cancel-after-assistant-message'), { provider: 'mock', model: 'mock' })
  275. const dispose = ctx.on('session/event', (session, event) => {
  276. if (session === agent.session && event.type === 'assistant/message') {
  277. agent.cancel({ kind: 'user' })
  278. }
  279. })
  280. const reasons: TurnEndReason[] = []
  281. ctx.on('session/event', (_session, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  282. send(agent, 'go')
  283. await waitForIdle(ctx, agent)
  284. dispose()
  285. expect(executions).toBe(0)
  286. expect(reasons).toEqual([{ kind: 'aborted' }])
  287. const call = agent.session.events.find(event => event.type === 'tool/call')
  288. const result = agent.session.events.find(event => event.type === 'tool/result')
  289. expect(call?.type === 'tool/call' ? call.data.callId : undefined).toBe('c1')
  290. expect(result?.type === 'tool/result' ? result.data : undefined).toMatchObject({
  291. callId: 'c1',
  292. isError: true,
  293. error: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  294. })
  295. send(agent, 'continue safely')
  296. await waitForIdle(ctx, agent)
  297. const replayedResult = adapter.requests[1]!.messages
  298. .flatMap(message => message.content)
  299. .find(block => block.type === 'tool-result')
  300. expect(replayedResult).toMatchObject({ toolCallId: 'c1', isError: true })
  301. expect(reasons).toEqual([
  302. { kind: 'aborted' },
  303. { kind: 'completed' },
  304. ])
  305. })
  306. it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
  307. const adapter = new MockAdapter(['hang', textResponse('second reply')])
  308. const ctx = await harness(adapter)
  309. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  310. // First turn hangs; cancel it mid-step.
  311. send(agent, 'first')
  312. await new Promise(r => setTimeout(r, 30))
  313. agent.cancel({ kind: 'user' })
  314. await waitForIdle(ctx, agent)
  315. // The marker must have been reset after the cancelled turn — a fresh prompt
  316. // runs to completion rather than being dropped by a stale marker.
  317. send(agent, 'second')
  318. await waitForIdle(ctx, agent)
  319. expect(userTexts(agent)).toContain('second')
  320. // The second turn completed (its reply was streamed).
  321. const reasons = agent.session.events.filter(e => e.type === 'turn/end')
  322. expect(reasons.length).toBe(2)
  323. })
  324. it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
  325. const adapter = new MockAdapter([textResponse('should not stream')])
  326. const ctx = await harness(adapter)
  327. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  328. // A turn/start listener fires before a step controller exists, so the
  329. // turn-scoped marker—not step abort—must drop the pending step.
  330. let streamed = false
  331. ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
  332. const dispose = ctx.on('session/event', (session, event) => {
  333. if (session === agent.session && event.type === 'turn/start') agent.cancel({ kind: 'user' })
  334. })
  335. const reasons: TurnEndReason[] = []
  336. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  337. send(agent, 'go')
  338. await waitForIdle(ctx, agent)
  339. dispose()
  340. // No step streamed (the model never ran), and the turn ended aborted with
  341. // the caller's cause — the marker carries `cancel(cause)` through even
  342. // though no AbortController observed it in this window.
  343. expect(streamed).toBe(false)
  344. expect(reasons).toEqual([{ kind: 'aborted' }])
  345. })
  346. it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
  347. const adapter = new MockAdapter([textResponse('should not stream')])
  348. const ctx = await harness(adapter)
  349. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  350. // A step/start session-event listener fires AFTER step/start is appended
  351. // (and after the pre-step seam), so cancelling there lands in the SECOND
  352. // cancel check (the one that must closeStep() to balance the already-open
  353. // step) — distinct from a turn-start cancel, caught before the step opens.
  354. let streamed = false
  355. ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
  356. const dispose = ctx.on('session/event', (session, event) => {
  357. if (session === agent.session && event.type === 'step/start') agent.cancel({ kind: 'user' })
  358. })
  359. const reasons: TurnEndReason[] = []
  360. ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
  361. send(agent, 'go')
  362. await waitForIdle(ctx, agent)
  363. dispose()
  364. // No step streamed, the turn ended with the coarse aborted outcome, and the
  365. // log is balanced (the open step was closed by the cancel branch).
  366. expect(streamed).toBe(false)
  367. expect(reasons).toEqual([{ kind: 'aborted' }])
  368. const types = agent.session.events.map(e => e.type)
  369. expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
  370. })
  371. it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => {
  372. const adapter = new MockAdapter([textResponse('should not stream')])
  373. const ctx = new Context()
  374. await ctx.plugin(LlmService)
  375. await ctx.plugin(SessionStore)
  376. await ctx.plugin(SystemPrompt)
  377. await ctx.plugin(ToolRegistry)
  378. await ctx.plugin(AgentRegistry)
  379. await ctx.plugin(AgentLoop, { agents: [] })
  380. ctx.llm.registerAdapter(['mock'], adapter)
  381. const handle = await ctx.agents.create({
  382. sessionId: SessionId('dispose-step-start-session'),
  383. agentOptions: { provider: 'mock', model: 'mock' },
  384. })
  385. const agent = handle.agent
  386. let disposalDone: Promise<void> | undefined
  387. let streamed = false
  388. ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
  389. ctx.on('session/event', (session, event) => {
  390. if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose()
  391. })
  392. send(agent, 'go')
  393. await disposalDone
  394. await driverDone(agent)
  395. expect(streamed).toBe(false)
  396. expect(adapter.requests).toHaveLength(0)
  397. const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
  398. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
  399. const types = agent.session.events.map(e => e.type)
  400. expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
  401. })
  402. it('cancel during the stopping window ends the turn aborted and runs no further step', async () => {
  403. const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
  404. const ctx = await harness(adapter)
  405. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  406. let steps = 0
  407. const reasons: TurnEndReason[] = []
  408. ctx.on('session/event', (_session, event) => {
  409. if (event.type === 'step/start') steps += 1
  410. if (event.type === 'turn/end') reasons.push(event.data.reason)
  411. })
  412. let cancelled = false
  413. ctx.on('agent/turn-stopping', (subject) => {
  414. if (subject === agent && !cancelled) {
  415. cancelled = true
  416. agent.cancel({ kind: 'user' })
  417. }
  418. })
  419. send(agent, 'go')
  420. await waitForIdle(ctx, agent)
  421. // Only ONE step ran (the second was cancelled in the stopping window),
  422. // and the shared turn signal classified the durable outcome as aborted.
  423. expect(steps).toBe(1)
  424. expect(reasons).toEqual([{ kind: 'aborted' }])
  425. })
  426. it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
  427. const adapter = new MockAdapter([textResponse('should not run')])
  428. const ctx = await harness(adapter)
  429. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  430. // `agent/status` is synchronous, so cancellation can land after the first
  431. // pre-step check; the second check must drop the now-empty turn.
  432. let streamed = false
  433. ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
  434. const dispose = ctx.on('agent/status', (subject, status) => {
  435. if (subject === agent && status === 'running') agent.cancel({ kind: 'user' })
  436. })
  437. send(agent, 'go')
  438. await waitForIdle(ctx, agent)
  439. dispose()
  440. // No turn opened, no step streamed, and a later prompt still runs (the marker
  441. // was reset).
  442. expect(streamed).toBe(false)
  443. expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
  444. })
  445. it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
  446. // Cancellation must not settle idle while replacement work remains queued.
  447. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
  448. const ctx = await harness(adapter)
  449. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  450. let replaced = false
  451. const dispose = ctx.on('agent/status', (subject, status) => {
  452. if (subject !== agent || status !== 'running' || replaced) return
  453. replaced = true
  454. agent.cancel({ kind: 'user' })
  455. send(agent, 'B')
  456. })
  457. send(agent, 'A')
  458. const idle = agent.whenIdle()
  459. await idle
  460. dispose()
  461. // whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end
  462. // are in the log, and A was dropped.
  463. expect(userTexts(agent)).toContain('B')
  464. expect(userTexts(agent)).not.toContain('A')
  465. expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
  466. })
  467. it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
  468. // The subtle race: a whenIdle() waiter is registered for prompt A; cancel() clears A;
  469. // prompt B is queued before the loop resumes from the idle wait.
  470. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
  471. const ctx = await harness(adapter)
  472. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  473. send(agent, 'A') // queues A (status still idle, loop microtask pending)
  474. const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
  475. agent.cancel({ kind: 'user' }) // arms marker, clears A
  476. send(agent, 'B') // B races in before the loop resumes
  477. // whenIdle() must resolve only after B's turn fully ran — by which point B's user message
  478. // and a turn/end are in the log.
  479. await idle
  480. expect(userTexts(agent)).toContain('B')
  481. expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
  482. // A was dropped (never ran); only B's turn is recorded.
  483. expect(userTexts(agent)).not.toContain('A')
  484. })
  485. it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
  486. const adapter = new MockAdapter(['hang'])
  487. const ctx = await harness(adapter)
  488. const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
  489. send(agent, 'go')
  490. await new Promise(r => setTimeout(r, 30))
  491. expect(agent.status).toBe('running')
  492. // Steer (joins the running turn's steering FIFO), then cancel: the steering
  493. // must be dropped, NOT re-enqueued as a new queued turn.
  494. agent.steer({ content: [{ type: 'text', text: 'steer text' }], source: { kind: 'user' } })
  495. agent.cancel({ kind: 'user' })
  496. await waitForIdle(ctx, agent)
  497. // After the cancelled turn settles, the agent is idle with NO follow-up turn
  498. // started from the dropped steering.
  499. await new Promise(r => setTimeout(r, 30))
  500. expect(agent.status).toBe('idle')
  501. const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
  502. expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
  503. // The steering text was dropped — it never reached the log.
  504. const flat = agent.session.events
  505. .filter(e => e.type === 'steering/message')
  506. .flatMap(e => e.type === 'steering/message' ? e.data.content : [])
  507. .flatMap(b => b.type === 'text' ? [b.text] : [])
  508. expect(flat).not.toContain('steer text')
  509. })
  510. it('keeps replacement work queued synchronously by an abort observer', async () => {
  511. const adapter = new MockAdapter(['hang', textResponse('replacement reply')])
  512. const ctx = await harness(adapter)
  513. const agent = ctx.agentLoop.create(SessionId('abort-observer-replacement'), { provider: 'mock', model: 'mock' })
  514. send(agent, 'original')
  515. await expect.poll(() => adapter.requests.length).toBe(1)
  516. const signal = adapter.requests[0]?.signal
  517. if (signal === undefined) throw new Error('model request omitted its turn signal')
  518. signal.addEventListener('abort', () => { send(agent, 'replacement') }, { once: true })
  519. const idle = waitForIdle(ctx, agent)
  520. agent.cancel({ kind: 'user' })
  521. await Promise.race([
  522. idle,
  523. new Promise((_resolve, reject) => {
  524. setTimeout(() => {
  525. reject(new Error(`replacement did not settle: ${JSON.stringify({
  526. status: agent.status,
  527. requests: adapter.requests.length,
  528. users: userTexts(agent),
  529. events: agent.session.events.map(event => event.type),
  530. })}`))
  531. }, 1000)
  532. }),
  533. ])
  534. expect(adapter.requests).toHaveLength(2)
  535. expect(userTexts(agent)).toEqual(['original', 'replacement'])
  536. const reasons = agent.session.events
  537. .filter(event => event.type === 'turn/end')
  538. .map(event => event.type === 'turn/end' ? event.data.reason : undefined)
  539. expect(reasons).toEqual([{ kind: 'aborted' }, { kind: 'completed' }])
  540. })
  541. it('keeps the first typed cause for an active turn and detaches the runtime reason', async () => {
  542. const adapter = new MockAdapter(['hang'])
  543. const ctx = await harness(adapter)
  544. const agent = ctx.agentLoop.create(SessionId('typed-first-wins'), { provider: 'mock', model: 'mock' })
  545. const supplied: { kind: 'parent' | 'user' } = { kind: 'parent' }
  546. send(agent, 'go')
  547. await expect.poll(() => adapter.requests.length).toBe(1)
  548. agent.cancel(supplied)
  549. supplied.kind = 'user'
  550. agent.cancel({ kind: 'user' })
  551. await waitForIdle(ctx, agent)
  552. const runtimeReason: unknown = adapter.requests[0]?.signal?.reason
  553. expect(runtimeReason).toEqual({ kind: 'parent' })
  554. expect(runtimeReason).not.toBe(supplied)
  555. expect(Object.isFrozen(runtimeReason)).toBe(true)
  556. const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
  557. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
  558. })
  559. it('preserves the first user cancellation when lifecycle teardown races it', async () => {
  560. const adapter = new MockAdapter(['hang'])
  561. const ctx = await harness(adapter)
  562. const handle = await ctx.agents.create({
  563. sessionId: SessionId('cancel-dispose-race'),
  564. agentOptions: { provider: 'mock', model: 'mock' },
  565. })
  566. const { agent } = handle
  567. send(agent, 'go')
  568. await expect.poll(() => adapter.requests.length).toBe(1)
  569. agent.cancel({ kind: 'user' })
  570. await handle.dispose()
  571. const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
  572. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
  573. })
  574. it.each([
  575. 'prompt-submit',
  576. 'system-prompt',
  577. 'step',
  578. 'request',
  579. 'stopping',
  580. 'tool',
  581. ] as const)('lets a cooperative %s boundary settle from the explicit turn signal', async (stage) => {
  582. const adapter = new MockAdapter(stage === 'tool'
  583. ? [toolCallResponse('blocked-tool', 'blocked', {})]
  584. : [textResponse('done')])
  585. const ctx = await harness(adapter)
  586. const agent = ctx.agentLoop.create(SessionId(`cooperative-${stage}`), { provider: 'mock', model: 'mock' })
  587. const started = Promise.withResolvers<undefined>()
  588. const blockUntilAbort = async (signal: AbortSignal): Promise<void> => {
  589. started.resolve(undefined)
  590. if (signal.aborted) return
  591. await new Promise<void>((resolve) => {
  592. signal.addEventListener('abort', () => { resolve() }, { once: true })
  593. })
  594. }
  595. switch (stage) {
  596. case 'prompt-submit':
  597. ctx.on('agent/prompt-submit', async (subject, _content, _source, signal, next) => {
  598. if (subject === agent) await blockUntilAbort(signal)
  599. return next()
  600. })
  601. break
  602. case 'system-prompt':
  603. ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
  604. if (context.agent === agent) {
  605. if (context.signal === undefined) throw new Error('turn assembly omitted its signal')
  606. await blockUntilAbort(context.signal)
  607. }
  608. return next()
  609. })
  610. break
  611. case 'step':
  612. ctx.on('agent/step', async (subject, _turn, _step, signal) => {
  613. if (subject === agent) await blockUntilAbort(signal)
  614. })
  615. break
  616. case 'request':
  617. ctx.on('agent/request', async (subject, _turn, _step, signal, next) => {
  618. if (subject === agent) await blockUntilAbort(signal)
  619. return next()
  620. })
  621. break
  622. case 'stopping':
  623. ctx.on('agent/turn-stopping', async (subject, _turn, signal) => {
  624. if (subject === agent) await blockUntilAbort(signal)
  625. })
  626. break
  627. case 'tool':
  628. ctx.tools.register(defineContentToolFixture({
  629. name: 'blocked',
  630. description: 'wait for cancellation',
  631. parameters: {},
  632. execute: async (_args, exec) => {
  633. if (exec.signal === undefined) throw new Error('tool execution omitted its signal')
  634. await blockUntilAbort(exec.signal)
  635. return [{ type: 'text', text: 'cancelled' }]
  636. },
  637. }))
  638. break
  639. }
  640. send(agent, 'go')
  641. await started.promise
  642. const idle = agent.whenIdle()
  643. agent.cancel({ kind: 'user' })
  644. await idle
  645. const turnEnd = agent.session.events.findLast(event => event.type === 'turn/end')
  646. if (stage === 'prompt-submit') {
  647. expect(turnEnd).toBeUndefined()
  648. } else {
  649. expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'aborted' })
  650. }
  651. await ctx.fiber.dispose()
  652. })
  653. })