task.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. import { afterEach, describe, expect } from "bun:test"
  2. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  3. import { Database } from "@kirincode-ai/core/database/database"
  4. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  5. import { SessionProjector } from "@kirincode-ai/core/session/projector"
  6. import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
  7. import { Agent } from "../../src/agent/agent"
  8. import { BackgroundJob } from "@/background/job"
  9. import { EventV2Bridge } from "@/event-v2-bridge"
  10. import { Config } from "@/config/config"
  11. import { CrossSpawnSpawner } from "@kirincode-ai/core/cross-spawn-spawner"
  12. import { Ripgrep } from "@kirincode-ai/core/ripgrep"
  13. import { Session } from "@/session/session"
  14. import type { SessionPrompt } from "../../src/session/prompt"
  15. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  16. import { SessionRunState } from "@/session/run-state"
  17. import { SessionStatus } from "@/session/status"
  18. import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
  19. import { Truncate } from "@/tool/truncate"
  20. import { ToolRegistry } from "@/tool/registry"
  21. import { RuntimeFlags } from "@/effect/runtime-flags"
  22. import { disposeAllInstances } from "../fixture/fixture"
  23. import { testEffect } from "../lib/effect"
  24. import { ProviderV2 } from "@kirincode-ai/core/provider"
  25. import { ModelV2 } from "@kirincode-ai/core/model"
  26. afterEach(async () => {
  27. await disposeAllInstances()
  28. })
  29. const ref = {
  30. providerID: ProviderV2.ID.make("test"),
  31. modelID: ModelV2.ID.make("test-model"),
  32. }
  33. const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
  34. LayerNode.compile(
  35. LayerNode.group([
  36. Agent.node,
  37. BackgroundJob.node,
  38. EventV2Bridge.node,
  39. Config.node,
  40. CrossSpawnSpawner.node,
  41. Session.node,
  42. SessionProjector.node,
  43. SessionRunState.node,
  44. SessionStatus.node,
  45. Truncate.node,
  46. ToolRegistry.node,
  47. Database.node,
  48. RuntimeFlags.node,
  49. Ripgrep.node,
  50. ]),
  51. [[RuntimeFlags.node, RuntimeFlags.layer(flags)]],
  52. )
  53. const it = testEffect(layer())
  54. const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
  55. function defer<T>() {
  56. let resolve!: (value: T | PromiseLike<T>) => void
  57. const promise = new Promise<T>((done) => {
  58. resolve = done
  59. })
  60. return { promise, resolve }
  61. }
  62. const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
  63. const session = yield* Session.Service
  64. const chat = yield* session.create({ title })
  65. const user = yield* session.updateMessage({
  66. id: MessageID.ascending(),
  67. role: "user",
  68. sessionID: chat.id,
  69. agent: "build",
  70. model: ref,
  71. time: { created: Date.now() },
  72. })
  73. const assistant: SessionV1.Assistant = {
  74. id: MessageID.ascending(),
  75. role: "assistant",
  76. parentID: user.id,
  77. sessionID: chat.id,
  78. mode: "build",
  79. agent: "build",
  80. cost: 0,
  81. path: { cwd: "/tmp", root: "/tmp" },
  82. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  83. modelID: ref.modelID,
  84. providerID: ref.providerID,
  85. variant: "xhigh",
  86. time: { created: Date.now() },
  87. }
  88. yield* session.updateMessage(assistant)
  89. return { chat, assistant }
  90. })
  91. function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
  92. return {
  93. cancel: () => Effect.void,
  94. resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
  95. prompt: (input) =>
  96. Effect.sync(() => {
  97. opts?.onPrompt?.(input)
  98. return reply(input, opts?.text ?? "done")
  99. }),
  100. }
  101. }
  102. function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts {
  103. const id = MessageID.ascending()
  104. return {
  105. info: {
  106. id,
  107. role: "assistant",
  108. parentID: input.messageID ?? MessageID.ascending(),
  109. sessionID: input.sessionID,
  110. mode: input.agent ?? "general",
  111. agent: input.agent ?? "general",
  112. cost: 0,
  113. path: { cwd: "/tmp", root: "/tmp" },
  114. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  115. modelID: input.model?.modelID ?? ref.modelID,
  116. providerID: input.model?.providerID ?? ref.providerID,
  117. time: { created: Date.now() },
  118. finish: "stop",
  119. },
  120. parts: [
  121. {
  122. id: PartID.ascending(),
  123. messageID: id,
  124. sessionID: input.sessionID,
  125. type: "text",
  126. text,
  127. },
  128. ],
  129. }
  130. }
  131. describe("tool.task", () => {
  132. it.instance(
  133. "description sorts subagents by name and is stable across calls",
  134. () =>
  135. Effect.gen(function* () {
  136. const agent = yield* Agent.Service
  137. const build = yield* agent.get("build")
  138. const registry = yield* ToolRegistry.Service
  139. const get = Effect.fnUntraced(function* () {
  140. const tools = yield* registry.tools({ ...ref, agent: build })
  141. return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
  142. })
  143. const first = yield* get()
  144. const second = yield* get()
  145. expect(first).toBe(second)
  146. const alpha = first.indexOf("- alpha: Alpha agent")
  147. const explore = first.indexOf("- explore:")
  148. const general = first.indexOf("- general:")
  149. const zebra = first.indexOf("- zebra: Zebra agent")
  150. expect(alpha).toBeGreaterThan(-1)
  151. expect(explore).toBeGreaterThan(alpha)
  152. expect(general).toBeGreaterThan(explore)
  153. expect(zebra).toBeGreaterThan(general)
  154. }),
  155. {
  156. config: {
  157. agent: {
  158. zebra: {
  159. description: "Zebra agent",
  160. mode: "subagent",
  161. },
  162. alpha: {
  163. description: "Alpha agent",
  164. mode: "subagent",
  165. },
  166. },
  167. },
  168. },
  169. )
  170. it.instance(
  171. "description hides denied subagents for the caller",
  172. () =>
  173. Effect.gen(function* () {
  174. const agent = yield* Agent.Service
  175. const build = yield* agent.get("build")
  176. const registry = yield* ToolRegistry.Service
  177. const description =
  178. (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
  179. expect(description).toContain("- alpha: Alpha agent")
  180. expect(description).not.toContain("- zebra: Zebra agent")
  181. }),
  182. {
  183. config: {
  184. permission: {
  185. task: {
  186. "*": "allow",
  187. zebra: "deny",
  188. },
  189. },
  190. agent: {
  191. zebra: {
  192. description: "Zebra agent",
  193. mode: "subagent",
  194. },
  195. alpha: {
  196. description: "Alpha agent",
  197. mode: "subagent",
  198. },
  199. },
  200. },
  201. },
  202. )
  203. it.instance("execute resumes an existing task session from task_id", () =>
  204. Effect.gen(function* () {
  205. const sessions = yield* Session.Service
  206. const { chat, assistant } = yield* seed()
  207. const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
  208. const tool = yield* TaskTool
  209. const def = yield* tool.init()
  210. let seen: SessionPrompt.PromptInput | undefined
  211. const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
  212. const result = yield* def.execute(
  213. {
  214. description: "inspect bug",
  215. prompt: "look into the cache key path",
  216. subagent_type: "general",
  217. task_id: child.id,
  218. },
  219. {
  220. sessionID: chat.id,
  221. messageID: assistant.id,
  222. agent: "build",
  223. abort: new AbortController().signal,
  224. extra: { promptOps },
  225. messages: [],
  226. metadata: () => Effect.void,
  227. ask: () => Effect.void,
  228. },
  229. )
  230. const kids = yield* sessions.children(chat.id)
  231. expect(kids).toHaveLength(1)
  232. expect(kids[0]?.id).toBe(child.id)
  233. expect(result.metadata.sessionId).toBe(child.id)
  234. expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
  235. expect(seen?.sessionID).toBe(child.id)
  236. expect(seen?.variant).toBe("xhigh")
  237. }),
  238. )
  239. it.instance("execute asks by default and skips checks when bypassed", () =>
  240. Effect.gen(function* () {
  241. const { chat, assistant } = yield* seed()
  242. const tool = yield* TaskTool
  243. const def = yield* tool.init()
  244. const calls: unknown[] = []
  245. const promptOps = stubOps()
  246. const exec = (extra?: Record<string, any>) =>
  247. def.execute(
  248. {
  249. description: "inspect bug",
  250. prompt: "look into the cache key path",
  251. subagent_type: "general",
  252. },
  253. {
  254. sessionID: chat.id,
  255. messageID: assistant.id,
  256. agent: "build",
  257. abort: new AbortController().signal,
  258. extra: { promptOps, ...extra },
  259. messages: [],
  260. metadata: () => Effect.void,
  261. ask: (input) =>
  262. Effect.sync(() => {
  263. calls.push(input)
  264. }),
  265. },
  266. )
  267. yield* exec()
  268. yield* exec({ bypassAgentCheck: true })
  269. expect(calls).toHaveLength(1)
  270. expect(calls[0]).toEqual({
  271. permission: "task",
  272. patterns: ["general"],
  273. always: ["*"],
  274. metadata: {
  275. description: "inspect bug",
  276. subagent_type: "general",
  277. },
  278. })
  279. }),
  280. )
  281. it.instance("execute cancels child session when abort signal fires", () =>
  282. Effect.gen(function* () {
  283. const { chat, assistant } = yield* seed()
  284. const tool = yield* TaskTool
  285. const def = yield* tool.init()
  286. const ready = defer<SessionPrompt.PromptInput>()
  287. const cancelled = defer<SessionID>()
  288. const abort = new AbortController()
  289. const promptOps: TaskPromptOps = {
  290. cancel: (sessionID) =>
  291. Effect.sync(() => {
  292. cancelled.resolve(sessionID)
  293. }),
  294. resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
  295. prompt: (input) =>
  296. Effect.promise(() => {
  297. ready.resolve(input)
  298. return cancelled.promise
  299. }).pipe(Effect.as(reply(input, "cancelled"))),
  300. }
  301. const fiber = yield* def
  302. .execute(
  303. {
  304. description: "inspect bug",
  305. prompt: "look into the cache key path",
  306. subagent_type: "general",
  307. },
  308. {
  309. sessionID: chat.id,
  310. messageID: assistant.id,
  311. agent: "build",
  312. abort: abort.signal,
  313. extra: { promptOps },
  314. messages: [],
  315. metadata: () => Effect.void,
  316. ask: () => Effect.void,
  317. },
  318. )
  319. .pipe(Effect.forkChild)
  320. const input = yield* Effect.promise(() => ready.promise)
  321. abort.abort()
  322. expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
  323. const exit = yield* Fiber.await(fiber)
  324. expect(Exit.isSuccess(exit)).toBe(true)
  325. }),
  326. )
  327. it.instance("execute creates a child when task_id does not exist", () =>
  328. Effect.gen(function* () {
  329. const sessions = yield* Session.Service
  330. const { chat, assistant } = yield* seed()
  331. const tool = yield* TaskTool
  332. const def = yield* tool.init()
  333. let seen: SessionPrompt.PromptInput | undefined
  334. const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
  335. const result = yield* def.execute(
  336. {
  337. description: "inspect bug",
  338. prompt: "look into the cache key path",
  339. subagent_type: "general",
  340. task_id: "ses_missing",
  341. },
  342. {
  343. sessionID: chat.id,
  344. messageID: assistant.id,
  345. agent: "build",
  346. abort: new AbortController().signal,
  347. extra: { promptOps },
  348. messages: [],
  349. metadata: () => Effect.void,
  350. ask: () => Effect.void,
  351. },
  352. )
  353. const kids = yield* sessions.children(chat.id)
  354. expect(kids).toHaveLength(1)
  355. expect(kids[0]?.id).toBe(result.metadata.sessionId)
  356. expect(result.metadata.sessionId).not.toBe("ses_missing")
  357. expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
  358. expect(seen?.sessionID).toBe(result.metadata.sessionId)
  359. }),
  360. )
  361. it.instance(
  362. "execute shapes child permissions for task, todowrite, and primary tools",
  363. () =>
  364. Effect.gen(function* () {
  365. const sessions = yield* Session.Service
  366. const { chat, assistant } = yield* seed()
  367. const tool = yield* TaskTool
  368. const def = yield* tool.init()
  369. let seen: SessionPrompt.PromptInput | undefined
  370. const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
  371. const result = yield* def.execute(
  372. {
  373. description: "inspect bug",
  374. prompt: "look into the cache key path",
  375. subagent_type: "reviewer",
  376. },
  377. {
  378. sessionID: chat.id,
  379. messageID: assistant.id,
  380. agent: "build",
  381. abort: new AbortController().signal,
  382. extra: { promptOps },
  383. messages: [],
  384. metadata: () => Effect.void,
  385. ask: () => Effect.void,
  386. },
  387. )
  388. const child = yield* sessions.get(result.metadata.sessionId)
  389. expect(child.parentID).toBe(chat.id)
  390. expect(child.agent).toBe("reviewer")
  391. expect(child.permission).toEqual([
  392. {
  393. permission: "todowrite",
  394. pattern: "*",
  395. action: "deny",
  396. },
  397. {
  398. permission: "bash",
  399. pattern: "*",
  400. action: "deny",
  401. },
  402. {
  403. permission: "read",
  404. pattern: "*",
  405. action: "deny",
  406. },
  407. ])
  408. expect(seen?.tools).toBeUndefined()
  409. }),
  410. {
  411. config: {
  412. agent: {
  413. reviewer: {
  414. mode: "subagent",
  415. permission: {
  416. task: "allow",
  417. },
  418. },
  419. },
  420. experimental: {
  421. primary_tools: ["bash", "read"],
  422. },
  423. },
  424. },
  425. )
  426. it.instance("rejects background execution when the experiment is disabled", () =>
  427. Effect.gen(function* () {
  428. const { chat, assistant } = yield* seed()
  429. const tool = yield* TaskTool
  430. const def = yield* tool.init()
  431. const exit = yield* def
  432. .execute(
  433. {
  434. description: "inspect bug",
  435. prompt: "look into the cache key path",
  436. subagent_type: "general",
  437. background: true,
  438. },
  439. {
  440. sessionID: chat.id,
  441. messageID: assistant.id,
  442. agent: "build",
  443. abort: new AbortController().signal,
  444. extra: { promptOps: stubOps() },
  445. messages: [],
  446. metadata: () => Effect.void,
  447. ask: () => Effect.void,
  448. },
  449. )
  450. .pipe(Effect.exit)
  451. expect(Exit.isFailure(exit)).toBe(true)
  452. }),
  453. )
  454. it.instance("promotes a running foreground task without restarting it", () =>
  455. Effect.gen(function* () {
  456. const jobs = yield* BackgroundJob.Service
  457. const { chat, assistant } = yield* seed()
  458. const tool = yield* TaskTool
  459. const def = yield* tool.init()
  460. const ready = yield* Deferred.make<void>()
  461. const done = yield* Deferred.make<void>()
  462. const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
  463. let runs = 0
  464. const promptOps: TaskPromptOps = {
  465. cancel: () => Effect.void,
  466. resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
  467. prompt: (input) => {
  468. if (input.sessionID === chat.id) {
  469. return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
  470. }
  471. return Effect.gen(function* () {
  472. runs += 1
  473. yield* Deferred.succeed(ready, undefined)
  474. yield* Deferred.await(done)
  475. return reply(input, "background done")
  476. })
  477. },
  478. }
  479. const fiber = yield* def
  480. .execute(
  481. {
  482. description: "inspect bug",
  483. prompt: "look into the cache key path",
  484. subagent_type: "general",
  485. },
  486. {
  487. sessionID: chat.id,
  488. messageID: assistant.id,
  489. agent: "build",
  490. abort: new AbortController().signal,
  491. extra: { promptOps },
  492. messages: [],
  493. metadata: () => Effect.void,
  494. ask: () => Effect.void,
  495. },
  496. )
  497. .pipe(Effect.forkChild)
  498. yield* Deferred.await(ready)
  499. const job = (yield* jobs.list())[0]
  500. expect(job).toBeDefined()
  501. if (!job) throw new Error("task job not found")
  502. expect(job.metadata?.parentSessionId).toBe(chat.id)
  503. yield* jobs.promote(job.id)
  504. const result = yield* Fiber.join(fiber)
  505. expect(result.metadata.background).toBe(true)
  506. expect(result.output).toContain(`state="running"`)
  507. expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
  508. expect(runs).toBe(1)
  509. yield* Deferred.succeed(done, undefined)
  510. expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
  511. expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
  512. expect(runs).toBe(1)
  513. }),
  514. )
  515. background.instance("execute launches background tasks without waiting for completion", () =>
  516. Effect.gen(function* () {
  517. const jobs = yield* BackgroundJob.Service
  518. const { chat, assistant } = yield* seed()
  519. const tool = yield* TaskTool
  520. const def = yield* tool.init()
  521. const result = yield* def.execute(
  522. {
  523. description: "inspect bug",
  524. prompt: "look into the cache key path",
  525. subagent_type: "general",
  526. background: true,
  527. },
  528. {
  529. sessionID: chat.id,
  530. messageID: assistant.id,
  531. agent: "build",
  532. abort: new AbortController().signal,
  533. extra: {
  534. promptOps: {
  535. ...stubOps(),
  536. prompt: () => Effect.never,
  537. } satisfies TaskPromptOps,
  538. },
  539. messages: [],
  540. metadata: () => Effect.void,
  541. ask: () => Effect.void,
  542. },
  543. )
  544. const job = yield* jobs.get(result.metadata.sessionId)
  545. expect(result.metadata.background).toBe(true)
  546. expect(result.output).toContain(`state="running"`)
  547. expect(job?.status).toBe("running")
  548. }),
  549. )
  550. background.instance("background task completion waits for running updates", () =>
  551. Effect.gen(function* () {
  552. const jobs = yield* BackgroundJob.Service
  553. const { chat, assistant } = yield* seed()
  554. const tool = yield* TaskTool
  555. const def = yield* tool.init()
  556. const first = defer<void>()
  557. const second = defer<void>()
  558. const updated = defer<SessionPrompt.PromptInput>()
  559. const injected = defer<SessionPrompt.PromptInput>()
  560. let prompts = 0
  561. const promptOps: TaskPromptOps = {
  562. ...stubOps(),
  563. prompt: (input) => {
  564. if (input.sessionID === chat.id) {
  565. injected.resolve(input)
  566. return Effect.succeed(reply(input, "done"))
  567. }
  568. prompts++
  569. if (prompts === 1) return Effect.promise(() => first.promise).pipe(Effect.as(reply(input, "first done")))
  570. updated.resolve(input)
  571. return Effect.promise(() => second.promise).pipe(Effect.as(reply(input, "second done")))
  572. },
  573. }
  574. const context = {
  575. sessionID: chat.id,
  576. messageID: assistant.id,
  577. agent: "build",
  578. abort: new AbortController().signal,
  579. extra: { promptOps },
  580. messages: [],
  581. metadata: () => Effect.void,
  582. ask: () => Effect.void,
  583. }
  584. const started = yield* def.execute(
  585. {
  586. description: "inspect bug",
  587. prompt: "look into the cache key path",
  588. subagent_type: "general",
  589. background: true,
  590. },
  591. context,
  592. )
  593. const result = yield* def.execute(
  594. {
  595. description: "add investigation scope",
  596. prompt: "also inspect cancellation",
  597. subagent_type: "general",
  598. task_id: started.metadata.sessionId,
  599. },
  600. context,
  601. )
  602. expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
  603. expect(result.metadata.background).toBe(true)
  604. expect(result.output).toContain("Background task updated")
  605. first.resolve()
  606. expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
  607. expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
  608. { type: "text", text: "also inspect cancellation" },
  609. ])
  610. second.resolve()
  611. const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
  612. expect(waited.info?.status).toBe("completed")
  613. expect(waited.info?.output).toBe("second done")
  614. const notification = yield* Effect.promise(() => injected.promise)
  615. expect(notification.variant).toBe("xhigh")
  616. expect(notification.parts[0]?.type).toBe("text")
  617. if (notification.parts[0]?.type === "text") expect(notification.parts[0].text).toContain("second done")
  618. }),
  619. )
  620. background.instance("background tasks complete through the background job service", () =>
  621. Effect.gen(function* () {
  622. const jobs = yield* BackgroundJob.Service
  623. const { chat, assistant } = yield* seed()
  624. const tool = yield* TaskTool
  625. const def = yield* tool.init()
  626. const result = yield* def.execute(
  627. {
  628. description: "inspect bug",
  629. prompt: "look into the cache key path",
  630. subagent_type: "general",
  631. background: true,
  632. },
  633. {
  634. sessionID: chat.id,
  635. messageID: assistant.id,
  636. agent: "build",
  637. abort: new AbortController().signal,
  638. extra: { promptOps: stubOps({ text: "background done" }) },
  639. messages: [],
  640. metadata: () => Effect.void,
  641. ask: () => Effect.void,
  642. },
  643. )
  644. const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
  645. expect(waited.timedOut).toBe(false)
  646. expect(waited.info?.status).toBe("completed")
  647. expect(waited.info?.output).toBe("background done")
  648. }),
  649. )
  650. background.instance("background task completion does not wait for the parent async prompt", () =>
  651. Effect.gen(function* () {
  652. const jobs = yield* BackgroundJob.Service
  653. const { chat, assistant } = yield* seed()
  654. const tool = yield* TaskTool
  655. const def = yield* tool.init()
  656. const result = yield* def.execute(
  657. {
  658. description: "inspect bug",
  659. prompt: "look into the cache key path",
  660. subagent_type: "general",
  661. background: true,
  662. },
  663. {
  664. sessionID: chat.id,
  665. messageID: assistant.id,
  666. agent: "build",
  667. abort: new AbortController().signal,
  668. extra: {
  669. promptOps: {
  670. ...stubOps({ text: "background done" }),
  671. prompt: (input) =>
  672. input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
  673. } satisfies TaskPromptOps,
  674. },
  675. messages: [],
  676. metadata: () => Effect.void,
  677. ask: () => Effect.void,
  678. },
  679. )
  680. const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
  681. expect(waited.timedOut).toBe(false)
  682. expect(waited.info?.status).toBe("completed")
  683. }),
  684. )
  685. background.instance("removing the parent session cancels running background tasks", () =>
  686. Effect.gen(function* () {
  687. const jobs = yield* BackgroundJob.Service
  688. const sessions = yield* Session.Service
  689. const { chat, assistant } = yield* seed()
  690. const tool = yield* TaskTool
  691. const def = yield* tool.init()
  692. const result = yield* def.execute(
  693. {
  694. description: "inspect bug",
  695. prompt: "look into the cache key path",
  696. subagent_type: "general",
  697. background: true,
  698. },
  699. {
  700. sessionID: chat.id,
  701. messageID: assistant.id,
  702. agent: "build",
  703. abort: new AbortController().signal,
  704. extra: {
  705. promptOps: {
  706. ...stubOps(),
  707. prompt: () => Effect.never,
  708. } satisfies TaskPromptOps,
  709. },
  710. messages: [],
  711. metadata: () => Effect.void,
  712. ask: () => Effect.void,
  713. },
  714. )
  715. yield* sessions.remove(chat.id)
  716. const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
  717. expect(waited.timedOut).toBe(false)
  718. expect(waited.info?.status).toBe("cancelled")
  719. }),
  720. )
  721. background.instance("removing the child task session cancels its running background task", () =>
  722. Effect.gen(function* () {
  723. const jobs = yield* BackgroundJob.Service
  724. const sessions = yield* Session.Service
  725. const { chat, assistant } = yield* seed()
  726. const tool = yield* TaskTool
  727. const def = yield* tool.init()
  728. const result = yield* def.execute(
  729. {
  730. description: "inspect bug",
  731. prompt: "look into the cache key path",
  732. subagent_type: "general",
  733. background: true,
  734. },
  735. {
  736. sessionID: chat.id,
  737. messageID: assistant.id,
  738. agent: "build",
  739. abort: new AbortController().signal,
  740. extra: {
  741. promptOps: {
  742. ...stubOps(),
  743. prompt: () => Effect.never,
  744. } satisfies TaskPromptOps,
  745. },
  746. messages: [],
  747. metadata: () => Effect.void,
  748. ask: () => Effect.void,
  749. },
  750. )
  751. yield* sessions.remove(result.metadata.sessionId)
  752. const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
  753. expect(waited.timedOut).toBe(false)
  754. expect(waited.info?.status).toBe("cancelled")
  755. }),
  756. )
  757. background.instance("cancelling the parent run cancels running background tasks", () =>
  758. Effect.gen(function* () {
  759. const jobs = yield* BackgroundJob.Service
  760. const runState = yield* SessionRunState.Service
  761. const { chat, assistant } = yield* seed()
  762. const tool = yield* TaskTool
  763. const def = yield* tool.init()
  764. const result = yield* def.execute(
  765. {
  766. description: "inspect bug",
  767. prompt: "look into the cache key path",
  768. subagent_type: "general",
  769. background: true,
  770. },
  771. {
  772. sessionID: chat.id,
  773. messageID: assistant.id,
  774. agent: "build",
  775. abort: new AbortController().signal,
  776. extra: {
  777. promptOps: {
  778. ...stubOps(),
  779. prompt: () => Effect.never,
  780. } satisfies TaskPromptOps,
  781. },
  782. messages: [],
  783. metadata: () => Effect.void,
  784. ask: () => Effect.void,
  785. },
  786. )
  787. yield* runState.cancel(chat.id)
  788. const waited = yield* jobs.wait({ id: result.metadata.sessionId, timeout: 1_000 })
  789. expect(waited.timedOut).toBe(false)
  790. expect(waited.info?.status).toBe("cancelled")
  791. }),
  792. )
  793. it.instance("cancelling a child run cancels its own pre-runner task job", () =>
  794. Effect.gen(function* () {
  795. const jobs = yield* BackgroundJob.Service
  796. const runState = yield* SessionRunState.Service
  797. const sessions = yield* Session.Service
  798. const { chat } = yield* seed()
  799. const child = yield* sessions.create({ parentID: chat.id, title: "child" })
  800. yield* jobs.start({
  801. id: child.id,
  802. type: "task",
  803. metadata: { parentSessionId: chat.id, sessionId: child.id },
  804. run: Effect.never,
  805. })
  806. yield* runState.cancel(child.id)
  807. expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
  808. }),
  809. )
  810. it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
  811. Effect.gen(function* () {
  812. const jobs = yield* BackgroundJob.Service
  813. const runState = yield* SessionRunState.Service
  814. const sessions = yield* Session.Service
  815. const { chat } = yield* seed()
  816. const child = yield* sessions.create({ parentID: chat.id, title: "child" })
  817. const grandchild = yield* sessions.create({ parentID: child.id, title: "grandchild" })
  818. yield* jobs.start({
  819. id: child.id,
  820. type: "task",
  821. metadata: { parentSessionId: chat.id, sessionId: child.id },
  822. run: Effect.never,
  823. })
  824. yield* jobs.start({
  825. id: grandchild.id,
  826. type: "task",
  827. metadata: { parentSessionId: child.id, sessionId: grandchild.id },
  828. run: Effect.never,
  829. })
  830. yield* runState.cancel(chat.id)
  831. expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
  832. expect((yield* jobs.get(grandchild.id))?.status).toBe("cancelled")
  833. }),
  834. )
  835. })