feat: enable background subagents by default
This commit is contained in:
parent
da68e2865e
commit
81f6e06681
37 changed files with 71 additions and 128 deletions
|
|
@ -40,7 +40,6 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||
enableExperimentalModels: bool("OPENCODE_ENABLE_EXPERIMENTAL_MODELS"),
|
||||
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
experimentalReferences: enabledByExperimental("OPENCODE_EXPERIMENTAL_REFERENCES"),
|
||||
experimentalBackgroundSubagents: enabledByExperimental("OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS"),
|
||||
experimentalLspTy: bool("OPENCODE_EXPERIMENTAL_LSP_TY"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalOxfmt: enabledByExperimental("OPENCODE_EXPERIMENTAL_OXFMT"),
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { Agent } from "@/agent/agent"
|
|||
import { Job } from "@/job"
|
||||
import { Config } from "@/config/config"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
|
|
@ -34,10 +33,9 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||
const worktreeSvc = yield* Worktree.Service
|
||||
const sessions = yield* Session.Service
|
||||
const jobs = yield* Job.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const capabilities = Effect.fn("ExperimentalHttpApi.capabilities")(function* () {
|
||||
return { backgroundSubagents: flags.experimentalBackgroundSubagents }
|
||||
return { backgroundSubagents: true }
|
||||
})
|
||||
|
||||
const getConsole = Effect.fn("ExperimentalHttpApi.console")(function* () {
|
||||
|
|
@ -159,7 +157,6 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
|||
const sessionBackground = Effect.fn("ExperimentalHttpApi.sessionBackground")(function* (ctx: {
|
||||
params: { sessionID: SessionID }
|
||||
}) {
|
||||
if (!flags.experimentalBackgroundSubagents) return false
|
||||
return (yield* jobs.backgroundAll({ sessionID: ctx.params.sessionID, type: "task" })).length > 0
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import * as Tool from "./tool"
|
||||
import DESCRIPTION from "./task.txt"
|
||||
import { ToolJsonSchema } from "./json-schema"
|
||||
import { SessionV1 } from "@opencode-ai/core/v1/session"
|
||||
import { Job } from "@/job"
|
||||
import { Session } from "@/session/session"
|
||||
|
|
@ -12,7 +11,6 @@ import type { SessionPrompt } from "../session/prompt"
|
|||
import { Config } from "@/config/config"
|
||||
import { Effect, Exit, Schema, Scope } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
||||
export interface TaskPromptOps {
|
||||
|
|
@ -51,8 +49,6 @@ const BaseParameterFields = {
|
|||
command: Schema.optional(Schema.String).annotate({ description: "The command that triggered this task" }),
|
||||
}
|
||||
|
||||
const BaseParameters = Schema.Struct(BaseParameterFields)
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
...BaseParameterFields,
|
||||
background: Schema.optional(Schema.Boolean).annotate({
|
||||
|
|
@ -86,7 +82,6 @@ export const TaskTool = Tool.define(
|
|||
const config = yield* Config.Service
|
||||
const sessions = yield* Session.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
const database = yield* Database.Service
|
||||
|
||||
const run = Effect.fn("TaskTool.execute")(function* (
|
||||
|
|
@ -95,11 +90,6 @@ export const TaskTool = Tool.define(
|
|||
) {
|
||||
const cfg = yield* config.get()
|
||||
const runInBackground = params.background === true
|
||||
if (runInBackground && !flags.experimentalBackgroundSubagents) {
|
||||
return yield* Effect.fail(
|
||||
new Error("Background subagents require OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true"),
|
||||
)
|
||||
}
|
||||
|
||||
if (!ctx.extra?.bypassAgentCheck) {
|
||||
yield* ctx.ask({
|
||||
|
|
@ -333,11 +323,8 @@ export const TaskTool = Tool.define(
|
|||
})
|
||||
|
||||
return {
|
||||
description: flags.experimentalBackgroundSubagents
|
||||
? [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n")
|
||||
: DESCRIPTION,
|
||||
description: [DESCRIPTION, BACKGROUND_DESCRIPTION].join("\n\n"),
|
||||
parameters: Parameters,
|
||||
jsonSchema: flags.experimentalBackgroundSubagents ? undefined : ToolJsonSchema.fromSchema(BaseParameters),
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
run(params, ctx).pipe(Effect.orDie),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -161,7 +161,6 @@ async function renderFooter(
|
|||
currentModel?: RunInput["model"]
|
||||
currentVariant?: string
|
||||
subagents?: FooterSubagentState
|
||||
backgroundSubagents?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
state?: Partial<FooterState>
|
||||
|
|
@ -199,7 +198,6 @@ async function renderFooter(
|
|||
subagent={subagents}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
tuiConfig={config}
|
||||
backgroundSubagents={input.backgroundSubagents ?? true}
|
||||
agent="opencode"
|
||||
onSubmit={input.onSubmit ?? (() => true)}
|
||||
onPermissionReply={() => {}}
|
||||
|
|
@ -998,7 +996,6 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
]}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
backgroundSubagents={true}
|
||||
agent="opencode"
|
||||
onSubmit={() => true}
|
||||
onPermissionReply={() => {}}
|
||||
|
|
@ -1071,7 +1068,7 @@ test("direct footer shows editable prompts and additional queued work while runn
|
|||
}
|
||||
})
|
||||
|
||||
test("direct footer separates a lone context hint from model and command hint", async () => {
|
||||
test("direct footer always offers backgrounding for a foreground subagent", async () => {
|
||||
const app = await renderFooter({
|
||||
providers: [provider()],
|
||||
currentModel: { providerID: "opencode", modelID: "gpt-5" },
|
||||
|
|
@ -1082,7 +1079,6 @@ test("direct footer separates a lone context hint from model and command hint",
|
|||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
backgroundSubagents: false,
|
||||
width: 160,
|
||||
})
|
||||
|
||||
|
|
@ -1091,8 +1087,8 @@ test("direct footer separates a lone context hint from model and command hint",
|
|||
const frame = app.captureCharFrame()
|
||||
|
||||
expect(frame).toContain("GPT-5")
|
||||
expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd")
|
||||
expect(frame).not.toContain("ctrl+b background")
|
||||
expect(frame).toContain("xhigh · ctrl+b background · ctrl+x down subagents · ctrl+p cmd")
|
||||
expect(frame).toContain("ctrl+b background")
|
||||
expect(frame).not.toContain("queued")
|
||||
} finally {
|
||||
app.cleanup()
|
||||
|
|
@ -1110,7 +1106,6 @@ test("direct footer hides the subagent hint when only completed subagents remain
|
|||
permissions: [],
|
||||
questions: [],
|
||||
},
|
||||
backgroundSubagents: false,
|
||||
width: 160,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async () => {
|
||||
|
|
@ -233,7 +232,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async () => {
|
||||
|
|
@ -406,7 +404,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: true,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
|
|
@ -496,7 +493,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
|
|
@ -557,7 +553,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async () => {
|
||||
|
|
@ -603,7 +598,6 @@ describe("run interactive runtime", () => {
|
|||
variant: undefined,
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
|
|
@ -716,7 +710,6 @@ describe("run interactive runtime", () => {
|
|||
variant: "low",
|
||||
files: [],
|
||||
thinking: false,
|
||||
backgroundSubagents: false,
|
||||
},
|
||||
{
|
||||
createRuntimeLifecycle: async (input) => {
|
||||
|
|
|
|||
|
|
@ -51,7 +51,6 @@ describe("RuntimeFlags", () => {
|
|||
expect(flags.enableExperimentalModels).toBe(true)
|
||||
expect(flags.enableQuestionTool).toBe(true)
|
||||
expect(flags.experimentalReferences).toBe(true)
|
||||
expect(flags.experimentalBackgroundSubagents).toBe(true)
|
||||
expect(flags.experimentalLspTy).toBe(false)
|
||||
expect(flags.experimentalLspTool).toBe(true)
|
||||
expect(flags.experimentalOxfmt).toBe(true)
|
||||
|
|
|
|||
|
|
@ -579,8 +579,10 @@ const scenarios: Scenario[] = [
|
|||
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
|
||||
.json(200, array),
|
||||
http.protected.get("/experimental/capabilities", "experimental.capabilities.get").json(200, (body) => {
|
||||
check(typeof body === "object" && body !== null, "capabilities should be an object")
|
||||
check("backgroundSubagents" in body, "capabilities should report background subagents")
|
||||
check(
|
||||
typeof body === "object" && body !== null && "backgroundSubagents" in body && body.backgroundSubagents === true,
|
||||
"capabilities should report background subagents as available",
|
||||
)
|
||||
}),
|
||||
http.protected
|
||||
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { Job } from "@/job"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), httpApiLayer))
|
||||
const it = testEffect(Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(Job.node), httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
|
|
@ -14,6 +15,19 @@ afterEach(async () => {
|
|||
})
|
||||
|
||||
describe("session action routes", () => {
|
||||
it.instance(
|
||||
"reports background subagents as available",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* requestInDirectory("/experimental/capabilities", test.directory)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(yield* res.json).toEqual({ backgroundSubagents: true })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"session routes expose metadata on create, update, get, and fork",
|
||||
() =>
|
||||
|
|
@ -107,4 +121,33 @@ describe("session action routes", () => {
|
|||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"experimental background route backgrounds a synchronous subagent",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* Effect.acquireRelease(SessionNs.use.create({}), (created) =>
|
||||
SessionNs.use.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
const jobs = yield* Job.Service
|
||||
const job = yield* jobs.start({ type: "task", run: Effect.never })
|
||||
const waiting = yield* jobs.block({ id: job.id, sessionID: session.id }).pipe(Effect.forkChild)
|
||||
|
||||
const backgrounded = yield* pollWithTimeout(
|
||||
requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
|
||||
method: "POST",
|
||||
}).pipe(
|
||||
Effect.flatMap((res) => res.json),
|
||||
Effect.map((value) => (value === true ? true : undefined)),
|
||||
),
|
||||
"background route never released the synchronous subagent",
|
||||
)
|
||||
|
||||
expect(backgrounded).toBe(true)
|
||||
expect(yield* Fiber.join(waiting)).toMatchObject({ type: "backgrounded", info: { id: job.id } })
|
||||
yield* jobs.cancel(job.id)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ describe("tool.registry", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
|
||||
it.instance("exposes the task background parameter by default", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = yield* Agent.Service
|
||||
|
|
@ -162,8 +162,9 @@ describe("tool.registry", () => {
|
|||
agent: build,
|
||||
})).find((tool) => tool.id === "task")
|
||||
|
||||
expect(task?.jsonSchema).toBeDefined()
|
||||
expect((task?.jsonSchema?.properties as Record<string, unknown> | undefined)?.background).toBeUndefined()
|
||||
if (!task) throw new Error("task tool not found")
|
||||
const jsonSchema = ToolJsonSchema.fromTool(task)
|
||||
expect((jsonSchema.properties as Record<string, unknown> | undefined)?.background).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ const ref = {
|
|||
modelID: ModelV2.ID.make("test-model"),
|
||||
}
|
||||
|
||||
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
const layer = () =>
|
||||
LayerNode.compile(
|
||||
LayerNode.group([
|
||||
Agent.node,
|
||||
|
|
@ -52,11 +52,10 @@ const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
|||
RuntimeFlags.node,
|
||||
Ripgrep.node,
|
||||
]),
|
||||
[[RuntimeFlags.node, RuntimeFlags.layer(flags)]],
|
||||
[[RuntimeFlags.node, RuntimeFlags.layer()]],
|
||||
)
|
||||
|
||||
const it = testEffect(layer())
|
||||
const background = testEffect(layer({ experimentalBackgroundSubagents: true }))
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
|
|
@ -456,37 +455,6 @@ describe("tool.task", () => {
|
|||
},
|
||||
)
|
||||
|
||||
it.instance("rejects background execution when the experiment is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const { chat, assistant } = yield* seed()
|
||||
const tool = yield* TaskTool
|
||||
const def = yield* tool.init()
|
||||
|
||||
const exit = yield* def
|
||||
.execute(
|
||||
{
|
||||
description: "inspect bug",
|
||||
prompt: "look into the cache key path",
|
||||
subagent_type: "general",
|
||||
background: true,
|
||||
},
|
||||
{
|
||||
sessionID: chat.id,
|
||||
messageID: assistant.id,
|
||||
agent: "build",
|
||||
abort: new AbortController().signal,
|
||||
extra: { promptOps: stubOps() },
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
},
|
||||
)
|
||||
.pipe(Effect.exit)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("backgrounds a running foreground task without restarting it", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
|
|
@ -558,7 +526,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("execute launches background tasks without waiting for completion", () =>
|
||||
it.instance("execute launches background tasks without waiting for completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
|
|
@ -596,7 +564,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("running task_id reports the existing background task", () =>
|
||||
it.instance("running task_id reports the existing background task", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
|
|
@ -661,7 +629,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("background tasks complete through the job service", () =>
|
||||
it.instance("background tasks complete through the job service", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
|
|
@ -694,7 +662,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("background task completion does not wait for the parent async prompt", () =>
|
||||
it.instance("background task completion does not wait for the parent async prompt", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const { chat, assistant } = yield* seed()
|
||||
|
|
@ -732,7 +700,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("removing the parent session cancels running background tasks", () =>
|
||||
it.instance("removing the parent session cancels running background tasks", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
|
@ -771,7 +739,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("removing the child task session cancels its running background task", () =>
|
||||
it.instance("removing the child task session cancels its running background task", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const sessions = yield* Session.Service
|
||||
|
|
@ -810,7 +778,7 @@ describe("tool.task", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
background.instance("cancelling the parent run cancels running background tasks", () =>
|
||||
it.instance("cancelling the parent run cancels running background tasks", () =>
|
||||
Effect.gen(function* () {
|
||||
const jobs = yield* Job.Service
|
||||
const runState = yield* SessionRunState.Service
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue