task.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  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 { Config } from "@/config/config"
  5. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  6. import { Session } from "@/session/session"
  7. import { MessageV2 } from "../../src/session/message-v2"
  8. import type { SessionPrompt } from "../../src/session/prompt"
  9. import { MessageID, PartID, SessionID } from "../../src/session/schema"
  10. import { ModelID, ProviderID } from "../../src/provider/schema"
  11. import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
  12. import { Truncate } from "@/tool/truncate"
  13. import { ToolRegistry } from "@/tool/registry"
  14. import { disposeAllInstances } from "../fixture/fixture"
  15. import { testEffect } from "../lib/effect"
  16. afterEach(async () => {
  17. await disposeAllInstances()
  18. })
  19. const ref = {
  20. providerID: ProviderID.make("test"),
  21. modelID: ModelID.make("test-model"),
  22. }
  23. const it = testEffect(
  24. Layer.mergeAll(
  25. Agent.defaultLayer,
  26. Config.defaultLayer,
  27. CrossSpawnSpawner.defaultLayer,
  28. Session.defaultLayer,
  29. Truncate.defaultLayer,
  30. ToolRegistry.defaultLayer,
  31. ),
  32. )
  33. function defer<T>() {
  34. let resolve!: (value: T | PromiseLike<T>) => void
  35. const promise = new Promise<T>((done) => {
  36. resolve = done
  37. })
  38. return { promise, resolve }
  39. }
  40. const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
  41. const session = yield* Session.Service
  42. const chat = yield* session.create({ title })
  43. const user = yield* session.updateMessage({
  44. id: MessageID.ascending(),
  45. role: "user",
  46. sessionID: chat.id,
  47. agent: "build",
  48. model: ref,
  49. time: { created: Date.now() },
  50. })
  51. const assistant: MessageV2.Assistant = {
  52. id: MessageID.ascending(),
  53. role: "assistant",
  54. parentID: user.id,
  55. sessionID: chat.id,
  56. mode: "build",
  57. agent: "build",
  58. cost: 0,
  59. path: { cwd: "/tmp", root: "/tmp" },
  60. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  61. modelID: ref.modelID,
  62. providerID: ref.providerID,
  63. time: { created: Date.now() },
  64. }
  65. yield* session.updateMessage(assistant)
  66. return { chat, assistant }
  67. })
  68. function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
  69. return {
  70. cancel: () => Effect.void,
  71. resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
  72. prompt: (input) =>
  73. Effect.sync(() => {
  74. opts?.onPrompt?.(input)
  75. return reply(input, opts?.text ?? "done")
  76. }),
  77. }
  78. }
  79. function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts {
  80. const id = MessageID.ascending()
  81. return {
  82. info: {
  83. id,
  84. role: "assistant",
  85. parentID: input.messageID ?? MessageID.ascending(),
  86. sessionID: input.sessionID,
  87. mode: input.agent ?? "general",
  88. agent: input.agent ?? "general",
  89. cost: 0,
  90. path: { cwd: "/tmp", root: "/tmp" },
  91. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  92. modelID: input.model?.modelID ?? ref.modelID,
  93. providerID: input.model?.providerID ?? ref.providerID,
  94. time: { created: Date.now() },
  95. finish: "stop",
  96. },
  97. parts: [
  98. {
  99. id: PartID.ascending(),
  100. messageID: id,
  101. sessionID: input.sessionID,
  102. type: "text",
  103. text,
  104. },
  105. ],
  106. }
  107. }
  108. describe("tool.task", () => {
  109. it.instance(
  110. "description sorts subagents by name and is stable across calls",
  111. () =>
  112. Effect.gen(function* () {
  113. const agent = yield* Agent.Service
  114. const build = yield* agent.get("build")
  115. const registry = yield* ToolRegistry.Service
  116. const get = Effect.fnUntraced(function* () {
  117. const tools = yield* registry.tools({ ...ref, agent: build })
  118. return tools.find((tool) => tool.id === TaskTool.id)?.description ?? ""
  119. })
  120. const first = yield* get()
  121. const second = yield* get()
  122. expect(first).toBe(second)
  123. const alpha = first.indexOf("- alpha: Alpha agent")
  124. const explore = first.indexOf("- explore:")
  125. const general = first.indexOf("- general:")
  126. const zebra = first.indexOf("- zebra: Zebra agent")
  127. expect(alpha).toBeGreaterThan(-1)
  128. expect(explore).toBeGreaterThan(alpha)
  129. expect(general).toBeGreaterThan(explore)
  130. expect(zebra).toBeGreaterThan(general)
  131. }),
  132. {
  133. config: {
  134. agent: {
  135. zebra: {
  136. description: "Zebra agent",
  137. mode: "subagent",
  138. },
  139. alpha: {
  140. description: "Alpha agent",
  141. mode: "subagent",
  142. },
  143. },
  144. },
  145. },
  146. )
  147. it.instance(
  148. "description hides denied subagents for the caller",
  149. () =>
  150. Effect.gen(function* () {
  151. const agent = yield* Agent.Service
  152. const build = yield* agent.get("build")
  153. const registry = yield* ToolRegistry.Service
  154. const description =
  155. (yield* registry.tools({ ...ref, agent: build })).find((tool) => tool.id === TaskTool.id)?.description ?? ""
  156. expect(description).toContain("- alpha: Alpha agent")
  157. expect(description).not.toContain("- zebra: Zebra agent")
  158. }),
  159. {
  160. config: {
  161. permission: {
  162. task: {
  163. "*": "allow",
  164. zebra: "deny",
  165. },
  166. },
  167. agent: {
  168. zebra: {
  169. description: "Zebra agent",
  170. mode: "subagent",
  171. },
  172. alpha: {
  173. description: "Alpha agent",
  174. mode: "subagent",
  175. },
  176. },
  177. },
  178. },
  179. )
  180. it.instance("execute resumes an existing task session from task_id", () =>
  181. Effect.gen(function* () {
  182. const sessions = yield* Session.Service
  183. const { chat, assistant } = yield* seed()
  184. const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
  185. const tool = yield* TaskTool
  186. const def = yield* tool.init()
  187. let seen: SessionPrompt.PromptInput | undefined
  188. const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
  189. const result = yield* def.execute(
  190. {
  191. description: "inspect bug",
  192. prompt: "look into the cache key path",
  193. subagent_type: "general",
  194. task_id: child.id,
  195. },
  196. {
  197. sessionID: chat.id,
  198. messageID: assistant.id,
  199. agent: "build",
  200. abort: new AbortController().signal,
  201. extra: { promptOps },
  202. messages: [],
  203. metadata: () => Effect.void,
  204. ask: () => Effect.void,
  205. },
  206. )
  207. const kids = yield* sessions.children(chat.id)
  208. expect(kids).toHaveLength(1)
  209. expect(kids[0]?.id).toBe(child.id)
  210. expect(result.metadata.sessionId).toBe(child.id)
  211. expect(result.output).toContain(`task_id: ${child.id}`)
  212. expect(seen?.sessionID).toBe(child.id)
  213. }),
  214. )
  215. it.instance("execute asks by default and skips checks when bypassed", () =>
  216. Effect.gen(function* () {
  217. const { chat, assistant } = yield* seed()
  218. const tool = yield* TaskTool
  219. const def = yield* tool.init()
  220. const calls: unknown[] = []
  221. const promptOps = stubOps()
  222. const exec = (extra?: Record<string, any>) =>
  223. def.execute(
  224. {
  225. description: "inspect bug",
  226. prompt: "look into the cache key path",
  227. subagent_type: "general",
  228. },
  229. {
  230. sessionID: chat.id,
  231. messageID: assistant.id,
  232. agent: "build",
  233. abort: new AbortController().signal,
  234. extra: { promptOps, ...extra },
  235. messages: [],
  236. metadata: () => Effect.void,
  237. ask: (input) =>
  238. Effect.sync(() => {
  239. calls.push(input)
  240. }),
  241. },
  242. )
  243. yield* exec()
  244. yield* exec({ bypassAgentCheck: true })
  245. expect(calls).toHaveLength(1)
  246. expect(calls[0]).toEqual({
  247. permission: "task",
  248. patterns: ["general"],
  249. always: ["*"],
  250. metadata: {
  251. description: "inspect bug",
  252. subagent_type: "general",
  253. },
  254. })
  255. }),
  256. )
  257. it.instance("execute cancels child session when abort signal fires", () =>
  258. Effect.gen(function* () {
  259. const { chat, assistant } = yield* seed()
  260. const tool = yield* TaskTool
  261. const def = yield* tool.init()
  262. const ready = defer<SessionPrompt.PromptInput>()
  263. const cancelled = defer<SessionID>()
  264. const abort = new AbortController()
  265. const promptOps: TaskPromptOps = {
  266. cancel: (sessionID) =>
  267. Effect.sync(() => {
  268. cancelled.resolve(sessionID)
  269. }),
  270. resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
  271. prompt: (input) =>
  272. Effect.promise(() => {
  273. ready.resolve(input)
  274. return cancelled.promise
  275. }).pipe(Effect.as(reply(input, "cancelled"))),
  276. }
  277. const fiber = yield* def
  278. .execute(
  279. {
  280. description: "inspect bug",
  281. prompt: "look into the cache key path",
  282. subagent_type: "general",
  283. },
  284. {
  285. sessionID: chat.id,
  286. messageID: assistant.id,
  287. agent: "build",
  288. abort: abort.signal,
  289. extra: { promptOps },
  290. messages: [],
  291. metadata: () => Effect.void,
  292. ask: () => Effect.void,
  293. },
  294. )
  295. .pipe(Effect.forkChild)
  296. const input = yield* Effect.promise(() => ready.promise)
  297. abort.abort()
  298. expect(yield* Effect.promise(() => cancelled.promise)).toBe(input.sessionID)
  299. const exit = yield* Fiber.await(fiber)
  300. expect(Exit.isSuccess(exit)).toBe(true)
  301. }),
  302. )
  303. it.instance("execute creates a child when task_id does not exist", () =>
  304. Effect.gen(function* () {
  305. const sessions = yield* Session.Service
  306. const { chat, assistant } = yield* seed()
  307. const tool = yield* TaskTool
  308. const def = yield* tool.init()
  309. let seen: SessionPrompt.PromptInput | undefined
  310. const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
  311. const result = yield* def.execute(
  312. {
  313. description: "inspect bug",
  314. prompt: "look into the cache key path",
  315. subagent_type: "general",
  316. task_id: "ses_missing",
  317. },
  318. {
  319. sessionID: chat.id,
  320. messageID: assistant.id,
  321. agent: "build",
  322. abort: new AbortController().signal,
  323. extra: { promptOps },
  324. messages: [],
  325. metadata: () => Effect.void,
  326. ask: () => Effect.void,
  327. },
  328. )
  329. const kids = yield* sessions.children(chat.id)
  330. expect(kids).toHaveLength(1)
  331. expect(kids[0]?.id).toBe(result.metadata.sessionId)
  332. expect(result.metadata.sessionId).not.toBe("ses_missing")
  333. expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
  334. expect(seen?.sessionID).toBe(result.metadata.sessionId)
  335. }),
  336. )
  337. it.instance(
  338. "execute shapes child permissions for task, todowrite, and primary tools",
  339. () =>
  340. Effect.gen(function* () {
  341. const sessions = yield* Session.Service
  342. const { chat, assistant } = yield* seed()
  343. const tool = yield* TaskTool
  344. const def = yield* tool.init()
  345. let seen: SessionPrompt.PromptInput | undefined
  346. const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
  347. const result = yield* def.execute(
  348. {
  349. description: "inspect bug",
  350. prompt: "look into the cache key path",
  351. subagent_type: "reviewer",
  352. },
  353. {
  354. sessionID: chat.id,
  355. messageID: assistant.id,
  356. agent: "build",
  357. abort: new AbortController().signal,
  358. extra: { promptOps },
  359. messages: [],
  360. metadata: () => Effect.void,
  361. ask: () => Effect.void,
  362. },
  363. )
  364. const child = yield* sessions.get(result.metadata.sessionId)
  365. expect(child.parentID).toBe(chat.id)
  366. expect(child.permission).toEqual([
  367. {
  368. permission: "todowrite",
  369. pattern: "*",
  370. action: "deny",
  371. },
  372. {
  373. permission: "bash",
  374. pattern: "*",
  375. action: "allow",
  376. },
  377. {
  378. permission: "read",
  379. pattern: "*",
  380. action: "allow",
  381. },
  382. ])
  383. expect(seen?.tools).toEqual({
  384. todowrite: false,
  385. bash: false,
  386. read: false,
  387. })
  388. }),
  389. {
  390. config: {
  391. agent: {
  392. reviewer: {
  393. mode: "subagent",
  394. permission: {
  395. task: "allow",
  396. },
  397. },
  398. },
  399. experimental: {
  400. primary_tools: ["bash", "read"],
  401. },
  402. },
  403. },
  404. )
  405. })