Apply PR #24174: feat(core): add background subagent support

This commit is contained in:
opencode-agent[bot] 2026-04-27 21:00:30 +00:00
commit 4326dc1a3b
12 changed files with 809 additions and 61 deletions

View file

@ -338,6 +338,10 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"properties": {
"background": {
"description": "When true, launch the subagent in the background and return immediately",
"type": "boolean",
},
"command": {
"description": "The command that triggered this task",
"type": "string",
@ -356,6 +360,7 @@ exports[`tool parameters JSON Schema (wire shape) task 1`] = `
},
"task_id": {
"description": "This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
"pattern": "^ses.*",
"type": "string",
},
},

View file

@ -26,6 +26,7 @@ import { Parameters as Todo } from "../../src/tool/todo"
import { Parameters as WebFetch } from "../../src/tool/webfetch"
import { Parameters as WebSearch } from "../../src/tool/websearch"
import { Parameters as Write } from "../../src/tool/write"
import { SessionID } from "../../src/session/schema"
const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S["Type"] =>
Schema.decodeUnknownSync(schema)(input)
@ -220,6 +221,19 @@ describe("tool parameters", () => {
const parsed = parse(Task, { description: "d", prompt: "p", subagent_type: "general" })
expect(parsed.subagent_type).toBe("general")
})
test("accepts optional task_id + command + background", () => {
const parsed = parse(Task, {
description: "d",
prompt: "p",
subagent_type: "general",
task_id: SessionID.make("ses_test"),
command: "/cmd",
background: true,
})
expect(parsed.task_id).toBe(SessionID.make("ses_test"))
expect(parsed.command).toBe("/cmd")
expect(parsed.background).toBe(true)
})
test("rejects missing prompt", () => {
expect(accepts(Task, { description: "d", subagent_type: "general" })).toBe(false)
})

View file

@ -3,11 +3,13 @@ import { Effect, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Bus } from "../../src/bus"
import { Instance } from "../../src/project/instance"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID } from "../../src/session/schema"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
import { Truncate } from "@/tool/truncate"
@ -27,9 +29,11 @@ const ref = {
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
Bus.defaultLayer,
Config.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
Truncate.defaultLayer,
ToolRegistry.defaultLayer,
),
@ -64,15 +68,59 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
return { chat, assistant }
})
function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
function stubOps(session: Session.Interface, opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
return {
cancel() {},
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
Effect.gen(function* () {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
const userID = input.messageID ?? MessageID.ascending()
const user: MessageV2.User = {
id: userID,
role: "user",
sessionID: input.sessionID,
agent: input.agent ?? "build",
model: input.model ?? ref,
tools: input.tools,
time: { created: Date.now() },
}
yield* session.updateMessage(user)
const parts = input.parts.map((part) => ({
...part,
id: part.id ?? PartID.ascending(),
messageID: user.id,
sessionID: input.sessionID,
}))
yield* Effect.forEach(parts, (part) => session.updatePart(part), { discard: true })
if (input.noReply) {
return {
info: user,
parts,
}
}
const result = reply({ ...input, messageID: user.id }, opts?.text ?? "done")
yield* session.updateMessage(result.info)
yield* Effect.forEach(result.parts, (part) => session.updatePart(part), { discard: true })
return result
}),
loop: (input) =>
Effect.sync(() =>
reply(
{
sessionID: input.sessionID,
messageID: MessageID.ascending(),
agent: "build",
model: ref,
parts: [],
},
opts?.text ?? "done",
),
),
fork() {},
}
}
@ -195,7 +243,7 @@ describe("tool.task", () => {
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
const promptOps = stubOps(sessions, { text: "resumed", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
@ -229,11 +277,12 @@ describe("tool.task", () => {
it.live("execute asks by default and skips checks when bypassed", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const calls: unknown[] = []
const promptOps = stubOps()
const promptOps = stubOps(sessions)
const exec = (extra?: Record<string, any>) =>
def.execute(
@ -282,14 +331,14 @@ describe("tool.task", () => {
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
const promptOps = stubOps(sessions, { text: "created", onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
task_id: "ses_missing",
task_id: SessionID.make("ses_missing"),
},
{
sessionID: chat.id,
@ -322,7 +371,7 @@ describe("tool.task", () => {
const tool = yield* TaskTool
const def = yield* tool.init()
let seen: SessionPrompt.PromptInput | undefined
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
const promptOps = stubOps(sessions, { onPrompt: (input) => (seen = input) })
const result = yield* def.execute(
{
@ -384,4 +433,116 @@ describe("tool.task", () => {
},
),
)
it.live("execute launches background tasks without waiting for completion", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const forks: Effect.Effect<void, never, never>[] = []
const result = 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(sessions),
fork(effect) {
forks.push(effect)
},
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.metadata.sessionId).toBeDefined()
expect(result.metadata.background).toBe(true)
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(result.output).toContain("state: running")
expect(forks).toHaveLength(1)
}),
),
)
it.live("background tasks inject completion into the parent session and resume when idle", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const forks: Effect.Effect<void, never, never>[] = []
const loops: string[] = []
const result = 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(sessions, { text: "background done" }),
loop(input) {
loops.push(input.sessionID)
return Effect.sync(() =>
reply(
{
sessionID: input.sessionID,
messageID: MessageID.ascending(),
agent: "build",
model: ref,
parts: [],
},
"looped",
),
)
},
fork(effect) {
forks.push(effect)
},
} satisfies TaskPromptOps,
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
yield* forks[0]!
const parent = yield* sessions.findMessage(chat.id, (msg) => msg.info.role === "user")
expect(parent._tag).toBe("Some")
if (parent._tag !== "Some") return
expect(parent.value.parts.find((part) => part.type === "text")?.text).toContain("Background task completed")
expect(parent.value.parts.find((part) => part.type === "text")?.text).toContain("background done")
expect(loops).toEqual([chat.id])
const child = yield* sessions.findMessage(result.metadata.sessionId, (msg) => msg.info.role === "assistant")
expect(child._tag).toBe("Some")
if (child._tag !== "Some") return
expect(child.value.parts.find((part) => part.type === "text")?.text).toBe("background done")
}),
),
)
})

View file

@ -0,0 +1,278 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer, Scope } from "effect"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Instance } from "../../src/project/instance"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import { MessageID, PartID } from "../../src/session/schema"
import { SessionStatus } from "../../src/session/status"
import { TaskStatusTool } from "../../src/tool/task_status"
import { Truncate } from "@/tool/truncate"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await Instance.disposeAll()
})
const ref = {
providerID: ProviderID.make("test"),
modelID: ModelID.make("test-model"),
}
const it = testEffect(
Layer.mergeAll(
Agent.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
Truncate.defaultLayer,
),
)
const seedUser = Effect.fn("TaskStatusToolTest.seedUser")(function* (sessionID: Session.Info["id"]) {
const session = yield* Session.Service
return yield* session.updateMessage({
id: MessageID.ascending(),
role: "user",
sessionID,
agent: "build",
model: ref,
time: { created: Date.now() },
})
})
const seedAssistant = Effect.fn("TaskStatusToolTest.seedAssistant")(function* (input: {
sessionID: Session.Info["id"]
text: string
error?: string
}) {
const session = yield* Session.Service
const user = yield* seedUser(input.sessionID)
const message = yield* session.updateMessage({
id: MessageID.ascending(),
role: "assistant",
parentID: user.id,
sessionID: input.sessionID,
mode: "build",
agent: "build",
cost: 0,
path: { cwd: "/tmp", root: "/tmp" },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
modelID: ref.modelID,
providerID: ref.providerID,
time: { created: Date.now(), completed: Date.now() },
finish: "stop",
...(input.error
? {
error: new MessageV2.APIError({
message: input.error,
isRetryable: false,
}).toObject(),
}
: {}),
})
yield* session.updatePart({
id: PartID.ascending(),
messageID: message.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
})
})
describe("tool.task_status", () => {
it.live("returns running while session status is busy", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* status.set(chat.id, { type: "busy" })
const result = yield* def.execute(
{ task_id: chat.id },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: running")
}),
),
)
it.live("returns completed with final task output", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* seedAssistant({ sessionID: chat.id, text: "all done" })
const result = yield* def.execute(
{ task_id: chat.id },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: completed")
expect(result.output).toContain("all done")
}),
),
)
it.live("wait=true blocks until terminal status", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
const scope = yield* Scope.Scope
yield* status.set(chat.id, { type: "busy" })
yield* Effect.gen(function* () {
yield* Effect.sleep("150 millis")
yield* status.set(chat.id, { type: "idle" })
yield* seedAssistant({ sessionID: chat.id, text: "finished later" })
}).pipe(Effect.forkIn(scope))
const result = yield* def.execute(
{
task_id: chat.id,
wait: true,
timeout_ms: 4_000,
},
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: completed")
expect(result.output).toContain("finished later")
}),
),
)
it.live("returns error when child run fails", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* seedAssistant({ sessionID: chat.id, text: "", error: "child failed" })
const result = yield* def.execute(
{ task_id: chat.id },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: error")
expect(result.output).toContain("child failed")
expect(result.metadata.state).toBe("error")
}),
),
)
it.live("wait=true times out with timed_out metadata", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const status = yield* SessionStatus.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* status.set(chat.id, { type: "busy" })
const result = yield* def.execute(
{
task_id: chat.id,
wait: true,
timeout_ms: 80,
},
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("Timed out after 80ms")
expect(result.metadata.timed_out).toBe(true)
expect(result.metadata.state).toBe("running")
}),
),
)
it.live("returns running for resumed task with a newer user turn", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* seedAssistant({ sessionID: chat.id, text: "old done" })
yield* seedUser(chat.id)
const result = yield* def.execute(
{ task_id: chat.id },
{
sessionID: chat.id,
messageID: MessageID.ascending(),
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
expect(result.output).toContain("state: running")
expect(result.output).toContain("Task is starting.")
}),
),
)
})