task.test.ts 23 KB

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