tool-subagent.test.ts 17 KB

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