Apply PR #24174: feat(core): add background subagent support
This commit is contained in:
commit
4326dc1a3b
12 changed files with 809 additions and 61 deletions
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
278
packages/opencode/test/tool/task_status.test.ts
Normal file
278
packages/opencode/test/tool/task_status.test.ts
Normal 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.")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue