tool-workflow.spec.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import Loader from '@deepseek-ai/cordis-plugin-loader'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRuntime, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
  6. import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { WorkflowRunId, WorkflowEngine } from '@deepseek-ai/dsh-workflow'
  9. import type {
  10. WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
  11. WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest,
  12. } from '@deepseek-ai/dsh-workflow'
  13. import { ToolCallId } from '@deepseek-ai/dsh-llm'
  14. import SubagentRuntime from '@deepseek-ai/dsh-subagent'
  15. import PtcWorkflowEngine from '@deepseek-ai/dsh-workflow-ptc'
  16. import { mountWorkflowRuntime } from '../../workflow-ptc/tests/setup.ts'
  17. import * as toolWorkflow from '../src/index.ts'
  18. import { Session, SessionId } from '@deepseek-ai/dsh-session'
  19. import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
  20. const testToolSignal = new AbortController().signal
  21. /** A controllable engine standing in behind ctx.workflowEngine (the tool's only seam). */
  22. class StubEngine extends WorkflowEngine {
  23. requests: WorkflowStartRequest[] = []
  24. cancels: string[] = []
  25. disposed = 0
  26. disposeBarrier: Promise<void> | undefined
  27. settle!: (result: WorkflowResult) => void
  28. readonly settlements = new Map<WorkflowRunIdType, (result: WorkflowResult) => void>()
  29. startError: Error | undefined
  30. start(request: WorkflowStartRequest): WorkflowRun {
  31. if (this.startError) throw this.startError
  32. this.requests.push(request)
  33. const id = WorkflowRunId(`run-${this.requests.length}`)
  34. const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
  35. this.settlements.set(id, this.settle)
  36. request.signal?.addEventListener('abort', () => {
  37. this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
  38. }, { once: true })
  39. return {
  40. id,
  41. meta: request.meta,
  42. result,
  43. cancel: (reason?: string) => {
  44. this.cancels.push(reason ?? 'cancelled')
  45. this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
  46. },
  47. dispose: async () => {
  48. this.disposed += 1
  49. await this.disposeBarrier
  50. this.settlements.delete(id)
  51. },
  52. }
  53. }
  54. settleRun(id: WorkflowRunIdType, result: WorkflowResult): void {
  55. const settle = this.settlements.get(id)
  56. if (settle === undefined) throw new Error(`unknown stub workflow ${id}`)
  57. settle(result)
  58. }
  59. agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void {
  60. this.emitWorkflowEvent('workflow/agent-start', {
  61. id,
  62. meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
  63. }, agent)
  64. }
  65. agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void {
  66. this.emitWorkflowEvent('workflow/agent-end', {
  67. id,
  68. meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
  69. }, agent)
  70. }
  71. }
  72. async function setup(config?: { toolName?: string; maxResultChars?: number }) {
  73. const ctx = new Context()
  74. await ctx.plugin(SystemPrompt)
  75. await ctx.plugin(ToolRuntime)
  76. await ctx.plugin(StubEngine)
  77. await ctx.plugin(toolWorkflow, config ?? {})
  78. const engine = ctx.workflowEngine as StubEngine
  79. const session = Session.create(SessionId('caller'))
  80. const parent = { id: session.id, options: {}, session } as unknown as Agent
  81. return { ctx, engine, parent, session }
  82. }
  83. const SCRIPT = 'return 1'
  84. const META = { name: 'audit', description: 'd' }
  85. function execute(ctx: Context, args: unknown, extra?: {
  86. agent?: Agent
  87. signal?: AbortSignal
  88. parent?: ToolExecutionToken
  89. }): Promise<ToolExecutionResult> {
  90. return ctx.tools.execute({
  91. signal: testToolSignal,
  92. callId: ToolCallId('call-1'),
  93. name: 'workflow',
  94. arguments: args,
  95. ...extra?.agent ? { agent: extra.agent } : {},
  96. ...extra?.signal ? { signal: extra.signal } : {},
  97. ...extra?.parent ? { parent: extra.parent } : {},
  98. })
  99. }
  100. describe('dsh-tool-workflow', () => {
  101. it('starts a run with the script/args/parent/signal and renders the completed value', async () => {
  102. const { ctx, engine, parent } = await setup()
  103. const controller = new AbortController()
  104. const pending = execute(ctx, { script: SCRIPT, meta: META, args: { files: ['a.ts'] } }, { agent: parent, signal: controller.signal })
  105. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  106. expect(engine.requests[0]).toMatchObject({ script: SCRIPT, meta: META, args: { files: ['a.ts'] }, parent })
  107. expect(engine.requests[0]!.signal).toBe(controller.signal)
  108. engine.settle({ value: { findings: [1, 2] }, stopReason: 'completed', agentsStarted: 7 })
  109. const result = await pending
  110. expect(result.isError).toBe(false)
  111. if (result.isError) throw new Error('expected workflow success')
  112. expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 7, result: { findings: [1, 2] } })
  113. const rendered = (result.content[0] as { text: string }).text
  114. expect(rendered).toContain('workflow "audit" completed (7 agents)')
  115. expect(rendered).toContain('"findings"')
  116. expect(engine.disposed).toBe(1)
  117. })
  118. it('records one top-level run and its members in the calling Session after cleanup', async () => {
  119. const { ctx, engine, parent, session } = await setup()
  120. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  121. await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
  122. const runId = WorkflowRunId('run-1')
  123. engine.agentStart(runId, {
  124. seq: 1,
  125. label: '',
  126. phase: '',
  127. childId: SessionId('child-1'),
  128. })
  129. engine.agentEnd(runId, {
  130. seq: 1,
  131. label: '',
  132. phase: '',
  133. childId: SessionId('child-1'),
  134. outcome: 'completed',
  135. })
  136. engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 })
  137. expect((await pending).isError).toBe(false)
  138. expect(engine.disposed).toBe(1)
  139. expect(session.snapshotEvents().map(event => [event.type, event.data])).toEqual([
  140. ['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }],
  141. ['tool-workflow/agent-start', {
  142. runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1',
  143. }],
  144. ['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }],
  145. ['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }],
  146. ])
  147. })
  148. it('writes run-end only after run disposal reaches quiescence', async () => {
  149. const { ctx, engine, parent, session } = await setup()
  150. const barrier = Promise.withResolvers<undefined>()
  151. engine.disposeBarrier = barrier.promise
  152. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  153. await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
  154. engine.settleRun(WorkflowRunId('run-1'), {
  155. value: null, stopReason: 'completed', agentsStarted: 0,
  156. })
  157. await vi.waitFor(() => { expect(engine.disposed).toBe(1) })
  158. expect(session.snapshotEvents().map(event => event.type)).toEqual(['tool-workflow/run-start'])
  159. barrier.resolve(undefined)
  160. expect((await pending).isError).toBe(false)
  161. expect(session.snapshotEvents().map(event => event.type)).toEqual([
  162. 'tool-workflow/run-start', 'tool-workflow/run-end',
  163. ])
  164. })
  165. it('records zero-member and concurrent runs independently', async () => {
  166. const { ctx, engine, parent, session } = await setup()
  167. const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent })
  168. const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent })
  169. await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
  170. const secondId = WorkflowRunId('run-2')
  171. engine.agentStart(secondId, {
  172. seq: 1, label: 'member', childId: SessionId('child-2'),
  173. })
  174. engine.agentEnd(secondId, {
  175. seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed',
  176. })
  177. engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
  178. engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 })
  179. expect((await first).isError).toBe(false)
  180. expect((await second).isError).toBe(true)
  181. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/agent-start'))
  182. .toHaveLength(1)
  183. expect(session.snapshotEvents().filter(event => event.type === 'tool-workflow/run-end').map(event => event.data))
  184. .toEqual([
  185. { runId: 'run-1', stopReason: 'completed' },
  186. { runId: 'run-2', stopReason: 'error' },
  187. ])
  188. })
  189. it('does not record nested transport executions', async () => {
  190. const { ctx, engine, parent, session } = await setup()
  191. const pending = execute(ctx, { script: SCRIPT, meta: META }, {
  192. agent: parent,
  193. parent: Symbol('outer') as ToolExecutionToken,
  194. })
  195. await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
  196. engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
  197. expect((await pending).isError).toBe(false)
  198. expect(session.snapshotEvents()).toEqual([])
  199. })
  200. it.each([
  201. 'tool-workflow/run-start',
  202. 'tool-workflow/agent-start',
  203. 'tool-workflow/agent-end',
  204. 'tool-workflow/run-end',
  205. ] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => {
  206. const { ctx, engine, parent, session } = await setup()
  207. const warnings: string[] = []
  208. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  209. const append = session.append.bind(session)
  210. session.append = ((type: Parameters<Session['append']>[0], data: never) => {
  211. if (type === failedType) throw new Error(`injected ${failedType} failure`)
  212. return append(type, data)
  213. }) as Session['append']
  214. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  215. await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
  216. const runId = WorkflowRunId('run-1')
  217. engine.agentStart(runId, {
  218. seq: 1, label: 'member', childId: SessionId('child-1'),
  219. })
  220. engine.agentEnd(runId, {
  221. seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed',
  222. })
  223. engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 })
  224. expect((await pending).isError).toBe(false)
  225. expect(engine.disposed).toBe(1)
  226. expect(warnings).toHaveLength(1)
  227. expect(warnings[0]).toContain(failedType)
  228. const types = session.snapshotEvents().map(event => event.type)
  229. const expectedPrefixes = {
  230. 'tool-workflow/run-start': [],
  231. 'tool-workflow/agent-start': ['tool-workflow/run-start'],
  232. 'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'],
  233. 'tool-workflow/run-end': [
  234. 'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end',
  235. ],
  236. } as const
  237. expect(types).toEqual(expectedPrefixes[failedType])
  238. })
  239. it('contains an append failure whose thrown value cannot be rendered', async () => {
  240. const { ctx, engine, parent, session } = await setup()
  241. const warnings: string[] = []
  242. ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
  243. session.append = () => {
  244. throw { toString: () => { throw new Error('coercion trap') } }
  245. }
  246. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  247. await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
  248. engine.settleRun(WorkflowRunId('run-1'), {
  249. value: null, stopReason: 'completed', agentsStarted: 0,
  250. })
  251. expect((await pending).isError).toBe(false)
  252. expect(warnings).toHaveLength(1)
  253. expect(warnings[0]).toContain('[unrenderable thrown value]')
  254. })
  255. it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
  256. const { ctx, engine, parent } = await setup()
  257. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  258. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  259. engine.settle({ value: null, stopReason: 'error', error: 'script threw: boom', agentsStarted: 2 })
  260. const result = await pending
  261. expect(result.isError).toBe(true)
  262. expect((result.content[0] as { text: string }).text).toContain('workflow run failed: script threw: boom')
  263. expect(engine.disposed).toBe(1)
  264. })
  265. it('reports a cancelled run distinctly (with and without a reason)', async () => {
  266. const { ctx, engine, parent } = await setup()
  267. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  268. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  269. engine.settle({ value: null, stopReason: 'cancelled', error: 'user', agentsStarted: 0 })
  270. const result = await pending
  271. expect(result.isError).toBe(true)
  272. expect((result.content[0] as { text: string }).text).toContain('workflow run was cancelled (user)')
  273. const bare = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  274. await vi.waitFor(() => { expect(engine.requests.length).toBe(2) })
  275. engine.settle({ value: null, stopReason: 'cancelled', agentsStarted: 0 })
  276. expect(((await bare).content[0] as { text: string }).text.trim().endsWith('cancelled')).toBe(true)
  277. })
  278. it('an error result without a message renders the unknown-error fallback', async () => {
  279. const { ctx, engine, parent } = await setup()
  280. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  281. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  282. engine.settle({ value: null, stopReason: 'error', agentsStarted: 0 })
  283. expect(((await pending).content[0] as { text: string }).text).toContain('unknown error')
  284. })
  285. it('cancels the run when exec.signal aborts MID-FLIGHT (the abort bridge)', async () => {
  286. const { ctx, engine, parent } = await setup()
  287. const controller = new AbortController()
  288. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
  289. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  290. controller.abort()
  291. const result = await pending
  292. expect(result.isError).toBe(true)
  293. expect(engine.cancels).toContain('parent step aborted')
  294. expect(engine.disposed).toBe(1)
  295. })
  296. it('a synchronous engine start throw (meta/parse failure) becomes an isError result', async () => {
  297. const { ctx, engine, parent } = await setup()
  298. engine.startError = new Error('invalid meta: meta.name must be a non-empty string')
  299. const result = await execute(ctx, { script: 'nope', meta: { name: '', description: 'd' } }, { agent: parent })
  300. expect(result.isError).toBe(true)
  301. expect((result.content[0] as { text: string }).text).toContain('meta.name must be a non-empty string')
  302. })
  303. it('requires a calling agent (fails loud without exec.agent)', async () => {
  304. const { ctx, engine } = await setup()
  305. const result = await execute(ctx, { script: SCRIPT, meta: META })
  306. expect(result.isError).toBe(true)
  307. expect((result.content[0] as { text: string }).text).toContain('requires a calling agent')
  308. expect(engine.requests.length).toBe(0)
  309. })
  310. it('validates its own arguments via the schema DSL (missing script)', async () => {
  311. const { ctx, parent } = await setup()
  312. const result = await execute(ctx, {}, { agent: parent })
  313. expect(result.isError).toBe(true)
  314. expect(result.error?.info?.code).toBe('INVALID_ARGS')
  315. })
  316. it('skips workflow startup when exec.signal is already aborted', async () => {
  317. const { ctx, engine, parent } = await setup()
  318. const controller = new AbortController()
  319. controller.abort()
  320. const result = await execute(ctx, { script: SCRIPT, meta: META }, { agent: parent, signal: controller.signal })
  321. expect(result.isError).toBe(true)
  322. expect(result.error).toEqual({
  323. message: 'tool call aborted before dispatch',
  324. info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
  325. })
  326. expect(engine.requests).toHaveLength(0)
  327. expect(engine.cancels).toHaveLength(0)
  328. expect(engine.disposed).toBe(0)
  329. })
  330. it('truncates an oversized rendered value with a notice (maxResultChars)', async () => {
  331. const { ctx, engine, parent } = await setup({ maxResultChars: 40 })
  332. const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
  333. await vi.waitFor(() => { expect(engine.requests.length).toBe(1) })
  334. engine.settle({ value: { blob: 'x'.repeat(500) }, stopReason: 'completed', agentsStarted: 1 })
  335. const result = await pending
  336. if (result.isError) throw new Error('expected workflow success')
  337. expect(result.value).toEqual({ runId: 'run-1', agentsStarted: 1, result: { blob: 'x'.repeat(500) } })
  338. const rendered = (result.content[0] as { text: string }).text
  339. expect(rendered).toContain('[truncated:')
  340. expect(rendered.length).toBeLessThan(400)
  341. })
  342. it('registers under a configured toolName and unregisters on fiber dispose (HMR safety)', async () => {
  343. const ctx = new Context()
  344. await ctx.plugin(SystemPrompt)
  345. await ctx.plugin(ToolRuntime)
  346. await ctx.plugin(StubEngine)
  347. const fiber = await ctx.plugin(toolWorkflow, { toolName: 'orchestrate' })
  348. expect(ctx.tools.get('orchestrate')).toBeDefined()
  349. expect(ctx.tools.get('workflow')).toBeUndefined()
  350. // The usage-policy prompt section rides the same registration: present
  351. // under the CONFIGURED name (its guidance names the tool it describes)…
  352. const sections = (await ctx.systemPrompt.assemble()).sections
  353. const section = sections.find(s => s.name === 'tool:orchestrate')
  354. expect(section?.text).toContain('orchestrate')
  355. expect(sections.some(s => s.name === 'tool:workflow')).toBe(false)
  356. ctx.systemPrompt.section({ name: 'tool:cordis-order-probe', order: 115.5, text: 'Cordis' })
  357. expect((await ctx.systemPrompt.assemble()).sections
  358. .filter(s => s.name === 'tool:cordis-order-probe' || s.name === 'tool:orchestrate')
  359. .map(s => s.name)).toEqual(['tool:cordis-order-probe', 'tool:orchestrate'])
  360. await fiber.dispose()
  361. expect(ctx.tools.get('orchestrate')).toBeUndefined()
  362. // …and gone with the fiber — a reload must not leak a stale section.
  363. expect((await ctx.systemPrompt.assemble()).sections.some(s => s.name === 'tool:orchestrate')).toBe(false)
  364. })
  365. it('presents a generic pending card titled by the meta name, with the script as rawInput', async () => {
  366. const { ctx } = await setup()
  367. const tool = ctx.tools.get('workflow')!
  368. const view = tool.presentCall!({ script: SCRIPT, meta: META })
  369. expect(view).toMatchObject({ card: 'generic', title: 'workflow: audit', rawInput: SCRIPT })
  370. })
  371. it('presentResult keeps the generic card; presentation is pure and replay-safe on malformed args', async () => {
  372. const { ctx } = await setup()
  373. const tool = ctx.tools.get('workflow')!
  374. expect(tool.presentResult!({ script: SCRIPT, meta: META }, { content: [], isError: false })).toEqual({ card: 'generic' })
  375. // defineTool soft-validates presentation args: a malformed logged shape
  376. // (wrong fields entirely, or a call missing its meta) falls back to
  377. // undefined instead of throwing mid-replay.
  378. expect(tool.presentCall!({ not: 'the schema' })).toBeUndefined()
  379. expect(tool.presentCall!({ script: SCRIPT })).toBeUndefined()
  380. })
  381. it('has the namespace-plugin export shape (no stray default)', () => {
  382. expect('default' in toolWorkflow).toBe(false)
  383. expect(toolWorkflow.name).toBe('tool-workflow')
  384. expect(toolWorkflow.inject).toEqual(['tools', 'workflowEngine', 'systemPrompt'])
  385. const loader = Object.create(Loader.prototype) as Loader
  386. const unwrapped = loader.unwrapExports(toolWorkflow) as Record<string, unknown>
  387. expect(unwrapped).toBe(toolWorkflow)
  388. expect(typeof unwrapped.apply).toBe('function')
  389. })
  390. describe('composition with the sandboxed PTC workflow engine', () => {
  391. it('an abort releases the tool even when the script parks on a promise no hook owns', async () => {
  392. const ctx = new Context()
  393. onTestFinished(async () => { await ctx.fiber.dispose() })
  394. await ctx.plugin(SystemPrompt)
  395. await ctx.plugin(ToolRuntime)
  396. await ctx.plugin(SessionProjectionRegistry)
  397. await ctx.plugin(SubagentRuntime)
  398. ctx.subagents.registerProvider({
  399. name: 'spawn',
  400. capabilities: { agentOptions: true, outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
  401. inheritsParentContext: false,
  402. start: () => Promise.reject(new Error('the parked-script fixture must not start a child')),
  403. })
  404. await mountWorkflowRuntime(ctx)
  405. await ctx.plugin(PtcWorkflowEngine, {})
  406. await ctx.plugin(toolWorkflow, {})
  407. const session = Session.create(SessionId('caller'))
  408. const parent = { id: session.id, options: {}, session } as unknown as Agent
  409. const controller = new AbortController()
  410. const ready = Promise.withResolvers<undefined>()
  411. ctx.on('workflow/log', () => { ready.resolve(undefined) })
  412. const pending = execute(ctx, {
  413. script: 'log("ready"); await new Promise(() => {})\nreturn 1',
  414. meta: { name: 'stuck', description: 'parks forever' },
  415. }, { agent: parent, signal: controller.signal })
  416. await ready.promise
  417. controller.abort('user abort')
  418. const result = await pending
  419. expect(result.isError).toBe(true)
  420. expect((result.content[0] as { text: string }).text).toContain('cancelled')
  421. })
  422. })
  423. })