remove the need for polling from experimental background agents (#29179)

This commit is contained in:
Aiden Cline
2026-05-25 16:09:56 -05:00
committed by GitHub
parent 775321173d
commit dabf2dc013
11 changed files with 63 additions and 431 deletions

View File

@@ -320,7 +320,7 @@ 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",
"description": "Run the agent in the background. You will be notified when it completes.",
"type": "boolean",
},
"command": {

View File

@@ -99,9 +99,6 @@ const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
const scout = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
)
const background = testEffect(
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
)
const withBrokenPlugin = testEffect(
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
)
@@ -131,7 +128,7 @@ describe("tool.registry", () => {
}),
)
it.instance("hides task_status unless experimental background subagents are enabled", () =>
it.instance("does not expose task_status", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
@@ -157,15 +154,6 @@ describe("tool.registry", () => {
}),
)
background.instance("shows task_status when experimental background subagents are enabled", () =>
Effect.gen(function* () {
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("task_status")
}),
)
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance

View File

@@ -92,7 +92,6 @@ function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void;
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
}),
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, opts?.text ?? "done")),
}
}
@@ -237,7 +236,7 @@ describe("tool.task", () => {
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(child.id)
expect(result.metadata.sessionId).toBe(child.id)
expect(result.output).toContain(`task_id: ${child.id}`)
expect(result.output).toContain(`<task id="${child.id}" state="completed">`)
expect(seen?.sessionID).toBe(child.id)
}),
)
@@ -307,7 +306,6 @@ describe("tool.task", () => {
ready.resolve(input)
return cancelled.promise
}).pipe(Effect.as(reply(input, "cancelled"))),
loop: (input) => Effect.succeed(reply({ sessionID: input.sessionID, parts: [] }, "done")),
}
const fiber = yield* def
@@ -371,7 +369,7 @@ describe("tool.task", () => {
expect(kids).toHaveLength(1)
expect(kids[0]?.id).toBe(result.metadata.sessionId)
expect(result.metadata.sessionId).not.toBe("ses_missing")
expect(result.output).toContain(`task_id: ${result.metadata.sessionId}`)
expect(result.output).toContain(`<task id="${result.metadata.sessionId}" state="completed">`)
expect(seen?.sessionID).toBe(result.metadata.sessionId)
}),
)
@@ -511,7 +509,7 @@ describe("tool.task", () => {
const job = yield* jobs.get(result.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("state: running")
expect(result.output).toContain(`state="running"`)
expect(job?.status).toBe("running")
}),
)
@@ -549,10 +547,9 @@ describe("tool.task", () => {
}),
)
background.instance("background task completion does not wait for the parent resume loop", () =>
background.instance("background task completion does not wait for the parent async prompt", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
@@ -573,27 +570,7 @@ describe("tool.task", () => {
promptOps: {
...stubOps({ text: "background done" }),
prompt: (input) =>
input.noReply
? Effect.gen(function* () {
const user = yield* sessions.updateMessage({
id: input.messageID ?? MessageID.ascending(),
role: "user",
sessionID: input.sessionID,
agent: input.agent ?? "build",
model: input.model ?? ref,
time: { created: Date.now() },
})
const parts = input.parts.map((part) => ({
...part,
id: part.id ?? PartID.ascending(),
messageID: user.id,
sessionID: input.sessionID,
}))
yield* Effect.forEach(parts, (part) => sessions.updatePart(part), { discard: true })
return { info: user, parts }
})
: Effect.succeed(reply(input, "background done")),
loop: () => Effect.never,
input.sessionID === chat.id ? Effect.never : Effect.succeed(reply(input, "background done")),
} satisfies TaskPromptOps,
},
messages: [],

View File

@@ -1,92 +0,0 @@
import { afterEach, describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { Agent } from "@/agent/agent"
import { BackgroundJob } from "@/background/job"
import { Bus } from "@/bus"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Session } from "@/session/session"
import { MessageID } from "@/session/schema"
import { SessionStatus } from "@/session/status"
import { TaskStatusTool } from "@/tool/task_status"
import { Truncate } from "@/tool/truncate"
import { RuntimeFlags } from "@/effect/runtime-flags"
import { disposeAllInstances } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
afterEach(async () => {
await disposeAllInstances()
})
const layer = (flags: Partial<RuntimeFlags.Info> = {}) =>
Layer.mergeAll(
Agent.defaultLayer,
BackgroundJob.defaultLayer,
Bus.defaultLayer,
CrossSpawnSpawner.defaultLayer,
Session.defaultLayer,
SessionStatus.defaultLayer,
Truncate.defaultLayer,
RuntimeFlags.layer(flags),
)
const it = testEffect(layer({ experimentalBackgroundSubagents: true }))
describe("tool.task_status", () => {
it.instance("returns completed background job output", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* jobs.start({ id: chat.id, type: "task", run: Effect.succeed("all done") })
const result = yield* def.execute(
{ task_id: chat.id, wait: true, timeout_ms: 1_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("all done")
expect(result.metadata.timed_out).toBe(false)
}),
)
it.instance("wait=true times out while the background job is running", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const sessions = yield* Session.Service
const tool = yield* TaskStatusTool
const def = yield* tool.init()
const chat = yield* sessions.create({})
yield* jobs.start({ id: chat.id, type: "task", run: Effect.never })
const result = yield* def.execute(
{ task_id: chat.id, wait: true, timeout_ms: 50 },
{
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("Timed out after 50ms")
expect(result.metadata.timed_out).toBe(true)
}),
)
})