tool-tasks.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { CallId } from '@deepseek-ai/dsh-llm'
  4. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  5. import ToolRegistry from '@deepseek-ai/dsh-tools'
  6. import AgentRegistry, { emitAgentEvent } from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { createUserMessage } from '@deepseek-ai/dsh-llm'
  9. import { SessionId } from '@deepseek-ai/dsh-session'
  10. import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope'
  11. import { TaskId } from '@deepseek-ai/dsh-tasks'
  12. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  13. import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
  14. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  15. import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
  16. const testToolSignal = new AbortController().signal
  17. const agentRegistryDisposers = new WeakMap<Agent, () => void>()
  18. const agentScopeFibers = new WeakMap<Agent, { dispose: () => Promise<void> }>()
  19. async function setup(config: ToolTasks.Config = {}) {
  20. const ctx = new Context()
  21. await ctx.plugin(SystemPrompt)
  22. await ctx.plugin(ToolRegistry)
  23. const agentsFiber = await ctx.plugin(AgentRegistry)
  24. await ctx.plugin(LocalTaskService)
  25. const toolsFiber = await ctx.plugin(ToolTasks, config)
  26. return { ctx, agentsFiber, toolsFiber }
  27. }
  28. /** The delivery surface a completion notice may reach on a fake owner. */
  29. interface FakeDelivery {
  30. inject?: (...args: unknown[]) => void
  31. followup?: (...args: unknown[]) => void
  32. /** Defaults to `running`, the lane that never wakes, so notice-content tests pin one lane. */
  33. status?: 'idle' | 'running'
  34. }
  35. /**
  36. * A fake agent with the shared agent/session identity, registered in
  37. * `ctx.agents` with a dedicated lifecycle scope.
  38. */
  39. function fakeAgent(ctx: Context, sessionId: string, delivery: FakeDelivery = {}): Agent {
  40. const scopeFiber = ctx.plugin(() => {})
  41. const id = SessionId(sessionId)
  42. const agent = {
  43. id,
  44. ctx: scopeFiber.ctx,
  45. inject: delivery.inject ?? (() => {}),
  46. followup: delivery.followup ?? (() => {}),
  47. status: delivery.status ?? 'running',
  48. session: { id, header: { version: 0, id, createdAt: 0 } },
  49. } as unknown as Agent
  50. agentRegistryDisposers.set(agent, ctx.agents.register(agent))
  51. agentScopeFibers.set(agent, scopeFiber)
  52. return agent
  53. }
  54. function detachAgent(agent: Agent): void {
  55. const dispose = agentRegistryDisposers.get(agent)
  56. if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
  57. dispose()
  58. }
  59. /** Dispose the agent's own lifecycle scope, which is what drains its owned tasks. */
  60. async function disposeAgentScope(agent: Agent): Promise<void> {
  61. const fiber = agentScopeFibers.get(agent)
  62. if (fiber === undefined) throw new Error(`missing scope fiber for agent "${agent.id}"`)
  63. await fiber.dispose()
  64. }
  65. /** A controllable producer start-spec (settle `done` on demand, record cancels). */
  66. function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
  67. let settle!: (outcome: TaskOutcome) => void
  68. const cancels: (string | undefined)[] = []
  69. const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
  70. const hooks: TaskHooks = {
  71. cancel(reason) { cancels.push(reason) },
  72. done: new Promise<TaskOutcome>((res) => { settle = res }),
  73. ...hookOverrides,
  74. }
  75. const spec: TaskStart = {
  76. kind,
  77. label,
  78. ...owner !== undefined ? { owner } : {},
  79. ...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
  80. run: () => hooks,
  81. }
  82. return { spec, settle, cancels }
  83. }
  84. let callCounter = 0
  85. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  86. return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
  87. }
  88. function text(result: { content: { type: string; text?: string }[] }): string {
  89. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  90. }
  91. const tick = () => new Promise<void>(r => setTimeout(r, 0))
  92. /** Start and settle `count` owned tasks one at a time, letting each notice land. */
  93. async function settleTasks(ctx: Context, owner: Agent, count: number): Promise<void> {
  94. for (let i = 0; i < count; i += 1) {
  95. const p = producer({ owner })
  96. ctx.tasks.start(p.spec)
  97. p.settle({ status: 'completed' })
  98. await tick()
  99. }
  100. }
  101. describe('tool-tasks setup', () => {
  102. it('attaches the task controller on load and detaches it with the fiber', async () => {
  103. const { ctx, toolsFiber } = await setup()
  104. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  105. await toolsFiber.dispose()
  106. expect(() => ctx.tasks.start(producer().spec)).toThrow('no task controller serves this agent')
  107. })
  108. it('rejects a config whose default wait exceeds the cap', async () => {
  109. const ctx = new Context()
  110. await ctx.plugin(SystemPrompt)
  111. await ctx.plugin(ToolRegistry)
  112. await ctx.plugin(LocalTaskService)
  113. await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
  114. .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
  115. })
  116. it('defaults delivery to wakeup and rejects an unknown lane', () => {
  117. expect(ToolTasks.Config({}).completionDelivery).toBe('wakeup')
  118. expect(ToolTasks.Config({}).maxConsecutiveWakes).toBe(3)
  119. expect(() => ToolTasks.Config({ completionDelivery: 'loud' as never })).toThrow()
  120. expect(() => ToolTasks.Config({ maxConsecutiveWakes: 0 })).toThrow()
  121. })
  122. it('rejects a wake budget that cannot bound anything', async () => {
  123. // Reports the load outcome as text: a resolved fiber is not safely printable.
  124. const loadWith = async (maxConsecutiveWakes: number): Promise<string> => {
  125. const ctx = new Context()
  126. await ctx.plugin(SystemPrompt)
  127. await ctx.plugin(ToolRegistry)
  128. await ctx.plugin(LocalTaskService)
  129. try {
  130. await ctx.plugin(ToolTasks, { maxConsecutiveWakes })
  131. return 'loaded'
  132. } catch (error: unknown) {
  133. return String(error)
  134. }
  135. }
  136. // The field exists to bound runaway waking; a fractional budget counts
  137. // nothing and an infinite one removes the bound it was configured for.
  138. expect(await loadWith(Number.POSITIVE_INFINITY)).toContain('maxConsecutiveWakes')
  139. expect(await loadWith(2.5)).toContain('maxConsecutiveWakes')
  140. expect(await loadWith(1)).toBe('loaded')
  141. })
  142. it('renders status lines with and without producer detail', () => {
  143. const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
  144. expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
  145. expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
  146. })
  147. it('applies the built-in wait bounds when apply() receives a bare config', async () => {
  148. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  149. // own `??` fallbacks when embedded programmatically without the schema.
  150. const ctx = new Context()
  151. await ctx.plugin(SystemPrompt)
  152. await ctx.plugin(ToolRegistry)
  153. await ctx.plugin(LocalTaskService)
  154. ToolTasks.apply(ctx, {})
  155. expect(ctx.tools.get('task_output')).toBeDefined()
  156. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  157. })
  158. })
  159. describe('task_output', () => {
  160. it('reads a consuming delta with a trailing status line', async () => {
  161. const { ctx } = await setup()
  162. const chunks = ['line one\n', '']
  163. ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
  164. // A body already ending in a newline gets no doubled separator.
  165. const first = await call(ctx, 'task_output', { task_id: 'bash-1' })
  166. if (first.isError) throw new Error('expected task_output success')
  167. const firstValue = first.value as { text: string; task: Record<string, unknown> }
  168. expect(firstValue).toMatchObject({
  169. text: 'line one\n',
  170. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
  171. })
  172. expect(firstValue.task).not.toHaveProperty('ownerSession')
  173. expect(firstValue.task).not.toHaveProperty('reported')
  174. expect(text(first)).toBe('line one\n[status: running]')
  175. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
  176. })
  177. it('returns the final output of a settled final-output task', async () => {
  178. const { ctx } = await setup()
  179. const p = producer({ kind: 'subagent', label: 'research' })
  180. ctx.tasks.start(p.spec)
  181. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
  182. p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
  183. await tick()
  184. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
  185. })
  186. it('applies a producer limit to the complete body and status result', async () => {
  187. const { ctx } = await setup()
  188. ctx.tasks.start(producer({
  189. outputLimitBytes: 48,
  190. readOutput: () => '界'.repeat(100),
  191. }).spec)
  192. const output = text(await call(ctx, 'task_output', { task_id: 'bash-1' }))
  193. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
  194. expect(output).toContain('[status: running]')
  195. })
  196. it('preserves empty and newline-terminated output under a producer limit', async () => {
  197. const { ctx } = await setup()
  198. const chunks = ['', 'line\n']
  199. ctx.tasks.start(producer({
  200. outputLimitBytes: 64,
  201. readOutput: () => chunks.shift() ?? '',
  202. }).spec)
  203. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
  204. .toBe('(no new output)\n[status: running]')
  205. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
  206. .toBe('line\n[status: running]')
  207. })
  208. it('bounds post-policy output without restoring the canonical status rendering', async () => {
  209. const { ctx } = await setup()
  210. ctx.tasks.start(producer({
  211. outputLimitBytes: 64,
  212. readOutput: () => 'canonical output',
  213. }).spec)
  214. ctx.on('tools/post-execute', (exec, _result, next) => {
  215. if (exec.name !== 'task_output') return next()
  216. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] })
  217. })
  218. const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
  219. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  220. expect(text(result)).toContain('[result truncated]')
  221. expect(text(result)).not.toContain('[status: running]')
  222. })
  223. it('applies a producer limit to a normalized read failure', async () => {
  224. const { ctx } = await setup()
  225. ctx.tasks.start(producer({
  226. outputLimitBytes: 64,
  227. readOutput: () => { throw new Error('read failed: '.repeat(100)) },
  228. }).spec)
  229. const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
  230. expect(result.isError).toBe(true)
  231. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  232. expect(text(result)).toContain('[result truncated]')
  233. })
  234. it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => {
  235. const { ctx } = await setup()
  236. for (let index = 0; index < 5; index += 1) {
  237. ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
  238. }
  239. ctx.on('tools/pre-execute', async (exec, next) => {
  240. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  241. if (taskId === 'bash-1') return { kind: 'deny', reason: 'd'.repeat(1_000) }
  242. if (taskId === 'bash-3') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
  243. return next()
  244. })
  245. ctx.on('tools/execute', async (exec, next) => {
  246. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  247. if (taskId === 'bash-2') {
  248. return {
  249. content: [],
  250. isError: false,
  251. value: {
  252. text: 'a'.repeat(1_000),
  253. task: {
  254. id: 'bash-2', kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
  255. },
  256. },
  257. }
  258. }
  259. if (taskId === 'bash-4') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
  260. return next()
  261. })
  262. ctx.on('tools/post-execute', async (exec, _result, next) => {
  263. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  264. if (taskId === 'bash-5') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
  265. return next()
  266. })
  267. const denied = await call(ctx, 'task_output', { task_id: 'bash-1' })
  268. expect(denied.isError).toBe(true)
  269. expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
  270. expect(text(denied)).toContain('[result truncated]')
  271. const shortCircuited = await call(ctx, 'task_output', { task_id: 'bash-2' })
  272. expect(shortCircuited.isError).toBe(false)
  273. expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
  274. expect(text(shortCircuited)).toContain('[output truncated]')
  275. const failures = [
  276. await call(ctx, 'task_output', { task_id: 'bash-3' }),
  277. await call(ctx, 'task_output', { task_id: 'bash-4' }),
  278. await call(ctx, 'task_output', { task_id: 'bash-5' }),
  279. ]
  280. for (const failure of failures) {
  281. expect(failure.isError).toBe(true)
  282. expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
  283. expect(text(failure)).toContain('[result truncated]')
  284. }
  285. })
  286. it('wait: true blocks until settlement and reports the terminal state', async () => {
  287. const { ctx } = await setup()
  288. const p = producer({ kind: 'subagent', label: 'research' })
  289. ctx.tasks.start(p.spec)
  290. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
  291. p.settle({ status: 'completed', output: 'done deal' })
  292. expect(text(await pending)).toBe('done deal\n[status: completed]')
  293. })
  294. it('wait: true times out against the configured cap and leaves the task alive', async () => {
  295. const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
  296. ctx.tasks.start(producer().spec)
  297. // A model-supplied timeout far above the cap is clamped: this returns
  298. // promptly (≤ the 20ms cap), not after ten minutes.
  299. const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
  300. expect(text(result)).toBe('(no new output)\n[status: running]')
  301. })
  302. it('rejects an empty or unknown task id as an errored result', async () => {
  303. const { ctx } = await setup()
  304. expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
  305. const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
  306. expect(unknown.isError).toBe(true)
  307. expect(text(unknown)).toContain('unknown task bash-99')
  308. })
  309. })
  310. describe('task_list', () => {
  311. it('lists caller-visible tasks and renders the empty case', async () => {
  312. const { ctx } = await setup()
  313. expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
  314. const alice = fakeAgent(ctx, 'sess-alice')
  315. ctx.tasks.start(producer({ owner: alice, label: 'pnpm test' }).spec)
  316. ctx.tasks.start(producer({ kind: 'subagent', label: 'open research' }).spec)
  317. const p = producer({ owner: alice, label: 'build' })
  318. ctx.tasks.start(p.spec)
  319. p.settle({ status: 'completed', detail: 'exit code: 0' })
  320. await tick()
  321. const listed = await call(ctx, 'task_list', {}, alice)
  322. if (listed.isError) throw new Error('expected task_list success')
  323. const listedValue = listed.value as Array<Record<string, unknown>>
  324. expect(listedValue).toHaveLength(3)
  325. expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
  326. expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
  327. for (const task of listedValue) {
  328. expect(task).not.toHaveProperty('ownerSession')
  329. expect(task).not.toHaveProperty('reported')
  330. }
  331. expect(text(listed)).toBe([
  332. 'bash-1 [bash] running — pnpm test',
  333. 'subagent-1 [subagent] running — open research',
  334. 'bash-2 [bash] completed — build',
  335. ].join('\n'))
  336. // A different caller sees only the unowned task.
  337. const bob = fakeAgent(ctx, 'sess-bob')
  338. expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
  339. })
  340. })
  341. describe('task_kill', () => {
  342. it('requests cancellation with the forwarded reason', async () => {
  343. const { ctx } = await setup()
  344. const p = producer()
  345. ctx.tasks.start(p.spec)
  346. const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
  347. if (result.isError) throw new Error('expected task_kill success')
  348. const killValue = result.value as { outcome: string; task: Record<string, unknown> }
  349. expect(killValue).toMatchObject({
  350. outcome: 'cancellation-requested',
  351. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
  352. })
  353. expect(killValue.task).not.toHaveProperty('ownerSession')
  354. expect(killValue.task).not.toHaveProperty('reported')
  355. expect(text(result)).toBe('requested cancellation of task bash-1')
  356. expect(p.cancels).toEqual(['superseded'])
  357. })
  358. it('applies the producer output limit to a cancellation acknowledgement', async () => {
  359. const { ctx } = await setup()
  360. const p = producer({ outputLimitBytes: 8 })
  361. ctx.tasks.start(p.spec)
  362. const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  363. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(8)
  364. expect(p.cancels).toEqual([undefined])
  365. })
  366. it('applies the producer output limit to a normalized cancellation failure', async () => {
  367. const { ctx } = await setup()
  368. ctx.tasks.start(producer({
  369. outputLimitBytes: 64,
  370. cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
  371. }).spec)
  372. const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  373. expect(result.isError).toBe(true)
  374. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  375. expect(text(result)).toContain('[result truncated]')
  376. expect(ctx.tasks.get(TaskId('bash-1'))).toMatchObject({ status: 'running', reported: false })
  377. })
  378. it('bounds single-text post policy while preserving structured policy results', async () => {
  379. const { ctx } = await setup()
  380. ctx.on('tools/post-execute', (exec, _result, next) => {
  381. if (exec.name !== 'task_kill') return next()
  382. const reason = (exec.arguments as { reason?: unknown }).reason
  383. if (reason === 'replace') {
  384. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'r'.repeat(1_000) }] })
  385. }
  386. if (reason === 'block') {
  387. return Promise.resolve({ kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] })
  388. }
  389. if (reason === 'multi') {
  390. return Promise.resolve({
  391. kind: 'block',
  392. feedback: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
  393. })
  394. }
  395. if (reason === 'reasoning') {
  396. return Promise.resolve({ kind: 'block', feedback: [{ type: 'reasoning', text: 'policy detail' }] })
  397. }
  398. return next()
  399. })
  400. for (let index = 0; index < 4; index += 1) {
  401. ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
  402. }
  403. const replaced = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'replace' })
  404. expect(replaced.isError).toBe(false)
  405. expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
  406. expect(text(replaced)).toContain('[result truncated]')
  407. const blocked = await call(ctx, 'task_kill', { task_id: 'bash-2', reason: 'block' })
  408. expect(blocked.isError).toBe(true)
  409. expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
  410. expect(text(blocked)).toContain('[result truncated]')
  411. const multi = await call(ctx, 'task_kill', { task_id: 'bash-3', reason: 'multi' })
  412. expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
  413. const reasoning = await call(ctx, 'task_kill', { task_id: 'bash-4', reason: 'reasoning' })
  414. expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
  415. })
  416. it('reports an already-finished task without consuming its pending delta', async () => {
  417. const { ctx } = await setup()
  418. let delta = 'unread tail'
  419. const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
  420. ctx.tasks.start(p.spec)
  421. p.settle({ status: 'completed', detail: 'exit code: 0' })
  422. await tick()
  423. const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  424. if (killed.isError) throw new Error('expected task_kill success')
  425. expect(killed.value).toMatchObject({
  426. outcome: 'already-finished',
  427. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
  428. })
  429. expect(text(killed)).toBe('task bash-1 had already finished [status: completed, exit code: 0]')
  430. // The kill described the task via a non-consuming snapshot: the delta is intact.
  431. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
  432. })
  433. it('rejects an empty task id as an errored result', async () => {
  434. const { ctx } = await setup()
  435. expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
  436. })
  437. })
  438. describe('tool-owned UI presentation (presentCall)', () => {
  439. it('renders generic cards for all three control tools', async () => {
  440. const { ctx } = await setup()
  441. expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
  442. .toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
  443. expect(ctx.tools.get('task_list')?.presentCall?.({}))
  444. .toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
  445. expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
  446. .toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
  447. })
  448. })
  449. describe('completion notices across scoped mounts', () => {
  450. /**
  451. * Two agent presets mounting `tool-tasks` over ONE host registry: each mount
  452. * registers its own `onTaskDone` listener on the shared service, and
  453. * `settle()` broadcasts one snapshot to every listener with no scope filter.
  454. * Only the mount whose scope the owner belongs to may deliver the notice.
  455. */
  456. it('delivers one notice from the owning scope when two mounts share the registry', async () => {
  457. const ctx = new Context()
  458. await ctx.plugin(SystemPrompt)
  459. await ctx.plugin(ToolRegistry)
  460. await ctx.plugin(AgentRegistry)
  461. await ctx.plugin(LocalTaskService)
  462. const standingA = createScope(ctx, {})
  463. const standingB = createScope(ctx, {})
  464. await standingA.ctx.plugin(ToolTasks)
  465. await standingB.ctx.plugin(ToolTasks)
  466. // The agent joins preset A exactly as `agentPresets.compose` binds it.
  467. const agentKey = {}
  468. const agentScope = createScope(ctx, agentKey)
  469. bindScopeParent(agentKey, scopeOf(standingA.ctx) as object)
  470. const inject = vi.fn()
  471. const owner = {
  472. id: SessionId('sess-scoped'),
  473. ctx: agentScope.ctx,
  474. inject,
  475. session: { id: SessionId('sess-scoped'), header: { version: 0, id: SessionId('sess-scoped'), createdAt: 0 } },
  476. } as unknown as Agent
  477. const dispose = ctx.agents.register(owner)
  478. try {
  479. // No waiter: `settle()` leaves `reported` false, which is the only path
  480. // that reaches the notice listeners at all.
  481. const p = producer({ owner, label: 'pnpm test' })
  482. ctx.tasks.start(p.spec)
  483. p.settle({ status: 'completed', detail: 'exit code: 0' })
  484. await tick()
  485. expect(inject).toHaveBeenCalledTimes(1)
  486. } finally {
  487. dispose()
  488. }
  489. })
  490. })
  491. describe('completion notice delivery', () => {
  492. it('opens a turn on an idle owner when a task settles', async () => {
  493. const { ctx } = await setup()
  494. const inject = vi.fn()
  495. const followup = vi.fn()
  496. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  497. const p = producer({ owner, label: 'pnpm test' })
  498. ctx.tasks.start(p.spec)
  499. p.settle({ status: 'completed', detail: 'exit code: 0' })
  500. await tick()
  501. expect(followup).toHaveBeenCalledTimes(1)
  502. expect(inject).not.toHaveBeenCalled()
  503. })
  504. it('never wakes an idle owner under quiet delivery', async () => {
  505. const { ctx } = await setup({ completionDelivery: 'quiet' })
  506. const inject = vi.fn()
  507. const followup = vi.fn()
  508. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  509. const p = producer({ owner })
  510. ctx.tasks.start(p.spec)
  511. p.settle({ status: 'completed' })
  512. await tick()
  513. expect(inject).toHaveBeenCalledTimes(1)
  514. expect(followup).not.toHaveBeenCalled()
  515. })
  516. it('degrades to injection once the consecutive wake budget is spent', async () => {
  517. const { ctx } = await setup({ maxConsecutiveWakes: 2 })
  518. const inject = vi.fn()
  519. const followup = vi.fn()
  520. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  521. await settleTasks(ctx, owner, 3)
  522. // A woken turn that starts another task is the self-exciting case: the
  523. // budget stops the chain, and the notice still reaches the inbox.
  524. expect(followup).toHaveBeenCalledTimes(2)
  525. expect(inject).toHaveBeenCalledTimes(1)
  526. })
  527. it('restores the wake budget when the owner claims a user message', async () => {
  528. const { ctx } = await setup({ maxConsecutiveWakes: 1 })
  529. const inject = vi.fn()
  530. const followup = vi.fn()
  531. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  532. await settleTasks(ctx, owner, 2)
  533. expect(followup).toHaveBeenCalledTimes(1)
  534. emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
  535. message: createUserMessage({ content: [{ type: 'text', text: 'carry on' }], source: { kind: 'user' } }),
  536. turn: 1,
  537. })
  538. await settleTasks(ctx, owner, 1)
  539. expect(followup).toHaveBeenCalledTimes(2)
  540. })
  541. it('neither wakes nor injects into an owner its own teardown is draining', async () => {
  542. const { ctx } = await setup()
  543. const inject = vi.fn()
  544. const followup = vi.fn()
  545. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  546. let settle!: (outcome: TaskOutcome) => void
  547. ctx.tasks.start({
  548. kind: 'bash',
  549. label: 'sleep 60',
  550. owner,
  551. run: () => ({
  552. cancel() { settle({ status: 'killed' }) },
  553. done: new Promise<TaskOutcome>((res) => { settle = res }),
  554. }),
  555. })
  556. // Disposal cancels and settles the owned task. Waking here would spend a
  557. // model request on an agent the host is destroying, once per tree layer.
  558. await disposeAgentScope(owner)
  559. await tick()
  560. expect(followup).not.toHaveBeenCalled()
  561. expect(inject).not.toHaveBeenCalled()
  562. })
  563. it('neither wakes nor injects when the teardown cancel itself threw', async () => {
  564. const { ctx } = await setup()
  565. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  566. const inject = vi.fn()
  567. const followup = vi.fn()
  568. const owner = fakeAgent(ctx, 'sess-1', { inject, followup, status: 'idle' })
  569. ctx.tasks.start({
  570. kind: 'bash',
  571. label: 'broken producer',
  572. owner,
  573. run: () => ({
  574. cancel() { throw new Error('cancel boom') },
  575. done: new Promise<TaskOutcome>(() => {}),
  576. }),
  577. })
  578. // The registry force-fails the record instead of deadlocking. That path
  579. // settles the task too, so it must claim the report as the ordinary
  580. // teardown cancel does — otherwise a throwing producer is all it takes to
  581. // spend a model request on an owner being destroyed.
  582. await disposeAgentScope(owner)
  583. await tick()
  584. expect(warn).toHaveBeenCalledWith(expect.stringContaining('work may be orphaned'))
  585. expect(followup).not.toHaveBeenCalled()
  586. expect(inject).not.toHaveBeenCalled()
  587. })
  588. it('keeps the budget spent when the owner only claims plugin notices', async () => {
  589. const { ctx } = await setup({ maxConsecutiveWakes: 1 })
  590. const followup = vi.fn()
  591. const owner = fakeAgent(ctx, 'sess-1', { followup, status: 'idle' })
  592. await settleTasks(ctx, owner, 1)
  593. emitAgentEvent(ctx, owner, 'agent/inbox/claimed', {
  594. message: createUserMessage({
  595. content: [{ type: 'text', text: 'background task bash-1 finished' }],
  596. source: { kind: 'plugin', plugin: 'tool-tasks', form: 'notice', summary: 'bash' },
  597. }),
  598. turn: 1,
  599. })
  600. await settleTasks(ctx, owner, 1)
  601. expect(followup).toHaveBeenCalledTimes(1)
  602. })
  603. })
  604. describe('completion notices', () => {
  605. it('injects a notice into the owning agent when an unreported task settles', async () => {
  606. const { ctx } = await setup()
  607. const inject = vi.fn()
  608. const owner = fakeAgent(ctx, 'sess-1', { inject })
  609. const p = producer({ owner, label: 'pnpm test' })
  610. ctx.tasks.start(p.spec)
  611. p.settle({ status: 'completed', detail: 'exit code: 0' })
  612. await tick()
  613. expect(inject).toHaveBeenCalledTimes(1)
  614. expect(inject).toHaveBeenCalledWith({
  615. id: expect.any(String) as unknown,
  616. role: 'user',
  617. content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
  618. source: {
  619. kind: 'plugin',
  620. plugin: 'tool-tasks',
  621. form: 'notice',
  622. summary: 'bash pnpm test [status: completed, exit code: 0]',
  623. },
  624. })
  625. })
  626. it('preserves task ids and collection guidance in bounded completion notices', async () => {
  627. const { ctx } = await setup()
  628. const inject = vi.fn()
  629. const owner = fakeAgent(ctx, 'sess-1', { inject })
  630. const first = producer({
  631. owner,
  632. kind: 'subagent',
  633. label: 'x'.repeat(1_000),
  634. outputLimitBytes: 64,
  635. })
  636. ctx.tasks.start(first.spec)
  637. first.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  638. await tick()
  639. expect(inject).toHaveBeenNthCalledWith(
  640. 1,
  641. {
  642. id: expect.any(String) as unknown,
  643. role: 'user',
  644. content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
  645. // The label and status detail are unbounded caller text, so the durable
  646. // one-line account caps itself rather than committing their full length.
  647. source: {
  648. kind: 'plugin',
  649. plugin: 'tool-tasks',
  650. form: 'notice',
  651. summary: `subagent ${'x'.repeat(110)}…`,
  652. },
  653. },
  654. )
  655. const second = producer({
  656. owner,
  657. kind: 'subagent',
  658. label: 'x'.repeat(1_000),
  659. outputLimitBytes: 80,
  660. })
  661. ctx.tasks.start(second.spec)
  662. second.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  663. await tick()
  664. const content = (inject.mock.calls[1]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
  665. const notice = content?.[0]?.text ?? ''
  666. expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80)
  667. expect(notice).toContain('background task subagent-2 (subagent: xxxx')
  668. expect(notice).toContain('[notice truncated]\nDone; task_output.')
  669. })
  670. it('keeps the complete PTY task id and collection action at the minimum PTY limit', async () => {
  671. const { ctx } = await setup()
  672. for (let index = 0; index < 99; index += 1) {
  673. const prior = producer({ kind: 'pty-send' })
  674. ctx.tasks.start(prior.spec)
  675. prior.settle({ status: 'completed' })
  676. }
  677. const inject = vi.fn()
  678. const owner = fakeAgent(ctx, 'sess-1', { inject })
  679. const target = producer({
  680. owner,
  681. kind: 'pty-send',
  682. label: 'x'.repeat(1_000),
  683. outputLimitBytes: 64,
  684. })
  685. ctx.tasks.start(target.spec)
  686. target.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  687. await tick()
  688. const content = (inject.mock.calls[0]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
  689. const notice = content?.[0]?.text ?? ''
  690. expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
  691. expect(notice).toBe('background task pty-send-100\nDone; task_output.')
  692. })
  693. it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
  694. const { ctx } = await setup()
  695. const inject = vi.fn()
  696. const owner = fakeAgent(ctx, 'sess-1', { inject })
  697. const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
  698. const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
  699. ctx.tasks.start(tiny.spec)
  700. ctx.tasks.start(short.spec)
  701. tiny.settle({ status: 'completed' })
  702. short.settle({ status: 'completed' })
  703. await tick()
  704. const tinyNotice = (inject.mock.calls[0]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
  705. const shortNotice = (inject.mock.calls[1]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
  706. expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
  707. expect(tinyNotice).toBe('_output.')
  708. expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
  709. expect(shortNotice).toBe('background ta\nDone; task_output.')
  710. })
  711. it('suppresses the notice for a task the model already killed', async () => {
  712. const { ctx } = await setup()
  713. const inject = vi.fn()
  714. const owner = fakeAgent(ctx, 'sess-1', { inject })
  715. const p = producer({ owner })
  716. ctx.tasks.start(p.spec)
  717. await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
  718. p.settle({ status: 'killed' })
  719. await tick()
  720. expect(inject).not.toHaveBeenCalled()
  721. })
  722. it('suppresses the notice when a wait returned the terminal state', async () => {
  723. const { ctx } = await setup()
  724. const inject = vi.fn()
  725. const owner = fakeAgent(ctx, 'sess-1', { inject })
  726. const p = producer({ owner, kind: 'subagent' })
  727. ctx.tasks.start(p.spec)
  728. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
  729. p.settle({ status: 'completed', output: 'answer' })
  730. expect(text(await pending)).toContain('answer')
  731. expect(inject).not.toHaveBeenCalled()
  732. })
  733. it('drops the notice for unowned tasks without throwing', async () => {
  734. const { ctx } = await setup()
  735. // Unowned: settles with nobody to notify — nothing throws.
  736. const unowned = producer()
  737. ctx.tasks.start(unowned.spec)
  738. unowned.settle({ status: 'completed' })
  739. await tick()
  740. })
  741. it('does not route an old owner completion notice to a same-session replacement', async () => {
  742. const { ctx } = await setup()
  743. // Delivery into a tearing-down owner is a plain inject: the loop has no
  744. // terminal state, so the notice lands in the old owner's (detached)
  745. // session instead of throwing or re-routing.
  746. const oldInject = vi.fn()
  747. const oldOwner = fakeAgent(ctx, 'shared', { inject: oldInject })
  748. const p = producer({ owner: oldOwner })
  749. ctx.tasks.start(p.spec)
  750. detachAgent(oldOwner)
  751. const replacementInject = vi.fn()
  752. fakeAgent(ctx, 'shared', { inject: replacementInject })
  753. p.settle({ status: 'completed' })
  754. await tick()
  755. expect(oldInject).toHaveBeenCalledTimes(1)
  756. expect(replacementInject).not.toHaveBeenCalled()
  757. })
  758. it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
  759. const { ctx } = await setup()
  760. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  761. const owner = fakeAgent(ctx, 'sess-1', { inject: () => { throw new Error('unexpected inject bug') } })
  762. const p = producer({ owner })
  763. ctx.tasks.start(p.spec)
  764. p.settle({ status: 'completed' })
  765. await tick()
  766. // The throw escapes the notice listener and is contained (logged) by the
  767. // registry's per-listener containment — visible, not swallowed.
  768. expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
  769. })
  770. it('keeps using the exact owner after the agent registry is gone', async () => {
  771. const { ctx, agentsFiber } = await setup()
  772. const inject = vi.fn()
  773. const owner = fakeAgent(ctx, 'sess-1', { inject })
  774. // Settlement must not depend on a later registry lookup: the exact owner
  775. // supplied at start remains the destination while its own scope is live.
  776. const p1 = producer({ owner })
  777. ctx.tasks.start(p1.spec)
  778. const p2 = producer({ owner })
  779. ctx.tasks.start(p2.spec)
  780. await agentsFiber.dispose()
  781. p1.settle({ status: 'completed' })
  782. p2.settle({ status: 'failed' })
  783. await tick()
  784. expect(inject).toHaveBeenCalledTimes(2)
  785. })
  786. })