tool-subagent.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. import { describe, expect } from "bun:test"
  2. import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
  3. import path from "path"
  4. import { Money } from "@opencode-ai/schema/money"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  7. import { Global } from "@opencode-ai/util/global"
  8. import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
  9. import { Database } from "@opencode-ai/core/database/database"
  10. import { Bus } from "@opencode-ai/core/bus"
  11. import { Location } from "@opencode-ai/core/location"
  12. import { Model } from "@opencode-ai/core/model"
  13. import { Provider } from "@opencode-ai/core/provider"
  14. import { AbsolutePath } from "@opencode-ai/core/schema"
  15. import { Agent } from "@opencode-ai/core/agent"
  16. import { Job } from "@opencode-ai/core/job"
  17. import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
  18. import { Session } from "@opencode-ai/core/session"
  19. import { SessionEvent } from "@opencode-ai/core/session/event"
  20. import { SessionExecution } from "@opencode-ai/core/session/execution"
  21. import { SessionPending } from "@opencode-ai/core/session/pending"
  22. import { SessionMessage } from "@opencode-ai/core/session/message"
  23. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  24. import { SessionStore } from "@opencode-ai/core/session/store"
  25. import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
  26. import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
  27. import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent"
  28. import { Tool } from "@opencode-ai/core/tool"
  29. import { tmpdir } from "./fixture/tmpdir"
  30. import { tempGlobalLayer } from "./fixture/global"
  31. import { testEffect } from "./lib/effect"
  32. import { executeTool, toolIdentity, waitForTool } from "./lib/tool"
  33. const childText = "child final response"
  34. const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") })
  35. const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
  36. const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
  37. const outputSessionID = (value: unknown) =>
  38. Schema.decodeUnknownSync(Schema.Struct({ sessionID: Session.ID }))(value).sessionID
  39. const executionNode = makeGlobalNode({
  40. service: SessionExecution.Service,
  41. layer: Layer.effect(
  42. SessionExecution.Service,
  43. Effect.gen(function* () {
  44. const bus = yield* Bus.Service
  45. const store = yield* SessionStore.Service
  46. const completed = new Set<Session.ID>()
  47. const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) {
  48. if (completed.has(sessionID)) return
  49. if ((yield* store.get(sessionID))?.title?.includes("fail")) {
  50. yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID })
  51. return
  52. }
  53. completed.add(sessionID)
  54. const assistantMessageID = SessionMessage.ID.create()
  55. yield* bus.publish(SessionEvent.Step.Started, {
  56. sessionID,
  57. assistantMessageID,
  58. agent: Agent.ID.make("reviewer"),
  59. model: childModel,
  60. })
  61. yield* bus.publish(SessionEvent.Text.Started, {
  62. sessionID,
  63. assistantMessageID,
  64. ordinal: 0,
  65. })
  66. yield* bus.publish(SessionEvent.Text.Ended, {
  67. sessionID,
  68. assistantMessageID,
  69. ordinal: 0,
  70. text: childText,
  71. })
  72. yield* bus.publish(SessionEvent.Step.Ended, {
  73. sessionID,
  74. assistantMessageID,
  75. finish: "stop",
  76. cost: Money.USD.zero,
  77. tokens,
  78. })
  79. })
  80. return SessionExecution.Service.of({
  81. active: Effect.succeed(new Set()),
  82. resume: complete,
  83. wake: () => Effect.void,
  84. interrupt: () => Effect.void,
  85. awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid),
  86. })
  87. }),
  88. ),
  89. deps: [Bus.node, SessionStore.node],
  90. })
  91. const layer = AppNodeBuilder.build(
  92. LayerNode.group([
  93. Database.node,
  94. Bus.node,
  95. Job.node,
  96. Session.node,
  97. SessionExecution.node,
  98. PluginRuntime.providerNode,
  99. LocationServiceMap.node,
  100. ]),
  101. [
  102. [SessionExecution.node, executionNode],
  103. [Global.node, tempGlobalLayer],
  104. ],
  105. )
  106. const it = testEffect(layer)
  107. const withSubagent = (location: Location.Ref) =>
  108. Effect.gen(function* () {
  109. const locations = yield* LocationServiceMap.Service
  110. yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location)))
  111. yield* Agent.Service.use((agents) =>
  112. agents.transform((draft) => {
  113. // The caller identity used by executeTool; subagent permission asserts against it.
  114. draft.update(toolIdentity.agent, (agent) => {
  115. agent.mode = "primary"
  116. agent.permissions.push({ action: "*", resource: "*", effect: "allow" })
  117. })
  118. draft.update(Agent.ID.make("reviewer"), (agent) => {
  119. agent.mode = "subagent"
  120. agent.model = childModel
  121. })
  122. draft.update(Agent.ID.make("fallback"), (agent) => {
  123. agent.mode = "subagent"
  124. })
  125. draft.update(Agent.ID.make("primary"), (agent) => {
  126. agent.mode = "primary"
  127. })
  128. }),
  129. ).pipe(Effect.provide(locations.get(location)))
  130. })
  131. describe("SubagentTool", () => {
  132. it.live("registers globally while resolving agents from the caller location", () =>
  133. Effect.acquireRelease(
  134. Effect.promise(() => tmpdir()),
  135. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  136. ).pipe(
  137. Effect.flatMap((dir) =>
  138. Effect.gen(function* () {
  139. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  140. const session = yield* Session.Service
  141. const parent = yield* session.create({ location })
  142. yield* withSubagent(parent.location)
  143. const locations = yield* LocationServiceMap.Service
  144. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  145. yield* waitForTool(registry, SubagentTool.name)
  146. expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name)
  147. expect(
  148. yield* executeTool(registry, {
  149. sessionID: parent.id,
  150. ...toolIdentity,
  151. call: {
  152. type: "tool-call",
  153. id: "call-primary",
  154. name: SubagentTool.name,
  155. input: { agent: "primary", description: "primary", prompt: "should fail" },
  156. },
  157. }),
  158. ).toEqual({
  159. status: "error",
  160. error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" },
  161. })
  162. }),
  163. ),
  164. ),
  165. )
  166. it.live("prevents subagents from launching subagents by default", () =>
  167. Effect.acquireRelease(
  168. Effect.promise(() => tmpdir()),
  169. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  170. ).pipe(
  171. Effect.flatMap((dir) =>
  172. Effect.gen(function* () {
  173. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  174. const sessions = yield* Session.Service
  175. const root = yield* sessions.create({ location })
  176. const parent = yield* sessions.create({ parentID: root.id, title: "parent" })
  177. yield* withSubagent(parent.location)
  178. const locations = yield* LocationServiceMap.Service
  179. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  180. yield* waitForTool(registry, SubagentTool.name)
  181. expect(
  182. yield* executeTool(registry, {
  183. sessionID: parent.id,
  184. ...toolIdentity,
  185. call: {
  186. type: "tool-call",
  187. id: "call-nested-subagent",
  188. name: SubagentTool.name,
  189. input: { agent: "reviewer", description: "nested", prompt: "should fail" },
  190. },
  191. }),
  192. ).toEqual({
  193. status: "error",
  194. error: {
  195. type: "tool.execution",
  196. message: expect.stringContaining("Subagent depth limit reached (1)"),
  197. },
  198. })
  199. expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0)
  200. }),
  201. ),
  202. ),
  203. )
  204. it.live("allows nested subagents up to the configured depth", () =>
  205. Effect.acquireRelease(
  206. Effect.promise(() => tmpdir()),
  207. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  208. ).pipe(
  209. Effect.flatMap((dir) =>
  210. Effect.gen(function* () {
  211. yield* Effect.promise(() =>
  212. Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_depth: 2 } })),
  213. )
  214. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  215. const sessions = yield* Session.Service
  216. const root = yield* sessions.create({ location })
  217. const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel })
  218. yield* withSubagent(parent.location)
  219. const locations = yield* LocationServiceMap.Service
  220. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  221. yield* waitForTool(registry, SubagentTool.name)
  222. const settled = yield* executeTool(registry, {
  223. sessionID: parent.id,
  224. ...toolIdentity,
  225. call: {
  226. type: "tool-call",
  227. id: "call-configured-nested-subagent",
  228. name: SubagentTool.name,
  229. input: { agent: "reviewer", description: "nested", prompt: "should run" },
  230. },
  231. })
  232. expect(settled).toMatchObject({
  233. status: "completed",
  234. metadata: { status: "completed" },
  235. content: [{ type: "text", text: childText }],
  236. })
  237. expect(settled.metadata).toEqual({
  238. sessionID: outputSessionID(settled.metadata),
  239. status: "completed",
  240. })
  241. expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id)
  242. }),
  243. ),
  244. ),
  245. )
  246. it.live("runs a foreground child session and returns the final assistant text", () =>
  247. Effect.acquireRelease(
  248. Effect.promise(() => tmpdir()),
  249. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  250. ).pipe(
  251. Effect.flatMap((dir) =>
  252. Effect.gen(function* () {
  253. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  254. const sessions = yield* Session.Service
  255. const parent = yield* sessions.create({ location, model: parentModel })
  256. yield* withSubagent(parent.location)
  257. const locations = yield* LocationServiceMap.Service
  258. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  259. yield* waitForTool(registry, SubagentTool.name)
  260. const progress: Tool.Metadata[] = []
  261. const settled = yield* executeTool(registry, {
  262. sessionID: parent.id,
  263. ...toolIdentity,
  264. progress: (update) => Effect.sync(() => progress.push(update)),
  265. call: {
  266. type: "tool-call",
  267. id: "call-subagent",
  268. name: SubagentTool.name,
  269. input: { agent: "reviewer", description: "review", prompt: "review this" },
  270. },
  271. })
  272. expect(settled).toMatchObject({
  273. status: "completed",
  274. metadata: { status: "completed" },
  275. content: [{ type: "text", text: childText }],
  276. })
  277. const child = yield* sessions.get(outputSessionID(settled.metadata))
  278. expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" })
  279. expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" })
  280. expect(child).toMatchObject({
  281. parentID: parent.id,
  282. location: parent.location,
  283. agent: "reviewer",
  284. model: childModel,
  285. })
  286. expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe(
  287. "You are a subagent spawned by another session.\nreview this",
  288. )
  289. const fallback = yield* executeTool(registry, {
  290. sessionID: parent.id,
  291. ...toolIdentity,
  292. call: {
  293. type: "tool-call",
  294. id: "call-subagent-fallback",
  295. name: SubagentTool.name,
  296. input: { agent: "fallback", description: "fallback", prompt: "fallback" },
  297. },
  298. })
  299. const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata))
  300. expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel })
  301. }),
  302. ),
  303. ),
  304. )
  305. it.live("returns child runner failures as tool errors", () =>
  306. Effect.acquireRelease(
  307. Effect.promise(() => tmpdir()),
  308. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  309. ).pipe(
  310. Effect.flatMap((dir) =>
  311. Effect.gen(function* () {
  312. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  313. const sessions = yield* Session.Service
  314. const parent = yield* sessions.create({ location })
  315. yield* withSubagent(parent.location)
  316. const locations = yield* LocationServiceMap.Service
  317. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  318. yield* waitForTool(registry, SubagentTool.name)
  319. expect(
  320. yield* executeTool(registry, {
  321. sessionID: parent.id,
  322. ...toolIdentity,
  323. call: {
  324. type: "tool-call",
  325. id: "call-subagent-failure",
  326. name: SubagentTool.name,
  327. input: { agent: "reviewer", description: "fail review", prompt: "please fail" },
  328. },
  329. }),
  330. ).toEqual({
  331. status: "error",
  332. error: {
  333. type: "tool.execution",
  334. message: expect.stringContaining("No model is available for session"),
  335. },
  336. })
  337. }),
  338. ),
  339. ),
  340. )
  341. it.live("notifies once when background work completes", () =>
  342. Effect.acquireRelease(
  343. Effect.promise(() => tmpdir()),
  344. (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
  345. ).pipe(
  346. Effect.flatMap((dir) =>
  347. Effect.gen(function* () {
  348. const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) })
  349. const sessions = yield* Session.Service
  350. const parent = yield* sessions.create({ location })
  351. yield* withSubagent(parent.location)
  352. const locations = yield* LocationServiceMap.Service
  353. const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location)))
  354. yield* waitForTool(registry, SubagentTool.name)
  355. const bus = yield* Bus.Service
  356. const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe(
  357. Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"),
  358. Stream.take(1),
  359. Stream.runCollect,
  360. Effect.forkScoped({ startImmediately: true }),
  361. )
  362. const settled = yield* executeTool(registry, {
  363. sessionID: parent.id,
  364. ...toolIdentity,
  365. call: {
  366. type: "tool-call",
  367. id: "call-background-subagent",
  368. name: SubagentTool.name,
  369. input: { agent: "reviewer", description: "background review", prompt: "review", background: true },
  370. },
  371. })
  372. const childID = outputSessionID(settled.metadata)
  373. expect(settled.metadata).toMatchObject({
  374. status: "running",
  375. })
  376. expect(settled.metadata).toEqual({ sessionID: childID, status: "running" })
  377. expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }])
  378. const admission = Array.from(yield* Fiber.join(admitted))[0]
  379. expect(admission?.data.input.data.text).toContain(`<subagent id="${childID}" state="completed"`)
  380. expect(admission?.data.input.data).toMatchObject({
  381. description: "background review",
  382. metadata: {
  383. source: "subagent",
  384. childID,
  385. agent: "reviewer",
  386. state: "completed",
  387. },
  388. })
  389. const database = yield* Database.Service
  390. yield* SessionPending.promote(database.db, bus, parent.id, "steer")
  391. const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
  392. expect(synthetic).toHaveLength(1)
  393. expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
  394. expect(synthetic[0]?.text).toContain(childText)
  395. }),
  396. ),
  397. ),
  398. )
  399. })