import { describe, expect } from "bun:test" import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect" import path from "path" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { Database } from "@opencode-ai/core/database/database" import { Bus } from "@opencode-ai/core/bus" import { Location } from "@opencode-ai/core/location" import { Model } from "@opencode-ai/core/model" import { Provider } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { Agent } from "@opencode-ai/core/agent" import { Job } from "@opencode-ai/core/job" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { Session } from "@opencode-ai/core/session" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionPending } from "@opencode-ai/core/session/pending" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" import { SessionStore } from "@opencode-ai/core/session/store" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor" import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent" import { Tool } from "@opencode-ai/core/tool" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { executeTool, toolIdentity, waitForTool } from "./lib/tool" const childText = "child final response" const childModel = Model.Ref.make({ id: Model.ID.make("child"), providerID: Provider.ID.make("test") }) const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") }) const tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } const outputSessionID = (value: unknown) => Schema.decodeUnknownSync(Schema.Struct({ sessionID: Session.ID }))(value).sessionID const executionNode = makeGlobalNode({ service: SessionExecution.Service, layer: Layer.effect( SessionExecution.Service, Effect.gen(function* () { const bus = yield* Bus.Service const store = yield* SessionStore.Service const completed = new Set() const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: Session.ID) { if (completed.has(sessionID)) return if ((yield* store.get(sessionID))?.title.includes("fail")) { yield* new SessionRunnerModel.ModelNotSelectedError({ sessionID }) return } completed.add(sessionID) const assistantMessageID = SessionMessage.ID.create() yield* bus.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, agent: Agent.ID.make("reviewer"), model: childModel, }) yield* bus.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 0, }) yield* bus.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 0, text: childText, }) yield* bus.publish(SessionEvent.Step.Ended, { sessionID, assistantMessageID, finish: "stop", cost: Money.USD.zero, tokens, }) }) return SessionExecution.Service.of({ active: Effect.succeed(new Set()), resume: complete, wake: () => Effect.void, interrupt: () => Effect.void, awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid), }) }), ), deps: [Bus.node, SessionStore.node], }) const layer = AppNodeBuilder.build( LayerNode.group([ Database.node, Bus.node, Job.node, Session.node, SessionExecution.node, PluginRuntime.providerNode, LocationServiceMap.node, ]), [[SessionExecution.node, executionNode]], ) const it = testEffect(layer) const withSubagent = (location: Location.Ref) => Effect.gen(function* () { const locations = yield* LocationServiceMap.Service yield* PluginSupervisor.Service.use((supervisor) => supervisor.flush).pipe(Effect.provide(locations.get(location))) yield* Agent.Service.use((agents) => agents.transform((draft) => { // The caller identity used by executeTool; subagent permission asserts against it. draft.update(toolIdentity.agent, (agent) => { agent.mode = "primary" agent.permissions.push({ action: "*", resource: "*", effect: "allow" }) }) draft.update(Agent.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel }) draft.update(Agent.ID.make("fallback"), (agent) => { agent.mode = "subagent" }) draft.update(Agent.ID.make("primary"), (agent) => { agent.mode = "primary" }) }), ).pipe(Effect.provide(locations.get(location))) }) describe("SubagentTool", () => { it.live("registers globally while resolving agents from the caller location", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const session = yield* Session.Service const parent = yield* session.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) expect((yield* registry.snapshot()).definitions.map((tool) => tool.name)).toContain(SubagentTool.name) expect( yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-primary", name: SubagentTool.name, input: { agent: "primary", description: "primary", prompt: "should fail" }, }, }), ).toEqual({ status: "error", error: { type: "tool.execution", message: "Agent primary cannot run as a subagent" }, }) }), ), ), ) it.live("prevents subagents from launching subagents by default", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* Session.Service const root = yield* sessions.create({ location }) const parent = yield* sessions.create({ parentID: root.id, title: "parent" }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) expect( yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-nested-subagent", name: SubagentTool.name, input: { agent: "reviewer", description: "nested", prompt: "should fail" }, }, }), ).toEqual({ status: "error", error: { type: "tool.execution", message: expect.stringContaining("Subagent depth limit reached (1)"), }, }) expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(0) }), ), ), ) it.live("allows nested subagents up to the configured depth", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { yield* Effect.promise(() => Bun.write(path.join(dir.path, "opencode.json"), JSON.stringify({ experimental: { subagent_depth: 2 } })), ) const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* Session.Service const root = yield* sessions.create({ location }) const parent = yield* sessions.create({ parentID: root.id, title: "parent", model: parentModel }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-configured-nested-subagent", name: SubagentTool.name, input: { agent: "reviewer", description: "nested", prompt: "should run" }, }, }) expect(settled).toMatchObject({ status: "completed", metadata: { status: "completed" }, content: [{ type: "text", text: childText }], }) expect(settled.metadata).toEqual({ sessionID: outputSessionID(settled.metadata), status: "completed", }) expect((yield* sessions.get(outputSessionID(settled.metadata))).parentID).toBe(parent.id) }), ), ), ) it.live("runs a foreground child session and returns the final assistant text", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* Session.Service const parent = yield* sessions.create({ location, model: parentModel }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const progress: Tool.Metadata[] = [] const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, progress: (update) => Effect.sync(() => progress.push(update)), call: { type: "tool-call", id: "call-subagent", name: SubagentTool.name, input: { agent: "reviewer", description: "review", prompt: "review this" }, }, }) expect(settled).toMatchObject({ status: "completed", metadata: { status: "completed" }, content: [{ type: "text", text: childText }], }) const child = yield* sessions.get(outputSessionID(settled.metadata)) expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) expect(progress[0]?.metadata).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, location: parent.location, agent: "reviewer", model: childModel, }) expect((yield* sessions.pending(child.id)).find((message) => message.type === "user")?.data.text).toBe( "You are a subagent spawned by another session.\nreview this", ) const fallback = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-subagent-fallback", name: SubagentTool.name, input: { agent: "fallback", description: "fallback", prompt: "fallback" }, }, }) const fallbackChild = yield* sessions.get(outputSessionID(fallback.metadata)) expect(fallbackChild).toMatchObject({ parentID: parent.id, model: parentModel }) }), ), ), ) it.live("returns child runner failures as tool errors", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* Session.Service const parent = yield* sessions.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) expect( yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-subagent-failure", name: SubagentTool.name, input: { agent: "reviewer", description: "fail review", prompt: "please fail" }, }, }), ).toEqual({ status: "error", error: { type: "tool.execution", message: expect.stringContaining("No model is available for session"), }, }) }), ), ), ) it.live("notifies once when background work completes", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const location = Location.Ref.make({ directory: AbsolutePath.make(dir.path) }) const sessions = yield* Session.Service const parent = yield* sessions.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const bus = yield* Bus.Service const admitted = yield* bus.subscribe(SessionEvent.InputAdmitted).pipe( Stream.filter((event) => event.data.sessionID === parent.id && event.data.input.type === "synthetic"), Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true }), ) const settled = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-background-subagent", name: SubagentTool.name, input: { agent: "reviewer", description: "background review", prompt: "review", background: true }, }, }) const childID = outputSessionID(settled.metadata) expect(settled.metadata).toMatchObject({ status: "running", }) expect(settled.metadata).toEqual({ sessionID: childID, status: "running" }) expect(settled.content).toEqual([{ type: "text", text: expect.stringContaining(`id: ${childID}`) }]) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.input.data.text).toContain(` message.type === "synthetic") expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(`