merge(dev): resolve latest manager and runtime-fallback conflicts

Sync the PR branch with the newest dev branch and resolve the new import-level conflicts in background-agent manager and runtime-fallback tests. Preserve both the delegated bootstrap coverage from this branch and the newer upstream test utilities and runtime wiring changes, then re-verify the affected delegated fallback suites and typecheck.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
tw-yshuang
2026-05-12 22:49:57 +08:00
225 changed files with 4552 additions and 2103 deletions
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import { buildBackgroundTaskNotificationText } from "./background-task-notification-template"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("buildBackgroundTaskNotificationText", () => {
describe("#given one task still running after a completed task notification", () => {
@@ -134,7 +135,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
const notification = buildBackgroundTaskNotificationText({
task: {
id: "bg_abc123",
description: undefined as unknown as string,
description: unsafeTestValue<string>(undefined),
status: "completed",
},
duration: "5s",
@@ -142,8 +143,8 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
allComplete: true,
remainingCount: 0,
completedTasks: [
{ id: "bg_abc123", description: undefined as unknown as string, status: "completed" },
{ id: "bg_def456", description: undefined as unknown as string, status: "completed" },
{ id: "bg_abc123", description: unsafeTestValue<string>(undefined), status: "completed" },
{ id: "bg_def456", description: unsafeTestValue<string>(undefined), status: "completed" },
],
})
@@ -230,7 +231,7 @@ Use \`background_output(task_id="<id>")\` to retrieve each result.
const notification = buildBackgroundTaskNotificationText({
task: {
id: "bg_xyz789",
description: undefined as unknown as string,
description: unsafeTestValue<string>(undefined),
status: "completed",
},
duration: "3s",
@@ -12,6 +12,7 @@ import {
setCompactionAgentConfigCheckpoint,
} from "../../shared/compaction-agent-config-checkpoint"
import { getCompactionPartStorageDir } from "../../shared/compaction-marker"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("isCompactionAgent", () => {
describe("#given agent name variations", () => {
@@ -49,7 +50,7 @@ describe("isCompactionAgent", () => {
test("returns false for null", () => {
// when
const result = isCompactionAgent(null as unknown as string)
const result = isCompactionAgent(unsafeTestValue<string>(null))
// then
expect(result).toBe(false)
@@ -6,6 +6,7 @@ import { tmpdir } from "node:os"
import type { BackgroundTaskConfig } from "../../config/schema"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createManager(config?: BackgroundTaskConfig): BackgroundManager {
const client = {
@@ -16,12 +17,12 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
},
}
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput, config: config })
const testManager = manager as unknown as {
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }), config: config })
const testManager = unsafeTestValue<{
enqueueNotificationForParent: (sessionId: string, fn: () => Promise<void>) => Promise<void>
notifyParentSession: (task: BackgroundTask) => Promise<void>
tasks: Map<string, BackgroundTask>
}
}>(manager)
testManager.enqueueNotificationForParent = async (_sessionId: string, fn) => {
await fn()
@@ -32,7 +33,7 @@ function createManager(config?: BackgroundTaskConfig): BackgroundManager {
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
return (unsafeTestValue<{ tasks: Map<string, BackgroundTask> }>(manager)).tasks
}
async function flushAsyncWork() {
@@ -4,8 +4,41 @@ import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("BackgroundManager session permission", () => {
test("passes parent directory route when prompting the child session", async () => {
// given
const promptCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async () => ({ data: { id: "ses_child" } }),
promptAsync: async (input: Record<string, unknown>) => {
promptCalls.push(input)
return {}
},
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }) })
// when
await manager.launch({
description: "Test task",
prompt: "Do something",
agent: "explore",
parentSessionId: "ses_parent",
parentMessageId: "msg_parent",
})
await new Promise(resolve => setTimeout(resolve, 50))
manager.shutdown()
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.query).toEqual({ directory: "/parent" })
})
test("passes query directory when loading the parent session", async () => {
// given
const getCalls: Array<Record<string, unknown>> = []
@@ -21,7 +54,7 @@ describe("BackgroundManager session permission", () => {
},
}
const directory = tmpdir()
const manager = new BackgroundManager({ pluginContext: { client, directory } as unknown as PluginInput })
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory }) })
// when
await manager.launch({
@@ -62,7 +95,7 @@ describe("BackgroundManager session permission", () => {
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: { client, directory: tmpdir() } as unknown as PluginInput })
const manager = new BackgroundManager({ pluginContext: unsafeTestValue<PluginInput>({ client, directory: tmpdir() }) })
// when
await manager.launch({
@@ -345,6 +345,47 @@ describe("BackgroundManager pollRunningTasks", () => {
expect(task.status).toBe("completed")
expect(todoCallCount).toBe(0)
})
test("#when cached incomplete todos become complete before idle polling #then refreshes todos and completes", async () => {
//#given
let todoCallCount = 0
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-idle-stale-todos": { type: "idle" } } }),
todo: async () => {
todoCallCount += 1
return {
data: [
{ content: "compile result", status: "completed", priority: "high" },
],
}
},
})
const task = createRunningTask("ses-idle-stale-todos")
injectTask(manager, task)
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "ses-idle-stale-todos", type: "text" },
})
manager.handleEvent({
type: "todo.updated",
properties: {
sessionID: "ses-idle-stale-todos",
todos: [
{ content: "compile result", status: "in_progress", priority: "high" },
],
},
})
//#when
const poll = manager["pollRunningTasks"]
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
expect(todoCallCount).toBe(1)
})
})
describe("#given a running task whose session status is busy", () => {
@@ -5248,6 +5248,54 @@ describe("BackgroundManager.handleEvent - session.error", () => {
manager.shutdown()
})
test("completes task when session.idle carries session id in info", async () => {
//#given
const sessionID = "ses-info-idle-completes-task"
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
messages: async () => ({
data: [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "done" }],
},
],
}),
todo: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
stubNotifyParentSession(manager)
const task = createMockTask({
id: "task-info-idle-completes",
sessionId: sessionID,
parentSessionId: "parent-session",
parentMessageId: "msg-info-idle",
description: "task completed by nested idle event",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - (MIN_IDLE_TIME_MS + 10)),
})
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.idle",
properties: { info: { id: sessionID } },
})
await new Promise((resolve) => setTimeout(resolve, 10))
//#then
expect(task.status).toBe("completed")
manager.shutdown()
})
test("completes task on session.status idle after todo-continuation finishes", async () => {
//#given
const sessionID = "ses-status-idle-after-todo-continuation"
@@ -5972,6 +6020,54 @@ describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
expect(task.progress!.toolCalls).toBe(2)
})
test("should update lastUpdate when legacy message.part.updated only has part session id", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ pluginContext: createPluginInput(client) })
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-part-only-1",
sessionId: "session-part-only-1",
parentSessionId: "parent-1",
parentMessageId: "msg-1",
description: "Legacy part-only task",
prompt: "Keep working",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a legacy message.part.updated event arrives without top-level sessionID
manager.handleEvent({
type: "message.part.updated",
properties: {
part: {
id: "part-1",
messageID: "msg-1",
sessionID: "session-part-only-1",
type: "text",
text: "still working",
},
},
})
//#then - lastUpdate should be refreshed, toolCalls should remain 0
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(0)
})
test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
+48 -185
View File
@@ -14,10 +14,13 @@ import {
getAgentToolRestrictions,
normalizePromptTools,
normalizeSDKResponse,
promptWithModelSuggestionRetry,
resolveInheritedPromptTools,
createInternalAgentTextPart,
messagesInDirectory,
promptAsyncInDirectory,
promptWithRetryInDirectory,
} from "../../shared"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -93,7 +96,6 @@ import {
registerDelegatedChildSessionBootstrap,
} from "../../shared/delegated-child-session-bootstrap"
import { settleAfterSessionIdle } from "../../hooks/shared/session-idle-settle"
type OpencodeClient = PluginInput["client"]
type ParentWakePromptContext = {
@@ -103,15 +105,6 @@ type ParentWakePromptContext = {
tools?: Record<string, boolean>
}
type PendingParentWake = {
promptContext: ParentWakePromptContext
notifications: string[]
}
type SessionStatusInfo = { type?: string }
const PENDING_PARENT_WAKE_RETRY_MS = 1_000
interface MessagePartInfo {
id?: string
sessionID?: string
@@ -122,7 +115,7 @@ interface MessagePartInfo {
interface EventProperties {
sessionID?: string
info?: { id?: string }
info?: { id?: string; sessionID?: string }
[key: string]: unknown
}
@@ -233,8 +226,6 @@ export class BackgroundManager {
private completedTaskSummaries: Map<string, BackgroundTaskNotificationTask[]> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
private pendingParentWakes: Map<string, PendingParentWake> = new Map()
private pendingParentWakeTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private observedOutputSessions: Set<string> = new Set()
private observedIncompleteTodosBySession: Map<string, boolean> = new Map()
private rootDescendantCounts: Map<string, number>
@@ -810,10 +801,10 @@ The fallback retry session is now created and can be inspected directly.
parts: [createInternalAgentTextPart(input.prompt)],
}
promptWithModelSuggestionRetry(this.client, {
promptWithRetryInDirectory(this.client, {
path: { id: sessionID },
body: promptBody,
}).catch(async (error) => {
}, parentDirectory).catch(async (error) => {
// Retry with fallback agent if the original agent was unregistered (e.g., after a model switch)
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", {
@@ -826,10 +817,10 @@ The fallback retry session is now created and can be inspected directly.
includeTeamToolDenylist: input.teamRunId === undefined,
})
setSessionTools(sessionID, fallbackBody.tools as Record<string, boolean>)
await promptWithModelSuggestionRetry(this.client, {
await promptWithRetryInDirectory(this.client, {
path: { id: sessionID },
body: fallbackBody,
})
}, parentDirectory)
task.agent = FALLBACK_AGENT
return
} catch (retryError) {
@@ -1175,7 +1166,7 @@ The fallback retry session is now created and can be inspected directly.
applySessionPromptParams(existingTask.sessionId!, existingTask.model)
}
this.client.session.promptAsync({
promptAsyncInDirectory(this.client, {
path: { id: existingTask.sessionId },
body: {
agent: existingTask.agent,
@@ -1195,7 +1186,7 @@ The fallback retry session is now created and can be inspected directly.
})(),
parts: [createInternalAgentTextPart(input.prompt)],
},
}).catch(async (error) => {
}, this.directory).catch(async (error) => {
log("[background-agent] resume prompt error:", error)
const errorInfo = {
name: extractErrorName(error),
@@ -1238,8 +1229,8 @@ The fallback retry session is now created and can be inspected directly.
private async checkSessionTodos(sessionID: string): Promise<boolean> {
const observedIncompleteTodos = this.observedIncompleteTodosBySession.get(sessionID)
if (observedIncompleteTodos !== undefined) {
return observedIncompleteTodos
if (observedIncompleteTodos === false) {
return false
}
try {
@@ -1279,8 +1270,9 @@ The fallback retry session is now created and can be inspected directly.
this.observedIncompleteTodosBySession.delete(sessionID)
}
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined): boolean {
if (!partInfo?.sessionID) return false
private hasOutputSignalFromPart(partInfo: MessagePartInfo | undefined, sessionID?: string): boolean {
if (!partInfo) return false
if (!partInfo.sessionID && !sessionID) return false
if (partInfo.tool) return true
if (partInfo.type === "tool" || partInfo.type === "tool_result") return true
if (partInfo.type === "text" || partInfo.type === "reasoning") return true
@@ -1298,9 +1290,9 @@ The fallback retry session is now created and can be inspected directly.
const info = props?.info
if (!info || typeof info !== "object") return
const sessionID = (info as Record<string, unknown>)["sessionID"]
const sessionID = resolveMessageEventSessionID(props)
const role = (info as Record<string, unknown>)["role"]
if (typeof sessionID !== "string") return
if (!sessionID) return
if (role === "tool") {
this.markSessionOutputObserved(sessionID)
@@ -1331,7 +1323,7 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
const partInfo = resolveMessagePartInfo(props)
const sessionID = partInfo?.sessionID
const sessionID = resolveMessageEventSessionID(props)
if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID)
@@ -1339,7 +1331,7 @@ The fallback retry session is now created and can be inspected directly.
const { task } = resolved
if (this.hasOutputSignalFromPart(partInfo)) {
if (this.hasOutputSignalFromPart(partInfo, sessionID)) {
this.markSessionOutputObserved(sessionID)
}
@@ -1423,7 +1415,7 @@ The fallback retry session is now created and can be inspected directly.
}
if (event.type === "todo.updated") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
const sessionID = resolveSessionEventID(props)
const todos = Array.isArray(props?.todos) ? props.todos : undefined
if (!sessionID || !todos) return
@@ -1438,12 +1430,6 @@ The fallback retry session is now created and can be inspected directly.
if (event.type === "session.idle") {
if (!props || typeof props !== "object") return
const sessionID = typeof props.sessionID === "string" ? props.sessionID : undefined
if (sessionID) {
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to flush pending parent wake:", { sessionID, error })
})
}
handleSessionIdleBackgroundEvent({
properties: props as Record<string, unknown>,
findBySession: (id) => {
@@ -1459,7 +1445,7 @@ The fallback retry session is now created and can be inspected directly.
}
if (event.type === "session.error") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
const resolved = this.resolveTaskAttemptBySession(sessionID)
@@ -1488,9 +1474,8 @@ The fallback retry session is now created and can be inspected directly.
}
if (event.type === "session.deleted") {
const info = props?.info
if (!info || typeof info.id !== "string") return
const sessionID = info.id
const sessionID = resolveSessionEventID(props)
if (!sessionID) return
this.clearSessionOutputObserved(sessionID)
this.clearSessionTodoObservation(sessionID)
@@ -1548,7 +1533,7 @@ The fallback retry session is now created and can be inspected directly.
}
if (event.type === "session.status") {
const sessionID = props?.sessionID as string | undefined
const sessionID = resolveSessionEventID(props)
const status = props?.status as { type?: string; message?: string } | undefined
if (!sessionID || !status?.type) return
@@ -1778,9 +1763,9 @@ The task was re-queued on a fallback model after a retryable failure.
}
try {
const response = await this.client.session.messages({
const response = await messagesInDirectory(this.client, {
path: { id: sessionID },
})
}, this.directory)
const messages = normalizeSDKResponse(response, [] as Array<{ info?: { role?: string } }>, { preferResponseOnMissingData: true })
@@ -2189,7 +2174,9 @@ The task was re-queued on a fallback model after a retryable failure.
if (this.enableParentSessionNotifications) {
try {
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionId } })
const messagesResp = await messagesInDirectory(this.client, {
path: { id: task.parentSessionId },
}, this.directory)
const messages = normalizeSDKResponse(messagesResp, [] as Array<{
info?: {
agent?: string
@@ -2250,42 +2237,30 @@ The task was re-queued on a fallback model after a retryable failure.
...(variant !== undefined ? { variant } : {}),
...(resolvedTools ? { tools: resolvedTools } : {}),
}
const shouldDeferReply = shouldReply && await this.isSessionActive(task.parentSessionId)
if (shouldDeferReply) {
this.queuePendingParentWake(task.parentSessionId, notification, parentPromptContext)
log("[background-agent] Deferred notification until parent session is idle:", {
try {
await promptAsyncInDirectory(this.client, {
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
}, this.directory)
log("[background-agent] Sent notification to parent session:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: !shouldReply,
})
} else {
try {
await this.client.session.promptAsync({
path: { id: task.parentSessionId },
body: {
noReply: !shouldReply,
...parentPromptContext,
parts: [createInternalAgentTextPart(notification)],
},
})
log("[background-agent] Sent notification to parent session:", {
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
allComplete,
isTaskFailure,
noReply: !shouldReply,
deferredReply: false,
parentSessionID: task.parentSessionId,
})
} catch (error) {
if (isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
taskId: task.id,
parentSessionID: task.parentSessionId,
})
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
this.queuePendingNotification(task.parentSessionId, notification)
} else {
log("[background-agent] Failed to send notification:", error)
}
}
} else {
@@ -2307,112 +2282,6 @@ The task was re-queued on a fallback model after a retryable failure.
return false
}
private async isSessionActive(sessionID: string): Promise<boolean> {
const sessionStatusMethod = this.client?.session?.status
if (typeof sessionStatusMethod !== "function") {
return false
}
try {
const statusResult = await this.client.session.status()
const statuses = normalizeSDKResponse(
statusResult,
{} as Record<string, SessionStatusInfo>,
)
const status = statuses[sessionID]
return typeof status?.type === "string" && isActiveSessionStatus(status.type)
} catch (error) {
log("[background-agent] Unable to check parent session status before wake:", {
sessionID,
error,
})
return false
}
}
private queuePendingParentWake(
sessionID: string,
notification: string,
promptContext: ParentWakePromptContext,
): void {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (pendingWake) {
pendingWake.notifications.push(notification)
pendingWake.promptContext = promptContext
} else {
this.pendingParentWakes.set(sessionID, {
promptContext,
notifications: [notification],
})
}
this.schedulePendingParentWakeFlush(sessionID)
}
private async flushPendingParentWake(sessionID: string): Promise<void> {
const pendingWake = this.pendingParentWakes.get(sessionID)
if (!pendingWake) {
this.clearPendingParentWakeTimer(sessionID)
return
}
if (await this.isSessionActive(sessionID)) {
this.schedulePendingParentWakeFlush(sessionID)
return
}
this.pendingParentWakes.delete(sessionID)
this.clearPendingParentWakeTimer(sessionID)
await settleAfterSessionIdle()
if (await this.isSessionActive(sessionID)) {
this.pendingParentWakes.set(sessionID, pendingWake)
this.schedulePendingParentWakeFlush(sessionID)
return
}
const notificationContent = pendingWake.notifications.join("\n\n")
try {
await this.client.session.promptAsync({
path: { id: sessionID },
body: {
noReply: false,
...pendingWake.promptContext,
parts: [createInternalAgentTextPart(notificationContent)],
},
})
log("[background-agent] Sent deferred parent wake:", { sessionID })
} catch (error) {
this.queuePendingNotification(sessionID, notificationContent)
log("[background-agent] Failed to send deferred parent wake:", { sessionID, error })
}
}
private schedulePendingParentWakeFlush(sessionID: string): void {
if (this.pendingParentWakeTimers.has(sessionID)) {
return
}
const timer = setTimeout(() => {
this.pendingParentWakeTimers.delete(sessionID)
void this.enqueueNotificationForParent(sessionID, () => this.flushPendingParentWake(sessionID)).catch((error) => {
log("[background-agent] Failed to retry pending parent wake:", { sessionID, error })
})
}, PENDING_PARENT_WAKE_RETRY_MS)
this.pendingParentWakeTimers.set(sessionID, timer)
}
private clearPendingParentWakeTimer(sessionID: string): void {
const timer = this.pendingParentWakeTimers.get(sessionID)
if (!timer) {
return
}
clearTimeout(timer)
this.pendingParentWakeTimers.delete(sessionID)
}
private pruneStaleTasksAndNotifications(allStatuses?: SessionStatusMap): void {
pruneStaleTasksAndNotifications({
tasks: this.tasks,
@@ -2726,11 +2595,6 @@ The task was re-queued on a fallback model after a retryable failure.
}
this.idleDeferralTimers.clear()
for (const timer of this.pendingParentWakeTimers.values()) {
clearTimeout(timer)
}
this.pendingParentWakeTimers.clear()
for (const sessionID of trackedSessionIDs) {
subagentSessions.delete(sessionID)
this.cleanupDelegatedSessionContext(sessionID)
@@ -2743,7 +2607,6 @@ The task was re-queued on a fallback model after a retryable failure.
this.pendingNotifications.clear()
this.pendingByParent.clear()
this.notificationQueueByParent.clear()
this.pendingParentWakes.clear()
this.rootDescendantCounts.clear()
this.queuesByKey.clear()
this.processingKeys.clear()
@@ -2,16 +2,17 @@ import { describe, expect, mock, test } from "bun:test"
import type { OpencodeClient } from "./opencode-client"
import { verifySessionExists } from "./session-existence"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
describe("verifySessionExists", () => {
test("passes query directory to session lookup when provided", async () => {
// given
const get = mock(async () => ({ data: { id: "session-123" } }))
const client = {
const client = unsafeTestValue<OpencodeClient>({
session: {
get,
},
} as unknown as OpencodeClient
})
// when
const result = await verifySessionExists(client, "session-123", "/project/root")
@@ -1,12 +1,8 @@
import { log } from "../../shared"
import { resolveSessionEventID } from "../../shared/event-session-id"
import { MIN_IDLE_TIME_MS } from "./constants"
import type { BackgroundTask } from "./types"
function getString(obj: Record<string, unknown>, key: string): string | undefined {
const value = obj[key]
return typeof value === "string" ? value : undefined
}
export function handleSessionIdleBackgroundEvent(args: {
properties: Record<string, unknown>
findBySession: (sessionID: string) => BackgroundTask | undefined
@@ -26,7 +22,7 @@ export function handleSessionIdleBackgroundEvent(args: {
emitIdleEvent,
} = args
const sessionID = getString(properties, "sessionID")
const sessionID = resolveSessionEventID(properties)
if (!sessionID) return
const task = findBySession(sessionID)
@@ -535,6 +535,58 @@ describe("background-agent spawner fallback model promotion", () => {
])
})
test("passes parent directory route when prompting the child session", async () => {
// given
const promptCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir" } }),
create: async () => ({ data: { id: "ses_child_query" } }),
promptAsync: async (input: Record<string, unknown>) => {
promptCalls.push(input)
return {}
},
},
}
const task = createTask({
description: "Test task",
prompt: "Do work",
agent: "sisyphus-junior",
parentSessionId: "ses_parent",
parentMessageId: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionId: task.parentSessionId,
parentMessageId: task.parentMessageId,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
},
}
// when
await startTask(item as never, {
client: client as never,
directory: "/fallback",
concurrencyManager: { release: () => {} } as never,
tmuxEnabled: false,
onTaskError: () => {},
})
await new Promise((resolve) => setTimeout(resolve, 0))
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0]?.query).toEqual({ directory: "/parent/dir" })
})
test("strips leading zwsp from prompt body agent before promptAsync", async () => {
//#given
const promptCalls: Array<{ body?: { agent?: string } }> = []
+19 -18
View File
@@ -1,6 +1,6 @@
import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
@@ -171,10 +171,10 @@ export async function startTask(
}
// Must fire BEFORE tmux callback: attach client needs session activity to render TUI.
const promptChain = promptWithModelSuggestionRetry(client, {
const promptChain = promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: promptBody,
}).catch(async (error) => {
}, parentDirectory).catch(async (error) => {
if (isAgentNotFoundError(error) && input.agent !== FALLBACK_AGENT) {
log("[background-agent] Agent not found, retrying with fallback agent", {
original: input.agent,
@@ -182,12 +182,12 @@ export async function startTask(
taskId: task.id,
})
try {
await promptWithModelSuggestionRetry(client, {
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(promptBody, FALLBACK_AGENT, {
includeTeamToolDenylist: input.teamRunId === undefined,
}),
})
}, parentDirectory)
task.agent = FALLBACK_AGENT
return
} catch (retryError) {
@@ -227,18 +227,19 @@ export async function startTask(
export async function resumeTask(
task: BackgroundTask,
input: ResumeInput,
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "onTaskError">
ctx: Pick<SpawnerContext, "client" | "concurrencyManager" | "directory" | "onTaskError">
): Promise<void> {
const { client, concurrencyManager, onTaskError } = ctx
const { client, concurrencyManager, directory, onTaskError } = ctx
if (!task.sessionId) {
throw new Error(`Task has no sessionID: ${task.id}`)
}
const sessionID = task.sessionId
if (task.status === "running") {
log("[background-agent] Resume skipped - task already running:", {
taskId: task.id,
sessionID: task.sessionId,
sessionID,
})
return
}
@@ -262,7 +263,7 @@ export async function resumeTask(
lastUpdate: new Date(),
}
subagentSessions.add(task.sessionId)
subagentSessions.add(sessionID)
const toastManager = getTaskToastManager()
if (toastManager) {
@@ -274,10 +275,10 @@ export async function resumeTask(
})
}
log("[background-agent] Resuming task:", { taskId: task.id, sessionID: task.sessionId })
log("[background-agent] Resuming task:", { taskId: task.id, sessionID })
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
sessionID: task.sessionId,
sessionID,
agent: task.agent,
model: task.model,
promptLength: input.prompt.length,
@@ -291,7 +292,7 @@ export async function resumeTask(
: undefined
const resumeVariant = task.model?.variant
applySessionPromptParams(task.sessionId, task.model)
applySessionPromptParams(sessionID, task.model)
const resumeBody = {
agent: task.agent,
@@ -308,10 +309,10 @@ export async function resumeTask(
parts: [createInternalAgentTextPart(input.prompt)],
}
client.session.promptAsync({
path: { id: task.sessionId },
promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: resumeBody,
}).catch(async (error) => {
}, directory).catch(async (error) => {
if (isAgentNotFoundError(error) && task.agent !== FALLBACK_AGENT) {
log("[background-agent] Resume agent not found, retrying with fallback agent", {
original: task.agent,
@@ -319,12 +320,12 @@ export async function resumeTask(
taskId: task.id,
})
try {
await promptWithModelSuggestionRetry(client, {
path: { id: task.sessionId! },
await promptWithRetryInDirectory(client, {
path: { id: sessionID },
body: buildFallbackBody(resumeBody, FALLBACK_AGENT, {
includeTeamToolDenylist: task.teamRunId === undefined,
}),
})
}, directory)
task.agent = FALLBACK_AGENT
return
} catch (retryError) {
@@ -6,6 +6,7 @@ import {
DEFAULT_MAX_SUBAGENT_DEPTH,
createSubagentDepthLimitError,
} from "./subagent-spawn-limits"
import { unsafeTestValue } from "../../../test-support/unsafe-test-value"
function createMockClient(sessionGet: OpencodeClient["session"]["get"]): OpencodeClient {
return {
@@ -20,14 +21,14 @@ describe("resolveSubagentSpawnContext", () => {
test("passes query.directory to each session.get call", async () => {
// given
const sessionGetCalls: Array<Record<string, unknown>> = []
const client = createMockClient((async (input) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (input) => {
sessionGetCalls.push(input as Record<string, unknown>)
if (input.path.id === "child-session") {
return { data: { id: "child-session", parentID: "root-session" } }
}
return { data: { id: "root-session", parentID: undefined } }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when
const result = await resolveSubagentSpawnContext(client, "child-session", "/project/root")
@@ -50,10 +51,10 @@ describe("resolveSubagentSpawnContext", () => {
describe("#given session.get returns an SDK error response", () => {
test("throws a fail-closed spawn blocked error", async () => {
// given
const client = createMockClient((async () => ({
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
error: "lookup failed",
data: undefined,
})) as unknown as OpencodeClient["session"]["get"])
}))))
// when
const result = resolveSubagentSpawnContext(client, "parent-session")
@@ -66,9 +67,9 @@ describe("resolveSubagentSpawnContext", () => {
describe("#given session.get returns no session data", () => {
test("throws a fail-closed spawn blocked error", async () => {
// given
const client = createMockClient((async () => ({
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async () => ({
data: undefined,
})) as unknown as OpencodeClient["session"]["get"])
}))))
// when
const result = resolveSubagentSpawnContext(client, "parent-session")
@@ -81,12 +82,12 @@ describe("resolveSubagentSpawnContext", () => {
describe("depth calculation smoke tests (regression guard)", () => {
test("root session (no parentID) reports depth 0 and childDepth 1", async () => {
// given - a root session with no parent
const client = createMockClient((async (opts) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
if (opts.path.id === "root-session") {
return { data: { id: "root-session", parentID: undefined } }
}
return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when
const result = await resolveSubagentSpawnContext(client, "root-session")
@@ -99,7 +100,7 @@ describe("resolveSubagentSpawnContext", () => {
test("depth-1 child reports childDepth 2", async () => {
// given - child -> root chain
const client = createMockClient((async (opts) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
if (opts.path.id === "child-1") {
return { data: { id: "child-1", parentID: "root-session" } }
}
@@ -107,7 +108,7 @@ describe("resolveSubagentSpawnContext", () => {
return { data: { id: "root-session", parentID: undefined } }
}
return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when
const result = await resolveSubagentSpawnContext(client, "child-1")
@@ -120,7 +121,7 @@ describe("resolveSubagentSpawnContext", () => {
test("depth-2 grandchild reports childDepth 3", async () => {
// given - grandchild -> child -> root chain
const client = createMockClient((async (opts) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const sessions: Record<string, { id: string; parentID?: string }> = {
"grandchild": { id: "grandchild", parentID: "child" },
"child": { id: "child", parentID: "root" },
@@ -129,7 +130,7 @@ describe("resolveSubagentSpawnContext", () => {
const session = sessions[opts.path.id]
if (session) return { data: session }
return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when
const result = await resolveSubagentSpawnContext(client, "grandchild")
@@ -153,11 +154,11 @@ describe("resolveSubagentSpawnContext", () => {
}
}
const client = createMockClient((async (opts) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const session = sessions[opts.path.id]
if (session) return { data: session }
return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when - resolve from the deepest session
const deepest = `session-${DEFAULT_MAX_SUBAGENT_DEPTH}`
@@ -170,7 +171,7 @@ describe("resolveSubagentSpawnContext", () => {
test("detects parent cycle and throws", async () => {
// given - A -> B -> A (cycle)
const client = createMockClient((async (opts) => {
const client = createMockClient(unsafeTestValue<OpencodeClient["session"]["get"]>((async (opts) => {
const sessions: Record<string, { id: string; parentID?: string }> = {
"session-a": { id: "session-a", parentID: "session-b" },
"session-b": { id: "session-b", parentID: "session-a" },
@@ -178,7 +179,7 @@ describe("resolveSubagentSpawnContext", () => {
const session = sessions[opts.path.id]
if (session) return { data: session }
return { error: "not found", data: undefined }
}) as unknown as OpencodeClient["session"]["get"])
})))
// when
const result = resolveSubagentSpawnContext(client, "session-a")
@@ -4,6 +4,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker"
type PromptAsyncCall = {
path: { id: string }
@@ -11,6 +12,9 @@ type PromptAsyncCall = {
noReply?: boolean
parts?: unknown[]
}
query?: {
directory: string
}
}
type FakeTimers = {
@@ -159,14 +163,6 @@ async function notifyParentSessionForTest(manager: BackgroundManager, task: Back
return notifyParentSession.call(manager, task)
}
function waitForDeferredWake(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 180))
}
function waitForDeferredWakeRetry(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_180))
}
function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnType<typeof setTimeout> {
const timer = getCompletionTimers(manager).get(taskID)
expect(timer).toBeDefined()
@@ -241,6 +237,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// then
expect(promptAsyncCalls).toHaveLength(2)
expect(promptAsyncCalls[0]?.body.noReply).toBe(true)
expect(getCompletionTimers(manager).size).toBe(2)
const allCompleteCall = promptAsyncCalls[1]
expect(allCompleteCall).toBeDefined()
@@ -251,13 +248,14 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
expect(allCompleteCall.body.noReply).toBe(false)
const allCompletePayload = JSON.stringify(allCompleteCall.body.parts)
expect(allCompletePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(allCompletePayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
expect(allCompletePayload).toContain(taskA.id)
expect(allCompletePayload).toContain(taskB.id)
expect(allCompletePayload).toContain(taskA.description)
expect(allCompletePayload).toContain(taskB.description)
})
test("#when parent session is busy #then all-complete notification does not start an overlapping parent reply", async () => {
test("#when parent session is busy #then all-complete notification keeps the direct 4.0.0 parent prompt behavior", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
@@ -272,10 +270,32 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(0)
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(notificationPayload).toContain(OMO_INTERNAL_INITIATOR_MARKER)
})
test("#when deferred parent session becomes idle #then completion notification wakes the parent without a pointer reminder", async () => {
test("#when all-complete notification wakes parent #then prompt stays in the same OpenCode directory instance", async () => {
// given
const { manager, promptAsyncCalls } = createManager(true)
managerUnderTest = manager
const directory = Reflect.get(manager, "directory") as string
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
// when
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
expect(promptAsyncCalls[0]?.query).toEqual({ directory })
})
test("#when busy parent later becomes idle #then completion notification is not replayed as a second parent prompt", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
@@ -286,21 +306,22 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
expect(promptAsyncCalls).toHaveLength(1)
// when
sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
await Promise.resolve()
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when a single background task finishes during a stale busy parent status #then completion notification is retried after the parent becomes idle", async () => {
test("#when a single background task finishes during a stale busy parent status #then no deferred wake is scheduled", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
@@ -314,22 +335,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// when
await notifyParentSessionForTest(manager, task)
sessionStatuses["parent-1"] = { type: "idle" }
await waitForDeferredWakeRetry()
await new Promise((resolve) => setTimeout(resolve, 1_180))
// then
expect(promptAsyncCalls).toHaveLength(1)
expect(promptAsyncCalls[0]?.body.noReply).toBe(false)
const wakePayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(wakePayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(wakePayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts)
expect(notificationPayload).toContain("ALL BACKGROUND TASKS COMPLETE")
expect(notificationPayload).not.toContain("BACKGROUND TASK NOTIFICATION READY")
})
test("#when deferred completion notification send fails #then notification is queued for the next user message", async () => {
test("#when completion notification send is aborted #then notification is queued for the next user message", async () => {
// given
const sessionStatuses: Record<string, { type: string }> = {
"parent-1": { type: "busy" },
}
const promptError = new Error("promptAsync failed")
const promptError = new Error("Request aborted while waiting for input")
promptError.name = "MessageAbortedError"
const { manager, promptAsyncCalls } = createManager(true, sessionStatuses, async () => {
throw promptError
})
@@ -337,12 +359,9 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
const task = createTask({ id: "task-a", parentSessionId: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
getTasks(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionId, new Set([task.id]))
await notifyParentSessionForTest(manager, task)
// when
sessionStatuses["parent-1"] = { type: "idle" }
manager.handleEvent({ type: "session.idle", properties: { sessionID: "parent-1" } })
await waitForDeferredWake()
await notifyParentSessionForTest(manager, task)
// then
expect(promptAsyncCalls).toHaveLength(1)