import { describe, expect } from "bun:test" import { Deferred, Effect, Fiber, Layer, Schema, Stream } from "effect" import { LanguageModel } from "@opencode-ai/ai" import { OpenAIChat } from "@opencode-ai/ai/protocols" import { TestLLM } from "@opencode-ai/ai/testing" import path from "path" import { Money } from "@opencode-ai/schema/money" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { Global } from "@opencode-ai/util/global" import { makeGlobalNode, makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Database } from "@opencode-ai/core/database/database" import { Bus } from "@opencode-ai/core/bus" import { Config } from "@opencode-ai/core/config" 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 { KV } from "@opencode-ai/core/kv" 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 { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { SessionInbox } from "@opencode-ai/core/session/inbox" 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 { Permission } from "@opencode-ai/core/permission" import { SubagentTool } from "@opencode-ai/core/tool/plugin/subagent" import { Tool } from "@opencode-ai/core/tool" import { tmpdir } from "./fixture/tmpdir" import { tempGlobalLayer } from "./fixture/global" import { testEffect } from "./lib/effect" import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool" const childText = "child final response" const completedOutput = (sessionID: Session.ID) => `\n${childText}\n` 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()), isActive: () => Effect.succeed(false), resume: complete, wake: () => Effect.void, interrupt: () => Effect.succeed(false), awaitIdle: (sessionID) => complete(sessionID).pipe(Effect.exit, Effect.asVoid), }) }), ), deps: [Bus.node, SessionStore.node], }) const subagentPluginSupervisor = makeLocationNode({ service: PluginSupervisor.Service, layer: Layer.effect( PluginSupervisor.Service, registerToolPlugin(SubagentTool.Plugin).pipe(Effect.as(PluginSupervisor.Service.of({ flush: Effect.void }))), ), deps: [Agent.node, Config.node, Permission.node, PluginRuntime.node, Tool.node], }) const nodes = LayerNode.group([ Database.node, Bus.node, Job.node, Session.node, SessionExecution.node, PluginRuntime.providerNode, LocationServiceMap.node, ]) const replacements = [ SessionExecution.node.replace(executionNode), Global.node.replace(tempGlobalLayer), ] satisfies LayerNode.Replacements const productionIt = testEffect(AppNodeBuilder.build(nodes, replacements)) const it = testEffect( AppNodeBuilder.build(nodes, [...replacements, PluginSupervisor.node.replace(subagentPluginSupervisor)]), ) const completionIt = testEffect( AppNodeBuilder.build(LayerNode.group([nodes, SessionRestart.node, KV.node]), [ Global.node.replace(tempGlobalLayer), PluginSupervisor.node.replace(subagentPluginSupervisor), LayerNodePlatform.llmClient.replace(TestLLM.testLayer({ fallback: TestLLM.text(childText, "completion") })), SessionRunnerModel.node.replace( Layer.succeed(SessionRunnerModel.Service, { resolve: () => Effect.succeed( SessionRunnerModel.resolved( LanguageModel.make({ id: "child", provider: "test", route: OpenAIChat.route }), { capabilities: { tools: true, input: ["text"], output: ["text"] }, cost: [], limit: { context: 200_000, output: 32_000 }, }, ), ), }), ), ]), ) 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", () => { completionIt.live("admits one durable completion across live delivery and restart replay", () => Effect.acquireRelease( Effect.promise(() => tmpdir()), (dir) => Effect.promise(() => dir[Symbol.asyncDispose]()), ).pipe( Effect.flatMap((dir) => Effect.gen(function* () { const sessions = yield* Session.Service const parent = yield* sessions.create({ location: Location.Ref.make({ directory: AbsolutePath.make(dir.path) }), model: parentModel, title: "Completion recipient", }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) const jobs = yield* Job.Service const bus = yield* Bus.Service const admitted = yield* Deferred.make() const notifications: SessionMessage.ID[] = [] yield* bus.project(SessionEvent.InboxEnqueued, (event) => Effect.gen(function* () { if (event.data.sessionID !== parent.id || event.data.item.type !== "synthetic") return notifications.push(event.data.inboxID) const marker = (yield* jobs.pendingBackground).find((job) => job.notificationID === event.data.inboxID) // The marker must survive until admission commits, not merely until delivery starts. expect(marker?.status).toBe("completed") if (marker) yield* Deferred.succeed(admitted, marker) }), ) const result = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-completion-replay", name: SubagentTool.name, input: { agent: "reviewer", description: "background review", prompt: "review", background: true }, }, }) const marker = yield* Deferred.await(admitted) yield* jobs.pendingBackground.pipe(Effect.repeat({ until: (pending) => pending.length === 0 })) yield* sessions.wait(parent.id) const messages = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic") expect(messages).toEqual([ expect.objectContaining({ id: marker.notificationID, description: "background review", text: `\n${childText}\n`, metadata: { source: "subagent", childID: outputSessionID(result.metadata), agent: "reviewer", state: "completed", }, }), ]) // Reproduce a crash after admission but before acknowledgment using the real persisted marker. const kv = yield* KV.Service yield* kv.set(`job.background/${marker.notificationID}`, marker) const restart = yield* SessionRestart.Service yield* restart.resumeSuspendedSessions yield* sessions.wait(parent.id) expect(notifications).toEqual([marker.notificationID]) expect((yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")).toEqual( messages, ) expect(yield* jobs.pendingBackground).toEqual([]) }), ), ), ) productionIt.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))) 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))) 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))) 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: expect.stringContaining(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))) 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: expect.stringContaining(childText) }], }) const child = yield* sessions.get(outputSessionID(settled.metadata)) expect(settled.content).toEqual([{ type: "text", text: completedOutput(child.id) }]) expect(settled.metadata).toEqual({ sessionID: child.id, status: "completed" }) expect(progress[0]).toEqual({ sessionID: child.id, status: "running" }) expect(child).toMatchObject({ parentID: parent.id, location: parent.location, agent: "reviewer", model: childModel, }) expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.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("continues an existing child session", () => 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))) const first = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-subagent-first", name: SubagentTool.name, input: { agent: "reviewer", description: "review", prompt: "review this" }, }, }) const childID = outputSessionID(first.metadata) const second = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-subagent-second", name: SubagentTool.name, input: { agent: "reviewer", description: "follow up", prompt: "continue this", sessionID: childID, }, }, }) expect(outputSessionID(second.metadata)).toBe(childID) expect((yield* sessions.list({ parentID: parent.id })).data).toHaveLength(1) expect((yield* sessions.get(childID)).title).toBe("review") expect( (yield* sessions.inbox(childID)).flatMap((message) => message.type === "user" ? [message.payload.text] : [], ), ).toEqual(["You are a subagent spawned by another session.\nreview this", "continue this"]) expect(second.content).toEqual([{ type: "text", text: completedOutput(childID) }]) }), ), ), ) it.live("steers a running child session in the background", () => 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 }) const child = yield* sessions.create({ parentID: parent.id, title: "review", agent: Agent.ID.make("reviewer"), model: childModel, }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) const jobs = yield* Job.Service yield* jobs.start({ id: child.id, type: SubagentTool.name, run: Effect.never }) const result = yield* executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call", id: "call-running-subagent", name: SubagentTool.name, input: { agent: "reviewer", description: "follow up", prompt: "continue while running", sessionID: child.id, background: true, }, }, }) expect(result).toMatchObject({ status: "completed", metadata: { sessionID: child.id, status: "running" }, }) expect((yield* sessions.inbox(child.id)).find((message) => message.type === "user")?.payload.text).toBe( "continue while running", ) expect((yield* jobs.get(child.id))?.status).toBe("running") yield* jobs.cancel(child.id) }), ), ), ) it.live("rejects unrelated children and switches agents on continuation", () => 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 }) const otherParent = yield* sessions.create({ location, model: parentModel }) const unrelated = yield* sessions.create({ parentID: otherParent.id, title: "other review", agent: Agent.ID.make("reviewer"), }) const switched = yield* sessions.create({ parentID: parent.id, title: "fallback review", agent: Agent.ID.make("fallback"), model: parentModel, }) yield* withSubagent(parent.location) const locations = yield* LocationServiceMap.Service const registry = yield* Tool.Service.pipe(Effect.provide(locations.get(parent.location))) const call = (sessionID: Session.ID, id: string, agent = "reviewer") => executeTool(registry, { sessionID: parent.id, ...toolIdentity, call: { type: "tool-call" as const, id, name: SubagentTool.name, input: { agent, description: "follow up", prompt: "continue", sessionID }, }, }) const missing = Session.ID.create() expect(yield* call(missing, "call-missing-child")).toEqual({ status: "error", error: { type: "tool.execution", message: `Subagent session not found: ${missing}`, }, }) expect(yield* call(unrelated.id, "call-unrelated-child")).toEqual({ status: "error", error: { type: "tool.execution", message: `Session ${unrelated.id} is not a child of the current session`, }, }) expect(yield* call(switched.id, "call-switched-child")).toMatchObject({ status: "completed", metadata: { sessionID: switched.id, status: "completed" }, }) expect(yield* sessions.get(switched.id)).toMatchObject({ agent: "reviewer", model: childModel, }) // Switching to an agent without a configured model keeps the child's current model. expect(yield* call(switched.id, "call-modelless-switch", "fallback")).toMatchObject({ status: "completed", metadata: { sessionID: switched.id, status: "completed" }, }) expect(yield* sessions.get(switched.id)).toMatchObject({ agent: "fallback", model: childModel, }) }), ), ), ) 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))) 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))) const bus = yield* Bus.Service const admitted = yield* bus.subscribe(SessionEvent.InboxEnqueued).pipe( Stream.filter((event) => event.data.sessionID === parent.id && event.data.item.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(`sessionID: ${childID}`) }]) const admission = Array.from(yield* Fiber.join(admitted))[0] expect(admission?.data.item.type).toBe("synthetic") if (admission?.data.item.type !== "synthetic") return yield* Effect.die("Expected synthetic inbox item") expect(admission?.data.item.payload.text).toContain(` message.type === "synthetic") expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(`