feat(tui): allow backgrounding synchronous subagents (#30488)
This commit is contained in:
parent
8c0edca175
commit
3003867c25
26 changed files with 527 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue