tool-tasks.spec.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641
  1. import { describe, expect, it, vi } from 'vitest'
  2. import { Context } from '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 from '@deepseek-ai/dsh-agent'
  7. import type { Agent } from '@deepseek-ai/dsh-agent'
  8. import { SessionId } from '@deepseek-ai/dsh-session'
  9. import { TaskId } from '@deepseek-ai/dsh-tasks'
  10. import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
  11. import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks'
  12. import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks'
  13. import { statusLine } from '@deepseek-ai/dsh-tool-tasks'
  14. const testToolSignal = new AbortController().signal
  15. const agentRegistryDisposers = new WeakMap<Agent, () => void>()
  16. async function setup(config: ToolTasks.Config = {}) {
  17. const ctx = new Context()
  18. await ctx.plugin(SystemPrompt)
  19. await ctx.plugin(ToolRegistry)
  20. const agentsFiber = await ctx.plugin(AgentRegistry)
  21. await ctx.plugin(LocalTaskService)
  22. const toolsFiber = await ctx.plugin(ToolTasks, config)
  23. return { ctx, agentsFiber, toolsFiber }
  24. }
  25. /**
  26. * A fake agent with the shared agent/session identity, registered in
  27. * `ctx.agents` with a dedicated lifecycle scope.
  28. */
  29. function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent {
  30. const scopeFiber = ctx.plugin(() => {})
  31. const id = SessionId(sessionId)
  32. const agent = {
  33. id,
  34. ctx: scopeFiber.ctx,
  35. inject,
  36. session: { id, header: { version: 0, id, createdAt: 0 } },
  37. } as unknown as Agent
  38. agentRegistryDisposers.set(agent, ctx.agents.register(agent))
  39. return agent
  40. }
  41. function detachAgent(agent: Agent): void {
  42. const dispose = agentRegistryDisposers.get(agent)
  43. if (dispose === undefined) throw new Error(`missing registry disposer for agent "${agent.id}"`)
  44. dispose()
  45. }
  46. /** A controllable producer start-spec (settle `done` on demand, record cancels). */
  47. function producer(overrides: Partial<Omit<TaskStart, 'run'> & TaskHooks> = {}) {
  48. let settle!: (outcome: TaskOutcome) => void
  49. const cancels: (string | undefined)[] = []
  50. const { kind = 'bash', label = 'sleep 60', owner, outputLimitBytes, ...hookOverrides } = overrides
  51. const hooks: TaskHooks = {
  52. cancel(reason) { cancels.push(reason) },
  53. done: new Promise<TaskOutcome>((res) => { settle = res }),
  54. ...hookOverrides,
  55. }
  56. const spec: TaskStart = {
  57. kind,
  58. label,
  59. ...owner !== undefined ? { owner } : {},
  60. ...outputLimitBytes !== undefined ? { outputLimitBytes } : {},
  61. run: () => hooks,
  62. }
  63. return { spec, settle, cancels }
  64. }
  65. let callCounter = 0
  66. function call(ctx: Context, name: string, args: unknown, agent?: Agent) {
  67. return ctx.tools.execute({ signal: testToolSignal, callId: CallId(`call-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
  68. }
  69. function text(result: { content: { type: string; text?: string }[] }): string {
  70. return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
  71. }
  72. const tick = () => new Promise<void>(r => setTimeout(r, 0))
  73. describe('tool-tasks setup', () => {
  74. it('attaches the control surface on load and detaches it with the fiber', async () => {
  75. const { ctx, toolsFiber } = await setup()
  76. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  77. await toolsFiber.dispose()
  78. expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached')
  79. })
  80. it('rejects a config whose default wait exceeds the cap', async () => {
  81. const ctx = new Context()
  82. await ctx.plugin(SystemPrompt)
  83. await ctx.plugin(ToolRegistry)
  84. await ctx.plugin(LocalTaskService)
  85. await expect(ctx.plugin(ToolTasks, { waitTimeoutMs: 100, maxWaitTimeoutMs: 50 }))
  86. .rejects.toThrow('waitTimeoutMs (100) exceeds maxWaitTimeoutMs (50)')
  87. })
  88. it('renders status lines with and without producer detail', () => {
  89. const base = { id: 'bash-1', kind: 'bash', label: 'x', startedAt: 0, reported: false } as unknown as TaskSnapshot
  90. expect(statusLine({ ...base, status: 'running' })).toBe('[status: running]')
  91. expect(statusLine({ ...base, status: 'completed', detail: 'exit code: 0' })).toBe('[status: completed, exit code: 0]')
  92. })
  93. it('applies the built-in wait bounds when apply() receives a bare config', async () => {
  94. // Bypasses the schemastery defaults on purpose: apply() must stand on its
  95. // own `??` fallbacks when embedded programmatically without the schema.
  96. const ctx = new Context()
  97. await ctx.plugin(SystemPrompt)
  98. await ctx.plugin(ToolRegistry)
  99. await ctx.plugin(LocalTaskService)
  100. ToolTasks.apply(ctx, {})
  101. expect(ctx.tools.get('task_output')).toBeDefined()
  102. expect(() => ctx.tasks.start(producer().spec)).not.toThrow()
  103. })
  104. })
  105. describe('task_output', () => {
  106. it('reads a consuming delta with a trailing status line', async () => {
  107. const { ctx } = await setup()
  108. const chunks = ['line one\n', '']
  109. ctx.tasks.start(producer({ readOutput: () => chunks.shift() ?? '' }).spec)
  110. // A body already ending in a newline gets no doubled separator.
  111. const first = await call(ctx, 'task_output', { task_id: 'bash-1' })
  112. if (first.isError) throw new Error('expected task_output success')
  113. const firstValue = first.value as { text: string; task: Record<string, unknown> }
  114. expect(firstValue).toMatchObject({
  115. text: 'line one\n',
  116. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'running' },
  117. })
  118. expect(firstValue.task).not.toHaveProperty('ownerSession')
  119. expect(firstValue.task).not.toHaveProperty('reported')
  120. expect(text(first)).toBe('line one\n[status: running]')
  121. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('(no new output)\n[status: running]')
  122. })
  123. it('returns the final output of a settled final-output task', async () => {
  124. const { ctx } = await setup()
  125. const p = producer({ kind: 'subagent', label: 'research' })
  126. ctx.tasks.start(p.spec)
  127. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('(no new output)\n[status: running]')
  128. p.settle({ status: 'completed', detail: 'completed', output: 'the answer' })
  129. await tick()
  130. expect(text(await call(ctx, 'task_output', { task_id: 'subagent-1' }))).toBe('the answer\n[status: completed, completed]')
  131. })
  132. it('applies a producer limit to the complete body and status result', async () => {
  133. const { ctx } = await setup()
  134. ctx.tasks.start(producer({
  135. outputLimitBytes: 48,
  136. readOutput: () => '界'.repeat(100),
  137. }).spec)
  138. const output = text(await call(ctx, 'task_output', { task_id: 'bash-1' }))
  139. expect(Buffer.byteLength(output)).toBeLessThanOrEqual(48)
  140. expect(output).toContain('[status: running]')
  141. })
  142. it('preserves empty and newline-terminated output under a producer limit', async () => {
  143. const { ctx } = await setup()
  144. const chunks = ['', 'line\n']
  145. ctx.tasks.start(producer({
  146. outputLimitBytes: 64,
  147. readOutput: () => chunks.shift() ?? '',
  148. }).spec)
  149. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
  150. .toBe('(no new output)\n[status: running]')
  151. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' })))
  152. .toBe('line\n[status: running]')
  153. })
  154. it('bounds post-policy output without restoring the canonical status rendering', async () => {
  155. const { ctx } = await setup()
  156. ctx.tasks.start(producer({
  157. outputLimitBytes: 64,
  158. readOutput: () => 'canonical output',
  159. }).spec)
  160. ctx.on('tools/post-execute', (exec, _result, next) => {
  161. if (exec.name !== 'task_output') return next()
  162. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'p'.repeat(1_000) }] })
  163. })
  164. const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
  165. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  166. expect(text(result)).toContain('[result truncated]')
  167. expect(text(result)).not.toContain('[status: running]')
  168. })
  169. it('applies a producer limit to a normalized read failure', async () => {
  170. const { ctx } = await setup()
  171. ctx.tasks.start(producer({
  172. outputLimitBytes: 64,
  173. readOutput: () => { throw new Error('read failed: '.repeat(100)) },
  174. }).spec)
  175. const result = await call(ctx, 'task_output', { task_id: 'bash-1' })
  176. expect(result.isError).toBe(true)
  177. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  178. expect(text(result)).toContain('[result truncated]')
  179. })
  180. it('bounds pre-, around-, and post-execute policy outcomes and failures', async () => {
  181. const { ctx } = await setup()
  182. for (let index = 0; index < 5; index += 1) {
  183. ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
  184. }
  185. ctx.on('tools/pre-execute', async (exec, next) => {
  186. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  187. if (taskId === 'bash-1') return { kind: 'deny', reason: 'd'.repeat(1_000) }
  188. if (taskId === 'bash-3') throw new Error(`pre failed: ${'p'.repeat(1_000)}`)
  189. return next()
  190. })
  191. ctx.on('tools/execute', async (exec, next) => {
  192. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  193. if (taskId === 'bash-2') {
  194. return {
  195. content: [],
  196. isError: false,
  197. value: {
  198. text: 'a'.repeat(1_000),
  199. task: {
  200. id: 'bash-2', kind: 'bash', label: 'sleep 60', status: 'running', startedAt: 0,
  201. },
  202. },
  203. }
  204. }
  205. if (taskId === 'bash-4') throw new Error(`around failed: ${'e'.repeat(1_000)}`)
  206. return next()
  207. })
  208. ctx.on('tools/post-execute', async (exec, _result, next) => {
  209. const taskId = (exec.arguments as { task_id?: unknown }).task_id
  210. if (taskId === 'bash-5') throw new Error(`post failed: ${'o'.repeat(1_000)}`)
  211. return next()
  212. })
  213. const denied = await call(ctx, 'task_output', { task_id: 'bash-1' })
  214. expect(denied.isError).toBe(true)
  215. expect(Buffer.byteLength(text(denied))).toBeLessThanOrEqual(64)
  216. expect(text(denied)).toContain('[result truncated]')
  217. const shortCircuited = await call(ctx, 'task_output', { task_id: 'bash-2' })
  218. expect(shortCircuited.isError).toBe(false)
  219. expect(Buffer.byteLength(text(shortCircuited))).toBeLessThanOrEqual(64)
  220. expect(text(shortCircuited)).toContain('[output truncated]')
  221. const failures = [
  222. await call(ctx, 'task_output', { task_id: 'bash-3' }),
  223. await call(ctx, 'task_output', { task_id: 'bash-4' }),
  224. await call(ctx, 'task_output', { task_id: 'bash-5' }),
  225. ]
  226. for (const failure of failures) {
  227. expect(failure.isError).toBe(true)
  228. expect(Buffer.byteLength(text(failure))).toBeLessThanOrEqual(64)
  229. expect(text(failure)).toContain('[result truncated]')
  230. }
  231. })
  232. it('wait: true blocks until settlement and reports the terminal state', async () => {
  233. const { ctx } = await setup()
  234. const p = producer({ kind: 'subagent', label: 'research' })
  235. ctx.tasks.start(p.spec)
  236. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true })
  237. p.settle({ status: 'completed', output: 'done deal' })
  238. expect(text(await pending)).toBe('done deal\n[status: completed]')
  239. })
  240. it('wait: true times out against the configured cap and leaves the task alive', async () => {
  241. const { ctx } = await setup({ waitTimeoutMs: 10, maxWaitTimeoutMs: 20 })
  242. ctx.tasks.start(producer().spec)
  243. // A model-supplied timeout far above the cap is clamped: this returns
  244. // promptly (≤ the 20ms cap), not after ten minutes.
  245. const result = await call(ctx, 'task_output', { task_id: 'bash-1', wait: true, timeout_ms: 600_000 })
  246. expect(text(result)).toBe('(no new output)\n[status: running]')
  247. })
  248. it('rejects an empty or unknown task id as an errored result', async () => {
  249. const { ctx } = await setup()
  250. expect((await call(ctx, 'task_output', { task_id: '' })).isError).toBe(true)
  251. const unknown = await call(ctx, 'task_output', { task_id: 'bash-99' })
  252. expect(unknown.isError).toBe(true)
  253. expect(text(unknown)).toContain('unknown task bash-99')
  254. })
  255. })
  256. describe('task_list', () => {
  257. it('lists caller-visible tasks and renders the empty case', async () => {
  258. const { ctx } = await setup()
  259. expect(text(await call(ctx, 'task_list', {}))).toBe('(no background tasks)')
  260. const alice = fakeAgent(ctx, 'sess-alice')
  261. ctx.tasks.start(producer({ owner: alice, label: 'pnpm test' }).spec)
  262. ctx.tasks.start(producer({ kind: 'subagent', label: 'open research' }).spec)
  263. const p = producer({ owner: alice, label: 'build' })
  264. ctx.tasks.start(p.spec)
  265. p.settle({ status: 'completed', detail: 'exit code: 0' })
  266. await tick()
  267. const listed = await call(ctx, 'task_list', {}, alice)
  268. if (listed.isError) throw new Error('expected task_list success')
  269. const listedValue = listed.value as Array<Record<string, unknown>>
  270. expect(listedValue).toHaveLength(3)
  271. expect(listedValue[0]).toMatchObject({ id: 'bash-1', kind: 'bash', label: 'pnpm test', status: 'running' })
  272. expect(listedValue[2]).toMatchObject({ id: 'bash-2', kind: 'bash', label: 'build', status: 'completed', detail: 'exit code: 0' })
  273. for (const task of listedValue) {
  274. expect(task).not.toHaveProperty('ownerSession')
  275. expect(task).not.toHaveProperty('reported')
  276. }
  277. expect(text(listed)).toBe([
  278. 'bash-1 [bash] running — pnpm test',
  279. 'subagent-1 [subagent] running — open research',
  280. 'bash-2 [bash] completed — build',
  281. ].join('\n'))
  282. // A different caller sees only the unowned task.
  283. const bob = fakeAgent(ctx, 'sess-bob')
  284. expect(text(await call(ctx, 'task_list', {}, bob))).toBe('subagent-1 [subagent] running — open research')
  285. })
  286. })
  287. describe('task_kill', () => {
  288. it('requests cancellation with the forwarded reason', async () => {
  289. const { ctx } = await setup()
  290. const p = producer()
  291. ctx.tasks.start(p.spec)
  292. const result = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'superseded' })
  293. if (result.isError) throw new Error('expected task_kill success')
  294. const killValue = result.value as { outcome: string; task: Record<string, unknown> }
  295. expect(killValue).toMatchObject({
  296. outcome: 'cancellation-requested',
  297. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'stopping' },
  298. })
  299. expect(killValue.task).not.toHaveProperty('ownerSession')
  300. expect(killValue.task).not.toHaveProperty('reported')
  301. expect(text(result)).toBe('requested cancellation of task bash-1')
  302. expect(p.cancels).toEqual(['superseded'])
  303. })
  304. it('applies the producer output limit to a cancellation acknowledgement', async () => {
  305. const { ctx } = await setup()
  306. const p = producer({ outputLimitBytes: 8 })
  307. ctx.tasks.start(p.spec)
  308. const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  309. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(8)
  310. expect(p.cancels).toEqual([undefined])
  311. })
  312. it('applies the producer output limit to a normalized cancellation failure', async () => {
  313. const { ctx } = await setup()
  314. ctx.tasks.start(producer({
  315. outputLimitBytes: 64,
  316. cancel: () => { throw new Error('cancel failed: '.repeat(100)) },
  317. }).spec)
  318. const result = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  319. expect(result.isError).toBe(true)
  320. expect(Buffer.byteLength(text(result))).toBeLessThanOrEqual(64)
  321. expect(text(result)).toContain('[result truncated]')
  322. expect(ctx.tasks.get(TaskId('bash-1'))).toMatchObject({ status: 'running', reported: false })
  323. })
  324. it('bounds single-text post policy while preserving structured policy results', async () => {
  325. const { ctx } = await setup()
  326. ctx.on('tools/post-execute', (exec, _result, next) => {
  327. if (exec.name !== 'task_kill') return next()
  328. const reason = (exec.arguments as { reason?: unknown }).reason
  329. if (reason === 'replace') {
  330. return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'r'.repeat(1_000) }] })
  331. }
  332. if (reason === 'block') {
  333. return Promise.resolve({ kind: 'block', feedback: [{ type: 'text', text: 'b'.repeat(1_000) }] })
  334. }
  335. if (reason === 'multi') {
  336. return Promise.resolve({
  337. kind: 'block',
  338. feedback: [{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }],
  339. })
  340. }
  341. if (reason === 'reasoning') {
  342. return Promise.resolve({ kind: 'block', feedback: [{ type: 'reasoning', text: 'policy detail' }] })
  343. }
  344. return next()
  345. })
  346. for (let index = 0; index < 4; index += 1) {
  347. ctx.tasks.start(producer({ outputLimitBytes: 64 }).spec)
  348. }
  349. const replaced = await call(ctx, 'task_kill', { task_id: 'bash-1', reason: 'replace' })
  350. expect(replaced.isError).toBe(false)
  351. expect(Buffer.byteLength(text(replaced))).toBeLessThanOrEqual(64)
  352. expect(text(replaced)).toContain('[result truncated]')
  353. const blocked = await call(ctx, 'task_kill', { task_id: 'bash-2', reason: 'block' })
  354. expect(blocked.isError).toBe(true)
  355. expect(Buffer.byteLength(text(blocked))).toBeLessThanOrEqual(64)
  356. expect(text(blocked)).toContain('[result truncated]')
  357. const multi = await call(ctx, 'task_kill', { task_id: 'bash-3', reason: 'multi' })
  358. expect(multi.content).toEqual([{ type: 'text', text: 'first' }, { type: 'text', text: 'second' }])
  359. const reasoning = await call(ctx, 'task_kill', { task_id: 'bash-4', reason: 'reasoning' })
  360. expect(reasoning.content).toEqual([{ type: 'reasoning', text: 'policy detail' }])
  361. })
  362. it('reports an already-finished task without consuming its pending delta', async () => {
  363. const { ctx } = await setup()
  364. let delta = 'unread tail'
  365. const p = producer({ readOutput: () => { const d = delta; delta = ''; return d } })
  366. ctx.tasks.start(p.spec)
  367. p.settle({ status: 'completed', detail: 'exit code: 0' })
  368. await tick()
  369. const killed = await call(ctx, 'task_kill', { task_id: 'bash-1' })
  370. if (killed.isError) throw new Error('expected task_kill success')
  371. expect(killed.value).toMatchObject({
  372. outcome: 'already-finished',
  373. task: { id: 'bash-1', kind: 'bash', label: 'sleep 60', status: 'completed', detail: 'exit code: 0' },
  374. })
  375. expect(text(killed)).toBe('task bash-1 had already finished [status: completed, exit code: 0]')
  376. // The kill described the task via a non-consuming snapshot: the delta is intact.
  377. expect(text(await call(ctx, 'task_output', { task_id: 'bash-1' }))).toBe('unread tail\n[status: completed, exit code: 0]')
  378. })
  379. it('rejects an empty task id as an errored result', async () => {
  380. const { ctx } = await setup()
  381. expect((await call(ctx, 'task_kill', { task_id: '' })).isError).toBe(true)
  382. })
  383. })
  384. describe('tool-owned UI presentation (presentCall)', () => {
  385. it('renders generic cards for all three control tools', async () => {
  386. const { ctx } = await setup()
  387. expect(ctx.tools.get('task_output')?.presentCall?.({ task_id: 'bash-1' }))
  388. .toEqual({ card: 'generic', title: 'Read output from background task bash-1', kind: 'read', rawInput: 'bash-1' })
  389. expect(ctx.tools.get('task_list')?.presentCall?.({}))
  390. .toEqual({ card: 'generic', title: 'List background tasks', kind: 'read' })
  391. expect(ctx.tools.get('task_kill')?.presentCall?.({ task_id: 'subagent-2' }))
  392. .toEqual({ card: 'generic', title: 'Kill background task subagent-2', kind: 'execute', rawInput: 'subagent-2' })
  393. })
  394. })
  395. describe('completion notices', () => {
  396. it('injects a notice into the owning agent when an unreported task settles', async () => {
  397. const { ctx } = await setup()
  398. const inject = vi.fn()
  399. const owner = fakeAgent(ctx, 'sess-1', inject)
  400. const p = producer({ owner, label: 'pnpm test' })
  401. ctx.tasks.start(p.spec)
  402. p.settle({ status: 'completed', detail: 'exit code: 0' })
  403. await tick()
  404. expect(inject).toHaveBeenCalledTimes(1)
  405. expect(inject).toHaveBeenCalledWith({
  406. id: expect.any(String) as unknown,
  407. role: 'user',
  408. content: [{ type: 'text', text: 'background task bash-1 (bash: pnpm test) finished [status: completed, exit code: 0]. Read its output with task_output.' }],
  409. source: { kind: 'plugin', plugin: 'tool-tasks' },
  410. })
  411. })
  412. it('preserves task ids and collection guidance in bounded completion notices', async () => {
  413. const { ctx } = await setup()
  414. const inject = vi.fn()
  415. const owner = fakeAgent(ctx, 'sess-1', inject)
  416. const first = producer({
  417. owner,
  418. kind: 'subagent',
  419. label: 'x'.repeat(1_000),
  420. outputLimitBytes: 64,
  421. })
  422. ctx.tasks.start(first.spec)
  423. first.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  424. await tick()
  425. expect(inject).toHaveBeenNthCalledWith(
  426. 1,
  427. {
  428. id: expect.any(String) as unknown,
  429. role: 'user',
  430. content: [{ type: 'text', text: 'background task subagent-1\n[notice truncated]\nDone; task_output.' }],
  431. source: { kind: 'plugin', plugin: 'tool-tasks' },
  432. },
  433. )
  434. const second = producer({
  435. owner,
  436. kind: 'subagent',
  437. label: 'x'.repeat(1_000),
  438. outputLimitBytes: 80,
  439. })
  440. ctx.tasks.start(second.spec)
  441. second.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  442. await tick()
  443. const content = (inject.mock.calls[1]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
  444. const notice = content?.[0]?.text ?? ''
  445. expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(80)
  446. expect(notice).toContain('background task subagent-2 (subagent: xxxx')
  447. expect(notice).toContain('[notice truncated]\nDone; task_output.')
  448. })
  449. it('keeps the complete PTY task id and collection action at the minimum PTY limit', async () => {
  450. const { ctx } = await setup()
  451. for (let index = 0; index < 99; index += 1) {
  452. const prior = producer({ kind: 'pty-send' })
  453. ctx.tasks.start(prior.spec)
  454. prior.settle({ status: 'completed' })
  455. }
  456. const inject = vi.fn()
  457. const owner = fakeAgent(ctx, 'sess-1', inject)
  458. const target = producer({
  459. owner,
  460. kind: 'pty-send',
  461. label: 'x'.repeat(1_000),
  462. outputLimitBytes: 64,
  463. })
  464. ctx.tasks.start(target.spec)
  465. target.settle({ status: 'completed', detail: 'd'.repeat(1_000) })
  466. await tick()
  467. const content = (inject.mock.calls[0]?.[0] as { content?: Array<{ type: string; text?: string }> } | undefined)?.content
  468. const notice = content?.[0]?.text ?? ''
  469. expect(Buffer.byteLength(notice)).toBeLessThanOrEqual(64)
  470. expect(notice).toBe('background task pty-send-100\nDone; task_output.')
  471. })
  472. it('reserves the collection-action tail when a producer supplies a smaller budget', async () => {
  473. const { ctx } = await setup()
  474. const inject = vi.fn()
  475. const owner = fakeAgent(ctx, 'sess-1', inject)
  476. const tiny = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 8 })
  477. const short = producer({ owner, kind: 'pty-send', label: 'x'.repeat(100), outputLimitBytes: 32 })
  478. ctx.tasks.start(tiny.spec)
  479. ctx.tasks.start(short.spec)
  480. tiny.settle({ status: 'completed' })
  481. short.settle({ status: 'completed' })
  482. await tick()
  483. const tinyNotice = (inject.mock.calls[0]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
  484. const shortNotice = (inject.mock.calls[1]?.[0] as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text ?? ''
  485. expect(Buffer.byteLength(tinyNotice)).toBeLessThanOrEqual(8)
  486. expect(tinyNotice).toBe('_output.')
  487. expect(Buffer.byteLength(shortNotice)).toBeLessThanOrEqual(32)
  488. expect(shortNotice).toBe('background ta\nDone; task_output.')
  489. })
  490. it('suppresses the notice for a task the model already killed', async () => {
  491. const { ctx } = await setup()
  492. const inject = vi.fn()
  493. const owner = fakeAgent(ctx, 'sess-1', inject)
  494. const p = producer({ owner })
  495. ctx.tasks.start(p.spec)
  496. await call(ctx, 'task_kill', { task_id: 'bash-1' }, owner)
  497. p.settle({ status: 'killed' })
  498. await tick()
  499. expect(inject).not.toHaveBeenCalled()
  500. })
  501. it('suppresses the notice when a wait returned the terminal state', async () => {
  502. const { ctx } = await setup()
  503. const inject = vi.fn()
  504. const owner = fakeAgent(ctx, 'sess-1', inject)
  505. const p = producer({ owner, kind: 'subagent' })
  506. ctx.tasks.start(p.spec)
  507. const pending = call(ctx, 'task_output', { task_id: 'subagent-1', wait: true }, owner)
  508. p.settle({ status: 'completed', output: 'answer' })
  509. expect(text(await pending)).toContain('answer')
  510. expect(inject).not.toHaveBeenCalled()
  511. })
  512. it('drops the notice for unowned tasks without throwing', async () => {
  513. const { ctx } = await setup()
  514. // Unowned: settles with nobody to notify — nothing throws.
  515. const unowned = producer()
  516. ctx.tasks.start(unowned.spec)
  517. unowned.settle({ status: 'completed' })
  518. await tick()
  519. })
  520. it('does not route an old owner completion notice to a same-session replacement', async () => {
  521. const { ctx } = await setup()
  522. // Delivery into a tearing-down owner is a plain inject: the loop has no
  523. // terminal state, so the notice lands in the old owner's (detached)
  524. // session instead of throwing or re-routing.
  525. const oldInject = vi.fn()
  526. const oldOwner = fakeAgent(ctx, 'shared', oldInject)
  527. const p = producer({ owner: oldOwner })
  528. ctx.tasks.start(p.spec)
  529. detachAgent(oldOwner)
  530. const replacementInject = vi.fn()
  531. fakeAgent(ctx, 'shared', replacementInject)
  532. p.settle({ status: 'completed' })
  533. await tick()
  534. expect(oldInject).toHaveBeenCalledTimes(1)
  535. expect(replacementInject).not.toHaveBeenCalled()
  536. })
  537. it('surfaces an inject failure through listener containment (a real bug must be visible)', async () => {
  538. const { ctx } = await setup()
  539. const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
  540. const owner = fakeAgent(ctx, 'sess-1', () => { throw new Error('unexpected inject bug') })
  541. const p = producer({ owner })
  542. ctx.tasks.start(p.spec)
  543. p.settle({ status: 'completed' })
  544. await tick()
  545. // The throw escapes the notice listener and is contained (logged) by the
  546. // registry's per-listener containment — visible, not swallowed.
  547. expect(warn).toHaveBeenCalledWith(expect.stringContaining('unexpected inject bug'))
  548. })
  549. it('keeps using the exact owner after the agent registry is gone', async () => {
  550. const { ctx, agentsFiber } = await setup()
  551. const inject = vi.fn()
  552. const owner = fakeAgent(ctx, 'sess-1', inject)
  553. // Settlement must not depend on a later registry lookup: the exact owner
  554. // supplied at start remains the destination while its own scope is live.
  555. const p1 = producer({ owner })
  556. ctx.tasks.start(p1.spec)
  557. const p2 = producer({ owner })
  558. ctx.tasks.start(p2.spec)
  559. await agentsFiber.dispose()
  560. p1.settle({ status: 'completed' })
  561. p2.settle({ status: 'failed' })
  562. await tick()
  563. expect(inject).toHaveBeenCalledTimes(2)
  564. })
  565. })