| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416 |
- 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<Session.ID>()
- 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(`<subagent id="${childID}" state="completed"`)
- expect(admission?.data.input.data).toMatchObject({
- description: "background review",
- metadata: {
- source: "subagent",
- childID,
- agent: "reviewer",
- state: "completed",
- },
- })
- const database = yield* Database.Service
- yield* SessionPending.promote(database.db, bus, parent.id, "steer")
- const synthetic = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
- expect(synthetic).toHaveLength(1)
- expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
- expect(synthetic[0]?.text).toContain(childText)
- }),
- ),
- ),
- )
- })
|