feat(tui): allow backgrounding synchronous subagents (#30488)

This commit is contained in:
Kit Langton 2026-06-04 23:40:52 -04:00 committed by GitHub
commit 3003867c25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 527 additions and 35 deletions

View file

@ -99,6 +99,31 @@ describe("background.job", () => {
}),
)
it.instance("runs extensions after earlier work completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const first = yield* Deferred.make<void>()
const order: string[] = []
const job = yield* jobs.start({
type: "test",
run: Effect.sync(() => order.push("start")).pipe(Effect.andThen(Deferred.await(first)), Effect.as("first")),
})
expect(
yield* jobs.extend({
id: job.id,
run: Effect.sync(() => order.push("extend")).pipe(Effect.as("second")),
}),
).toBe(true)
yield* Effect.yieldNow
expect(order).toEqual(["start"])
yield* Deferred.succeed(first, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("second")
expect(order).toEqual(["start", "extend"])
}),
)
it.instance("rejects extensions after a job completes", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@ -160,25 +185,47 @@ describe("background.job", () => {
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const interrupted = yield* Deferred.make<void>()
const extendedInterrupted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(interrupted, undefined))),
})
yield* jobs.extend({
id: job.id,
run: Effect.never.pipe(Effect.ensuring(Deferred.succeed(extendedInterrupted, undefined))),
run: Effect.never,
})
const cancelled = yield* jobs.cancel(job.id)
expect(cancelled?.status).toBe("cancelled")
yield* Deferred.await(interrupted).pipe(Effect.timeout("1 second"))
yield* Deferred.await(extendedInterrupted).pipe(Effect.timeout("1 second"))
expect((yield* jobs.get(job.id))?.status).toBe("cancelled")
}),
)
it.instance("promotes running jobs without interrupting them", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const latch = yield* Deferred.make<void>()
const promoted = yield* Deferred.make<void>()
const job = yield* jobs.start({
type: "test",
metadata: { parentSessionId: "parent" },
onPromote: Deferred.succeed(promoted, undefined).pipe(Effect.asVoid),
run: Deferred.await(latch).pipe(Effect.as("done")),
})
const info = yield* jobs.promote(job.id)
expect(info?.status).toBe("running")
expect(info?.metadata?.background).toBe(true)
yield* Deferred.await(promoted)
expect((yield* jobs.get(job.id))?.status).toBe("running")
yield* Deferred.succeed(latch, undefined)
expect((yield* jobs.wait({ id: job.id })).info?.output).toBe("done")
}),
)
it.instance("returns immutable snapshots", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service

View file

@ -185,6 +185,7 @@ async function renderFooter(
subagent={subagents}
theme={RUN_THEME_FALLBACK}
tuiConfig={config}
backgroundSubagents={true}
agent="opencode"
onSubmit={input.onSubmit ?? (() => true)}
onPermissionReply={() => {}}
@ -613,6 +614,7 @@ 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={() => {}}
@ -647,7 +649,7 @@ test("direct footer shows editable prompts and additional queued work while runn
try {
await app.renderOnce()
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("interrupt • 1 agent ctrl+x down • ctrl+b background • 1 queued ctrl+x q")
expect(app.captureCharFrame()).toContain("2 queued")
expect(app.captureCharFrame()).not.toContain("to view")
expect(app.captureCharFrame()).not.toContain("edit/remove")

View file

@ -568,6 +568,17 @@ const scenarios: Scenario[] = [
.get("/experimental/session", "experimental.session.list")
.at((ctx) => ({ path: "/experimental/session?roots=false&archived=false", headers: ctx.headers() }))
.json(200, array),
http.protected
.post("/experimental/session/{sessionID}/background", "experimental.session.background")
.mutating()
.seeded((ctx) => ctx.session({ title: "Background route owner" }))
.at((ctx) => ({
path: route("/experimental/session/{sessionID}/background", { sessionID: ctx.state.id }),
headers: ctx.headers(),
}))
.json(200, (body) => {
check(body === false, "background route should be a no-op without running subagents")
}),
http.protected.get("/experimental/resource", "experimental.resource.list").json(),
http.protected
.post("/sync/history", "sync.history.list")

View file

@ -90,4 +90,23 @@ describe("session action routes", () => {
}),
{ git: true },
)
it.instance(
"experimental background route is a no-op without synchronous subagents",
() =>
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 res = yield* requestInDirectory(`/experimental/session/${session.id}/background`, test.directory, {
method: "POST",
})
expect(res.status).toBe(200)
expect(yield* res.json).toBe(false)
}),
{ git: true },
)
})

View file

@ -41,6 +41,11 @@ describe("getWorkspaceRouteSessionID", () => {
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_xyz"))
})
test("extracts session ID from experimental background path", () => {
const url = new URL("http://localhost/experimental/session/ses_bg/background")
expect(getWorkspaceRouteSessionID(url)).toBe(SessionID.make("ses_bg"))
})
test("returns null for /session/status", () => {
const url = new URL("http://localhost/session/status")
expect(getWorkspaceRouteSessionID(url)).toBeNull()

View file

@ -1,14 +1,13 @@
import { afterEach, describe, expect } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Database } from "@opencode-ai/core/database/database"
import { Effect, Exit, Fiber, Layer } from "effect"
import { Deferred, Effect, Exit, Fiber, Layer } from "effect"
import { Agent } from "../../src/agent/agent"
import { BackgroundJob } from "@/background/job"
import { EventV2Bridge } from "@/event-v2-bridge"
import { Config } from "@/config/config"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Session } from "@/session/session"
import { MessageV2 } from "../../src/session/message-v2"
import type { SessionPrompt } from "../../src/session/prompt"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { SessionRunState } from "@/session/run-state"
@ -484,6 +483,72 @@ describe("tool.task", () => {
}),
)
it.instance("promotes a running foreground task without restarting it", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()
const ready = yield* Deferred.make<void>()
const done = yield* Deferred.make<void>()
const injected = yield* Deferred.make<SessionPrompt.PromptInput>()
let runs = 0
const promptOps: TaskPromptOps = {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) => {
if (input.sessionID === chat.id) {
return Deferred.succeed(injected, input).pipe(Effect.as(reply(input, "injected")))
}
return Effect.gen(function* () {
runs += 1
yield* Deferred.succeed(ready, undefined)
yield* Deferred.await(done)
return reply(input, "background done")
})
},
}
const fiber = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: { promptOps },
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.forkChild)
yield* Deferred.await(ready)
const job = (yield* jobs.list())[0]
expect(job).toBeDefined()
if (!job) throw new Error("task job not found")
expect(job.metadata?.parentSessionId).toBe(chat.id)
yield* jobs.promote(job.id)
const result = yield* Fiber.join(fiber)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain(`state="running"`)
expect((yield* jobs.get(result.metadata.sessionId))?.status).toBe("running")
expect(runs).toBe(1)
yield* Deferred.succeed(done, undefined)
expect((yield* jobs.wait({ id: result.metadata.sessionId })).info?.output).toBe("background done")
expect((yield* Deferred.await(injected)).parts[0]?.type).toBe("text")
expect(runs).toBe(1)
}),
)
background.instance("execute launches background tasks without waiting for completion", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
@ -576,14 +641,14 @@ describe("tool.task", () => {
context,
)
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
expect(result.metadata.sessionId).toBe(started.metadata.sessionId)
expect(result.metadata.background).toBe(true)
expect(result.output).toContain("Background task updated")
first.resolve()
expect((yield* jobs.get(started.metadata.sessionId))?.status).toBe("running")
expect((yield* Effect.promise(() => updated.promise)).parts).toEqual([
{ type: "text", text: "also inspect cancellation" },
])
second.resolve()
const waited = yield* jobs.wait({ id: started.metadata.sessionId, timeout: 1_000 })
@ -784,6 +849,27 @@ describe("tool.task", () => {
}),
)
it.instance("cancelling a child run cancels its own pre-runner task job", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service
const runState = yield* SessionRunState.Service
const sessions = yield* Session.Service
const { chat } = yield* seed()
const child = yield* sessions.create({ parentID: chat.id, title: "child" })
yield* jobs.start({
id: child.id,
type: "task",
metadata: { parentSessionId: chat.id, sessionId: child.id },
run: Effect.never,
})
yield* runState.cancel(child.id)
expect((yield* jobs.get(child.id))?.status).toBe("cancelled")
}),
)
it.instance("cancelling a parent run recursively cancels descendant background tasks", () =>
Effect.gen(function* () {
const jobs = yield* BackgroundJob.Service