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 { EventV2 } from "@opencode-ai/core/event" import { Location } from "@opencode-ai/core/location" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" import { AgentV2 } from "@opencode-ai/core/agent" import { Job } from "@opencode-ai/core/job" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import { SessionV2 } 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/subagent" import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" import { executeTool, settleTool, toolIdentity, waitForTool } from "./lib/tool" const childText = "child final response" const childModel = ModelV2.Ref.make({ id: ModelV2.ID.make("child"), providerID: ProviderV2.ID.make("test") }) const parentModel = ModelV2.Ref.make({ id: ModelV2.ID.make("parent"), providerID: ProviderV2.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: SessionV2.ID }))(value).sessionID const executionNode = makeGlobalNode({ service: SessionExecution.Service, layer: Layer.effect( SessionExecution.Service, Effect.gen(function* () { const events = yield* EventV2.Service const store = yield* SessionStore.Service const completed = new Set() const complete = Effect.fn("SubagentTest.complete")(function* (sessionID: SessionV2.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* events.publish(SessionEvent.Step.Started, { sessionID, assistantMessageID, agent: AgentV2.ID.make("reviewer"), model: childModel, }) yield* events.publish(SessionEvent.Text.Started, { sessionID, assistantMessageID, ordinal: 0, }) yield* events.publish(SessionEvent.Text.Ended, { sessionID, assistantMessageID, ordinal: 0, text: childText, }) yield* events.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: [EventV2.node, SessionStore.node], }) const layer = AppNodeBuilder.build( LayerNode.group([ Database.node, EventV2.node, Job.node, ToolOutputStore.cleanupNode, SessionV2.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* AgentV2.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(AgentV2.ID.make("reviewer"), (agent) => { agent.mode = "subagent" agent.model = childModel }) draft.update(AgentV2.ID.make("fallback"), (agent) => { agent.mode = "subagent" }) draft.update(AgentV2.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* SessionV2.Service const parent = yield* session.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) expect((yield* registry.materialize()).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({ type: "error", value: "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* SessionV2.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* ToolRegistry.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({ type: "error", value: 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* SessionV2.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* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const settled = yield* settleTool(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({ result: { type: "text", value: childText }, output: { structured: { status: "completed" }, content: [{ type: "text", text: childText }], }, }) expect(settled.output?.structured).toEqual({ sessionID: outputSessionID(settled.output?.structured), status: "completed", }) expect((yield* sessions.get(outputSessionID(settled.output?.structured))).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* SessionV2.Service const parent = yield* sessions.create({ location, model: parentModel }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const progress: ToolRegistry.Progress[] = [] const settled = yield* settleTool(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({ result: { type: "text", value: childText }, output: { structured: { status: "completed" }, content: [{ type: "text", text: childText }], }, }) const child = yield* sessions.get(outputSessionID(settled.output?.structured)) expect(settled.output?.structured).toEqual({ sessionID: child.id, status: "completed" }) expect(progress[0]?.structured).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* settleTool(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.output?.structured)) 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* SessionV2.Service const parent = yield* sessions.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.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({ type: "error", value: 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* SessionV2.Service const parent = yield* sessions.create({ location }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* ToolRegistry.Service.pipe(Effect.provide(locations.get(parent.location))) yield* waitForTool(registry, SubagentTool.name) const events = yield* EventV2.Service const admitted = yield* events.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* settleTool(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.output?.structured) expect(settled.output?.structured).toMatchObject({ status: "running", }) expect(settled.output?.structured).toEqual({ sessionID: childID, status: "running" }) expect(settled.result).toEqual({ type: "text", value: expect.stringContaining(`id: ${childID}`) }) expect(settled.output?.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(`