refactor(background-agent): optimize lifecycle and simplify tools (#1411)

* refactor(background-agent): optimize cache timer lifecycle and result handling

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* refactor(background-task): simplify tool implementation and expand test coverage

Ultraworked with Sisyphus

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(background-task): fix BackgroundCancel tool parameter handling

Correct parameter names and types in BackgroundCancel tool to match actual usage patterns. Add comprehensive test coverage for parameter validation.

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-03 12:11:45 +09:00
committed by GitHub
parent b62519b401
commit e36dde6e64
4 changed files with 209 additions and 67 deletions
+79 -3
View File
@@ -1,7 +1,7 @@
import { createBackgroundOutput } from "./tools"
import type { BackgroundTask } from "../../features/background-agent"
import { createBackgroundCancel, createBackgroundOutput } from "./tools"
import type { BackgroundManager, BackgroundTask } from "../../features/background-agent"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { BackgroundOutputManager, BackgroundOutputClient } from "./tools"
import type { BackgroundCancelClient, BackgroundOutputManager, BackgroundOutputClient } from "./tools"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
@@ -253,6 +253,82 @@ describe("background_output full_session", () => {
expect(output).not.toContain("y".repeat(2100))
})
})
describe("background_cancel", () => {
test("cancels a running task via manager", async () => {
// #given
const task = createTask({ status: "running" })
const cancelled: string[] = []
const manager = {
getTask: (id: string) => (id === task.id ? task : undefined),
getAllDescendantTasks: () => [task],
cancelTask: async (taskId: string) => {
cancelled.push(taskId)
task.status = "cancelled"
return true
},
} as unknown as BackgroundManager
const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient
const tool = createBackgroundCancel(manager, client)
// #when
const output = await tool.execute({ taskId: task.id }, mockContext)
// #then
expect(cancelled).toEqual([task.id])
expect(output).toContain("Task cancelled successfully")
})
test("cancels all running or pending tasks", async () => {
// #given
const taskA = createTask({ id: "task-a", status: "running" })
const taskB = createTask({ id: "task-b", status: "pending" })
const cancelled: string[] = []
const manager = {
getTask: () => undefined,
getAllDescendantTasks: () => [taskA, taskB],
cancelTask: async (taskId: string) => {
cancelled.push(taskId)
const task = taskId === taskA.id ? taskA : taskB
task.status = "cancelled"
return true
},
} as unknown as BackgroundManager
const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient
const tool = createBackgroundCancel(manager, client)
// #when
const output = await tool.execute({ all: true }, mockContext)
// #then
expect(cancelled).toEqual([taskA.id, taskB.id])
expect(output).toContain("Cancelled 2 background task(s)")
})
test("preserves original status in cancellation table", async () => {
// #given
const taskA = createTask({ id: "task-a", status: "running", sessionID: "ses-a", description: "running task" })
const taskB = createTask({ id: "task-b", status: "pending", sessionID: undefined, description: "pending task" })
const manager = {
getTask: () => undefined,
getAllDescendantTasks: () => [taskA, taskB],
cancelTask: async (taskId: string) => {
const task = taskId === taskA.id ? taskA : taskB
task.status = "cancelled"
return true
},
} as unknown as BackgroundManager
const client = { session: { abort: async () => ({}) } } as BackgroundCancelClient
const tool = createBackgroundCancel(manager, client)
// #when
const output = await tool.execute({ all: true }, mockContext)
// #then
expect(output).toContain("| `task-a` | running task | running | `ses-a` |")
expect(output).toContain("| `task-b` | pending task | pending | (not started) |")
})
})
type BackgroundOutputMessage = {
id?: string
info?: { role?: string; time?: string | { created?: number }; agent?: string }
+21 -41
View File
@@ -638,28 +638,18 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Backg
}> = []
for (const task of cancellableTasks) {
if (task.status === "pending") {
manager.cancelPendingTask(task.id)
cancelledInfo.push({
id: task.id,
description: task.description,
status: "pending",
sessionID: undefined,
})
} else if (task.sessionID) {
client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
task.status = "cancelled"
task.completedAt = new Date()
cancelledInfo.push({
id: task.id,
description: task.description,
status: "running",
sessionID: task.sessionID,
})
}
const originalStatus = task.status
const cancelled = await manager.cancelTask(task.id, {
source: "background_cancel",
abortSession: originalStatus === "running",
})
if (!cancelled) continue
cancelledInfo.push({
id: task.id,
description: task.description,
status: originalStatus === "pending" ? "pending" : "running",
sessionID: task.sessionID,
})
}
const tableRows = cancelledInfo
@@ -679,7 +669,7 @@ Continuable sessions:
${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}`
: ""
return `Cancelled ${cancellableTasks.length} background task(s):
return `Cancelled ${cancelledInfo.length} background task(s):
| Task ID | Description | Status | Session ID |
|---------|-------------|--------|------------|
@@ -697,13 +687,15 @@ ${resumeSection}`
Only running or pending tasks can be cancelled.`
}
if (task.status === "pending") {
// Pending task: use manager method (no session to abort, no slot to release)
const cancelled = manager.cancelPendingTask(task.id)
if (!cancelled) {
return `[ERROR] Failed to cancel pending task: ${task.id}`
}
const cancelled = await manager.cancelTask(task.id, {
source: "background_cancel",
abortSession: task.status === "running",
})
if (!cancelled) {
return `[ERROR] Failed to cancel task: ${task.id}`
}
if (task.status === "pending") {
return `Pending task cancelled successfully
Task ID: ${task.id}
@@ -711,18 +703,6 @@ Description: ${task.description}
Status: ${task.status}`
}
// Running task: abort session
// Fire-and-forget: abort 요청을 보내고 await 하지 않음
// await 하면 메인 세션까지 abort 되는 문제 발생
if (task.sessionID) {
client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
}
task.status = "cancelled"
task.completedAt = new Date()
return `Task cancelled successfully
Task ID: ${task.id}