mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-26 00:51:02 +00:00
164 lines
6.5 KiB
TypeScript
164 lines
6.5 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import path from "path"
|
|
import { Effect, Layer } from "effect"
|
|
import { LanguageModel } from "@opencode/ai/effect"
|
|
import { OpenAIChat } from "@opencode/ai/protocols/openai-chat"
|
|
import { TestLLM } from "@opencode/ai/testing"
|
|
import { Agent } from "@opencode/core/agent"
|
|
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
|
import { LayerNodePlatform } from "@opencode/core/effect/app-node-platform"
|
|
import { Watcher } from "@opencode/core/filesystem/watcher"
|
|
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
|
import { Model } from "@opencode/core/model"
|
|
import { Provider } from "@opencode/core/provider"
|
|
import { AbsolutePath } from "@opencode/core/schema"
|
|
import { Session } from "@opencode/core/session"
|
|
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
|
|
import { LayerNode } from "@opencode/util/effect/layer-node"
|
|
import { Global } from "@opencode/util/global"
|
|
import { tempGlobalLayer } from "../fixture/global"
|
|
import { offlineModels } from "../fixture/models"
|
|
import { tmpdirScoped } from "../fixture/tmpdir"
|
|
import { testEffect } from "../lib/effect"
|
|
|
|
const llmLayer = TestLLM.testLayer({ fallback: TestLLM.text("Review complete", "review") })
|
|
const it = testEffect(
|
|
Layer.merge(
|
|
llmLayer,
|
|
AppNodeBuilder.build(LayerNode.group([Session.node, LocationServiceMap.node]), [
|
|
Global.node.replace(tempGlobalLayer),
|
|
offlineModels,
|
|
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
|
LayerNodePlatform.llmClient.replace(llmLayer),
|
|
SessionRunnerModel.node.replace(
|
|
Layer.succeed(SessionRunnerModel.Service, {
|
|
resolve: (session) =>
|
|
Effect.succeed(
|
|
SessionRunnerModel.resolved(
|
|
LanguageModel.make({ id: session.model?.id ?? "parent", provider: "test", route: OpenAIChat.route }),
|
|
{
|
|
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
|
cost: [],
|
|
limit: { context: 200_000, output: 32_000 },
|
|
},
|
|
),
|
|
),
|
|
}),
|
|
),
|
|
]),
|
|
),
|
|
)
|
|
|
|
const parentModel = Model.Ref.make({ id: Model.ID.make("parent"), providerID: Provider.ID.make("test") })
|
|
|
|
describe("command subagents", () => {
|
|
for (const fixture of [
|
|
{
|
|
name: "native JSON",
|
|
format: "json",
|
|
command: { subagent: true, agent: "build", model: "test/override" },
|
|
agent: "build",
|
|
model: "override",
|
|
},
|
|
{
|
|
name: "legacy Markdown",
|
|
format: "markdown",
|
|
command: { subtask: true, agent: "build" },
|
|
agent: "build",
|
|
model: "parent",
|
|
},
|
|
{
|
|
name: "subagent mode by default",
|
|
format: "json",
|
|
command: { agent: "reviewer" },
|
|
agent: "reviewer",
|
|
model: "child",
|
|
},
|
|
] as const) {
|
|
it.live(`runs ${fixture.name} in the background without switching the parent`, () =>
|
|
Effect.gen(function* () {
|
|
const parent = yield* project(fixture.command, fixture.format)
|
|
const sessions = yield* Session.Service
|
|
const llm = yield* TestLLM.Test
|
|
const gate = yield* llm.gate()
|
|
|
|
// This must return while the child's model is still blocked.
|
|
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
|
yield* gate.started
|
|
const children = (yield* sessions.list({ parentID: parent.id })).data
|
|
expect(children).toHaveLength(1)
|
|
const child = children[0]
|
|
if (!child) return yield* Effect.die("Expected a child session")
|
|
expect(child).toMatchObject({ agent: fixture.agent, model: { id: fixture.model }, title: "Review code" })
|
|
expect(yield* sessions.get(parent.id)).toMatchObject({ agent: "build", model: parentModel })
|
|
expect(yield* sessions.context(parent.id)).toEqual([])
|
|
expect(yield* llm.requests()).toHaveLength(1)
|
|
expect((yield* sessions.context(child.id)).filter((message) => message.type === "user")).toMatchObject([
|
|
{ text: "You are a subagent spawned by another session.\nReview changes: ready" },
|
|
])
|
|
yield* gate.release
|
|
yield* llm.wait(2)
|
|
yield* sessions.wait(parent.id)
|
|
const notices = (yield* sessions.context(parent.id)).filter((message) => message.type === "synthetic")
|
|
expect(notices).toMatchObject([{ metadata: { source: "subagent", childID: child.id, state: "completed" } }])
|
|
expect(notices[0]?.text).toContain("Review complete")
|
|
}),
|
|
)
|
|
}
|
|
|
|
it.live("subagent: false overrides subagent mode and the legacy alias", () =>
|
|
Effect.gen(function* () {
|
|
const parent = yield* project({ subagent: false, subtask: true, agent: "reviewer" }, "json")
|
|
const sessions = yield* Session.Service
|
|
yield* sessions.command({ sessionID: parent.id, command: "review", text: "changes" })
|
|
yield* sessions.wait(parent.id)
|
|
expect((yield* sessions.list({ parentID: parent.id })).data).toEqual([])
|
|
expect(yield* sessions.get(parent.id)).toMatchObject({
|
|
agent: "reviewer",
|
|
model: { id: "child" },
|
|
})
|
|
expect((yield* sessions.context(parent.id)).filter((message) => message.type === "user")).toMatchObject([
|
|
{ text: "Review changes: ready" },
|
|
])
|
|
}),
|
|
)
|
|
})
|
|
|
|
function project(
|
|
command: { agent?: string; model?: string; subagent?: boolean; subtask?: boolean },
|
|
format: "json" | "markdown",
|
|
) {
|
|
return Effect.gen(function* () {
|
|
const tmp = yield* tmpdirScoped()
|
|
const definition = { description: "Review code", template: "Review $ARGUMENTS: !`printf ready`", ...command }
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, "opencode.json"),
|
|
JSON.stringify({
|
|
agents: { reviewer: { mode: "subagent", model: "test/child" } },
|
|
...(format === "markdown" ? {} : { commands: { review: definition } }),
|
|
}),
|
|
),
|
|
)
|
|
if (format === "markdown")
|
|
yield* Effect.promise(() =>
|
|
Bun.write(
|
|
path.join(tmp.path, ".opencode/commands/review.md"),
|
|
[
|
|
"---",
|
|
"description: Review code",
|
|
...Object.entries(command).map(([key, value]) => `${key}: ${value}`),
|
|
"---",
|
|
definition.template,
|
|
].join("\n"),
|
|
),
|
|
)
|
|
const sessions = yield* Session.Service
|
|
return yield* sessions.create({
|
|
location: { directory: AbsolutePath.make(tmp.path) },
|
|
title: "Parent session",
|
|
agent: Agent.ID.make("build"),
|
|
model: parentModel,
|
|
})
|
|
})
|
|
}
|