From 982fa8136721784246aa2a295a2cf4247294328a Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 16:21:48 +0900 Subject: [PATCH 1/9] fix(delegate-task): start child prompts reliably Preserve delegated child prompt/bootstrap metadata for early runtime fallback before OpenCode has persisted the first user turn. Bind prompt gate calls to the SDK session receiver and keep completed background task lookup visible across plugin manager instances. --- src/features/background-agent/manager.test.ts | 186 ++++++++++++++++++ src/features/background-agent/manager.ts | 165 ++++++++++------ .../background-agent/task-registry.ts | 92 +++++++++ src/hooks/runtime-fallback/auto-retry.ts | 4 +- src/hooks/runtime-fallback/index.test.ts | 115 ++++++++++- .../runtime-fallback/last-user-retry-parts.ts | 20 +- src/hooks/shared/prompt-async-gate.test.ts | 60 ++++++ .../delegated-child-session-bootstrap.ts | 71 +++++++ src/shared/prompt-async-gate.ts | 14 +- src/tools/delegate-task/sync-task.test.ts | 60 ++++++ src/tools/delegate-task/sync-task.ts | 43 ++-- 11 files changed, 742 insertions(+), 88 deletions(-) create mode 100644 src/features/background-agent/task-registry.ts create mode 100644 src/shared/delegated-child-session-bootstrap.ts diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 3adbab455..030b3670d 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -15,7 +15,15 @@ import { ConcurrencyManager } from "./concurrency" import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate" import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager" import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup" +import { clearBackgroundTaskRegistryForTesting } from "./task-registry" +import { + clearAllDelegatedChildSessionBootstrap, + getDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" +afterEach(() => { + clearBackgroundTaskRegistryForTesting() +}) const TASK_TTL_MS = 30 * 60 * 1000 type PendingParentWakeForTest = { @@ -458,6 +466,71 @@ describe("BackgroundManager session.error fallback hydration", () => { }) }) +describe("BackgroundManager delegated child-session bootstrap", () => { + test("registers launch bootstrap before first prompt and clears it after completion", async () => { + //#given + clearAllDelegatedChildSessionBootstrap() + const observedBootstrapPrompts: string[] = [] + const client = { + session: { + get: async () => ({ data: { directory: tmpdir() } }), + create: async () => ({ data: { id: "ses_background_bootstrap" } }), + promptAsync: async () => { + const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap") + observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "") + return {} + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + stubNotifyParentSession(manager) + const task = createMockTask({ + id: "bg_bootstrap", + parentSessionId: "parent-session", + status: "pending", + queuedAt: new Date(), + prompt: "background bootstrap prompt", + agent: "sisyphus-junior", + category: "quick", + model: { providerID: "anthropic", modelID: "claude-haiku-4-5" }, + fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }], + }) + getTaskMap(manager).set(task.id, task) + const input = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + parentModel: task.parentModel, + parentAgent: task.parentAgent, + parentTools: task.parentTools, + model: task.model, + fallbackChain: task.fallbackChain, + category: task.category, + } + + try { + //#when + await (cast<{ startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise }>(manager)) + .startTask({ task, input }) + await flushBackgroundNotifications() + + //#then + expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt") + expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined() + + const completed = await tryCompleteTaskForTest(manager, task) + expect(completed).toBe(true) + expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeUndefined() + } finally { + manager.shutdown() + clearAllDelegatedChildSessionBootstrap() + } + }) +}) + describe("BackgroundManager prompt rejection fallback routing", () => { test("routes launch-time prompt rejections into tryFallbackRetry before marking interrupt", async () => { //#given @@ -6721,6 +6794,119 @@ describe("BackgroundManager regression fixes - resume and aborted notification", manager.shutdown() }) + test("should resolve a completed task registered by an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-regression", + sessionId: "session-cross-manager-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager regression", + prompt: "test", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + + //#when + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + + firstManager.shutdown() + secondManager.shutdown() + }) + + test("should resolve archived completed task from an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-archive-regression", + sessionId: "session-cross-manager-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager archive regression", + prompt: "sensitive prompt", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + getTaskMap(firstManager).set(task.id, task) + + //#when + ;(cast<{ removeTask: (task: BackgroundTask) => void }>(firstManager)).removeTask(task) + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + expect(resolvedTask?.prompt).toBe("[redacted]") + + firstManager.shutdown() + secondManager.shutdown() + }) + + test("should archive terminal registry tasks during earlier manager shutdown", async () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-shutdown-archive-regression", + sessionId: "session-shutdown-archive-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "shutdown archive regression", + prompt: "sensitive shutdown prompt", + agent: "explore", + status: "completed", + startedAt: new Date(), + completedAt: new Date(), + } + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#when + await firstManager.shutdown() + + //#then + const resolvedTask = secondManager.getTask(task.id) + expect(resolvedTask?.sessionId).toBe(task.sessionId) + expect(resolvedTask?.prompt).toBe("[redacted]") + + await secondManager.shutdown() + }) + + test("should forget active registry tasks during earlier manager shutdown", async () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-shutdown-active-regression", + sessionId: "session-shutdown-active-regression", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "shutdown active regression", + prompt: "test", + agent: "explore", + status: "running", + startedAt: new Date(), + } + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + + //#when + await firstManager.shutdown() + + //#then + expect(secondManager.getTask(task.id)).toBeUndefined() + + await secondManager.shutdown() + }) + test("should cap completed task archive size at 100 entries", () => { //#given const manager = createBackgroundManager() diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index fab28ccd5..9f96aa4e4 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -1,99 +1,107 @@ - +import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" +import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" +import { setContinuationMarkerSource } from "../../features/run-continuation-state" import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback" -import { isAgentNotFoundError, FALLBACK_AGENT, buildFallbackBody } from "./spawner" -import type { - BackgroundTask, - BackgroundTaskAttempt, - LaunchInput, - ResumeInput, -} from "./types" -import { TaskHistory } from "./task-history" +import { type PromptAsyncGateResult, promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate" +import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle" import { - log, + createInternalAgentTextPart, getAgentToolRestrictions, + log, + messagesInDirectory, normalizePromptTools, normalizeSDKResponse, - resolveInheritedPromptTools, - createInternalAgentTextPart, - messagesInDirectory, promptWithRetryInDirectory, + resolveInheritedPromptTools, } from "../../shared" +import { + clearDelegatedChildSessionBootstrap, + registerDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" import { resolveMessageEventSessionID, resolveSessionEventID } from "../../shared/event-session-id" +import { + hasMoreFallbacks, + shouldRetryError, +} from "../../shared/model-error-classifier" +import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" -import { SessionCategoryRegistry } from "../../shared/session-category-registry" -import { ConcurrencyManager } from "./concurrency" -import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema" import { isInsideTmux } from "../../shared/tmux" -import { - shouldRetryError, - hasMoreFallbacks, -} from "../../shared/model-error-classifier" -import { - POLLING_INTERVAL_MS, - TASK_CLEANUP_DELAY_MS, - TASK_TTL_MS, - type QueueItem, -} from "./constants" - import { subagentSessions } from "../claude-code-session-state" +import { MESSAGE_STORAGE } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" -import { formatDuration } from "./duration-formatter" -import { - buildBackgroundTaskNotificationText, - type BackgroundTaskNotificationTask, -} from "./background-task-notification-template" -import { - isAbortedSessionError, - extractErrorName, - extractErrorMessage, - extractErrorStatusCode, - getSessionErrorMessage, - isRecord, -} from "./error-classifier" -import { tryFallbackRetry } from "./fallback-retry-handler" +import { abortWithTimeout } from "./abort-with-timeout" import { bindAttemptSession, ensureCurrentAttempt, - findAttemptBySession, finalizeAttempt, + findAttemptBySession, getCurrentAttempt, startAttempt, } from "./attempt-lifecycle" -import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" -import { setContinuationMarkerSource } from "../../features/run-continuation-state" -import { isSessionActive as isOpenCodeSessionActive } from "../../hooks/shared/session-idle-settle" -import { promptAsyncAfterSessionIdle, type PromptAsyncGateResult } from "../../hooks/shared/prompt-async-gate" +import { + type BackgroundTaskNotificationTask, + buildBackgroundTaskNotificationText, +} from "./background-task-notification-template" import { findNearestMessageExcludingCompaction, resolvePromptContextFromSessionMessages, } from "./compaction-aware-message-resolver" -import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler" -import { MESSAGE_STORAGE } from "../hook-message-injector" -import { join } from "node:path" -import { pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller" -import { checkAndInterruptStaleTasks } from "./task-poller" +import { ConcurrencyManager } from "./concurrency" +import { + POLLING_INTERVAL_MS, + type QueueItem, + TASK_CLEANUP_DELAY_MS, + TASK_TTL_MS, +} from "./constants" +import { formatDuration } from "./duration-formatter" +import { + extractErrorMessage, + extractErrorName, + extractErrorStatusCode, + getSessionErrorMessage, + isAbortedSessionError, + isRecord, +} from "./error-classifier" +import { tryFallbackRetry } from "./fallback-retry-handler" +import { + type CircuitBreakerSettings, + detectRepetitiveToolUse, + recordToolCall, + resolveCircuitBreakerSettings, +} from "./loop-detector" +import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier" +import { registerManagerForCleanup, unregisterManagerForCleanup } from "./process-cleanup" import { removeTaskToastTracking } from "./remove-task-toast-tracking" -import { abortWithTimeout } from "./abort-with-timeout" import { MIN_SESSION_GONE_POLLS, verifySessionExists as verifySessionStillExists, } from "./session-existence" +import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler" import { isActiveSessionStatus, isTerminalSessionStatus } from "./session-status-classifier" -import { - detectRepetitiveToolUse, - recordToolCall, - resolveCircuitBreakerSettings, - type CircuitBreakerSettings, -} from "./loop-detector" +import { buildFallbackBody, FALLBACK_AGENT, isAgentNotFoundError } from "./spawner" import { createSubagentDepthLimitError, getMaxSubagentDepth, resolveSubagentSpawnContext, type SubagentSpawnContext, } from "./subagent-spawn-limits" -import { ParentWakeNotifier, type ParentWakePromptContext } from "./parent-wake-notifier" +import { TaskHistory } from "./task-history" +import { checkAndInterruptStaleTasks, pruneStaleTasksAndNotifications, type SessionStatusMap } from "./task-poller" +import { + archiveBackgroundTask, + forgetBackgroundTask, + getRegisteredBackgroundTask, + rememberBackgroundTask, +} from "./task-registry" +import type { + BackgroundTask, + BackgroundTaskAttempt, + LaunchInput, + ResumeInput, +} from "./types" + type OpencodeClient = PluginInput["client"] type ResumeTaskSnapshot = { @@ -111,6 +119,13 @@ type ResumeTaskSnapshot = { concurrencyGroup?: string } +const TERMINAL_BACKGROUND_TASK_STATUSES = new Set([ + "completed", + "error", + "cancelled", + "interrupt", +]) + const PENDING_PARENT_WAKE_RETRY_MS = 1_000 const PENDING_PARENT_WAKE_DEBOUNCE_MS = 100 const PARENT_WAKE_ACCEPTED_MESSAGE_SKEW_MS = 5_000 @@ -376,6 +391,7 @@ export class BackgroundManager { private addTask(task: BackgroundTask): void { this.completedTaskArchive.delete(task.id) this.tasks.set(task.id, task) + rememberBackgroundTask(task) if (!task.parentSessionId) { return } @@ -387,6 +403,7 @@ export class BackgroundManager { private removeTask(task: BackgroundTask): void { this.archiveCompletedTask(task) + archiveBackgroundTask(task) this.tasks.delete(task.id) this.removeTaskFromParentIndex(task.id, task.parentSessionId) } @@ -659,6 +676,7 @@ export class BackgroundManager { // Abort the orphaned session if one was created before the error if (item.task.sessionId) { + clearDelegatedChildSessionBootstrap(item.task.sessionId) await this.abortSessionWithLogging(item.task.sessionId, "startTask error cleanup") } @@ -729,6 +747,7 @@ export class BackgroundManager { const sessionID = createResult.data.id if (task.status === "cancelled") { + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "cancelled pre-start cleanup") this.concurrencyManager.release(concurrencyKey) return @@ -739,6 +758,7 @@ export class BackgroundManager { subagentSessions.add(sessionID) if (this.tasks.get(task.id)?.status === "cancelled") { + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "cancelled during launch setup") subagentSessions.delete(sessionID) if (task.rootSessionId) { @@ -750,6 +770,7 @@ export class BackgroundManager { const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model) if (!boundAttempt) { + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup") subagentSessions.delete(sessionID) if (task.rootSessionId) { @@ -806,6 +827,13 @@ The fallback retry session is now created and can be inspected directly. this.startPolling() log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + registerDelegatedChildSessionBootstrap({ + sessionID, + promptText: input.prompt, + fallbackChain: input.fallbackChain, + category: input.category, + modelFallbackControllerAccessor: this.modelFallbackControllerAccessor, + }) const toastManager = getTaskToastManager() if (toastManager) { @@ -926,6 +954,7 @@ The fallback retry session is now created and can be inspected directly. // Abort the session to prevent infinite polling hang // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) + clearDelegatedChildSessionBootstrap(sessionID) await this.abortSessionWithLogging(sessionID, "launch error cleanup") this.markForNotification(existingTask) @@ -960,7 +989,7 @@ The fallback retry session is now created and can be inspected directly. } getTask(id: string): BackgroundTask | undefined { - return this.tasks.get(id) ?? this.completedTaskArchive.get(id) + return this.tasks.get(id) ?? this.completedTaskArchive.get(id) ?? getRegisteredBackgroundTask(id) } getTasksByParentSession(sessionID: string): BackgroundTask[] { @@ -1313,6 +1342,7 @@ The fallback retry session is now created and can be inspected directly. // Abort the session to prevent infinite polling hang // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) if (existingTask.sessionId) { + clearDelegatedChildSessionBootstrap(existingTask.sessionId) await this.abortSessionWithLogging(existingTask.sessionId, "resume error cleanup") } @@ -1650,6 +1680,7 @@ The fallback retry session is now created and can be inspected directly. } this.rootDescendantCounts.delete(sessionID) + clearDelegatedChildSessionBootstrap(sessionID) SessionCategoryRegistry.remove(sessionID) } @@ -1732,6 +1763,7 @@ The fallback retry session is now created and can be inspected directly. this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) await this.abortSessionWithLogging(task.sessionId, `${reason} cleanup`) } @@ -1838,6 +1870,7 @@ The fallback retry session is now created and can be inspected directly. } this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -1895,6 +1928,7 @@ The task was re-queued on a fallback model after a retryable failure. if (retried && previousSessionID) { this.clearSessionOutputObserved(previousSessionID) this.clearSessionTodoObservation(previousSessionID) + clearDelegatedChildSessionBootstrap(previousSessionID) subagentSessions.delete(previousSessionID) } return retried @@ -2059,6 +2093,7 @@ The task was re-queued on a fallback model after a retryable failure. this.clearTaskHistoryWhenParentTasksGone(task.parentSessionId) if (task.sessionId) { subagentSessions.delete(task.sessionId) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } log("[background-agent] Removed completed task from memory:", taskId) @@ -2134,6 +2169,7 @@ The task was re-queued on a fallback model after a retryable failure. // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) await this.abortSessionWithLogging(task.sessionId, `task cancellation (${source})`) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2259,6 +2295,7 @@ The task was re-queued on a fallback model after a retryable failure. // Awaited to prevent dangling promise during subagent teardown (Bun/WebKit SIGABRT) await this.abortSessionWithLogging(task.sessionId, `task completion (${source})`) + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2588,6 +2625,7 @@ The task was re-queued on a fallback model after a retryable failure. removeTaskToastTracking(task.id) this.scheduleTaskRemoval(task.id) if (task.sessionId) { + clearDelegatedChildSessionBootstrap(task.sessionId) SessionCategoryRegistry.remove(task.sessionId) } @@ -2775,6 +2813,12 @@ The task was re-queued on a fallback model after a retryable failure. // Release concurrency for all running tasks for (const task of this.tasks.values()) { + if (TERMINAL_BACKGROUND_TASK_STATUSES.has(task.status)) { + archiveBackgroundTask(task) + } else { + forgetBackgroundTask(task.id) + } + if (task.concurrencyKey) { this.concurrencyManager.release(task.concurrencyKey) task.concurrencyKey = undefined @@ -2795,6 +2839,7 @@ The task was re-queued on a fallback model after a retryable failure. for (const sessionID of trackedSessionIDs) { subagentSessions.delete(sessionID) + clearDelegatedChildSessionBootstrap(sessionID) SessionCategoryRegistry.remove(sessionID) } diff --git a/src/features/background-agent/task-registry.ts b/src/features/background-agent/task-registry.ts new file mode 100644 index 000000000..ae01889a9 --- /dev/null +++ b/src/features/background-agent/task-registry.ts @@ -0,0 +1,92 @@ +import type { BackgroundTask } from "./types" + +const MAX_COMPLETED_TASK_REGISTRY_SIZE = 100 +const REGISTRY_KEY = "__omoBackgroundTaskRegistry" + +type BackgroundTaskRegistry = { + activeTasks: Map + completedTasks: Map +} + +type GlobalWithBackgroundTaskRegistry = typeof globalThis & { + [REGISTRY_KEY]?: BackgroundTaskRegistry +} + +const TERMINAL_TASK_STATUSES = new Set([ + "completed", + "error", + "cancelled", + "interrupt", +]) + +function getRegistry(): BackgroundTaskRegistry { + const registryGlobal = globalThis as GlobalWithBackgroundTaskRegistry + registryGlobal[REGISTRY_KEY] ??= { + activeTasks: new Map(), + completedTasks: new Map(), + } + return registryGlobal[REGISTRY_KEY] +} + +function cloneCompletedTask(task: BackgroundTask): BackgroundTask { + return { + id: task.id, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + description: task.description, + prompt: "[redacted]", + agent: task.agent, + sessionId: task.sessionId, + status: task.status, + queuedAt: task.queuedAt, + startedAt: task.startedAt, + completedAt: task.completedAt, + model: task.model, + error: task.error, + category: task.category, + } +} + +function trimCompletedTasks(registry: BackgroundTaskRegistry): void { + while (registry.completedTasks.size > MAX_COMPLETED_TASK_REGISTRY_SIZE) { + const oldestTaskID = registry.completedTasks.keys().next().value + if (typeof oldestTaskID !== "string") { + return + } + registry.completedTasks.delete(oldestTaskID) + } +} + +export function rememberBackgroundTask(task: BackgroundTask): void { + const registry = getRegistry() + registry.completedTasks.delete(task.id) + registry.activeTasks.set(task.id, task) +} + +export function archiveBackgroundTask(task: BackgroundTask): void { + const registry = getRegistry() + registry.activeTasks.delete(task.id) + registry.completedTasks.delete(task.id) + if (!task.sessionId || !TERMINAL_TASK_STATUSES.has(task.status)) { + return + } + registry.completedTasks.set(task.id, cloneCompletedTask(task)) + trimCompletedTasks(registry) +} + +export function getRegisteredBackgroundTask(taskID: string): BackgroundTask | undefined { + const registry = getRegistry() + return registry.activeTasks.get(taskID) ?? registry.completedTasks.get(taskID) +} + +export function forgetBackgroundTask(taskID: string): void { + const registry = getRegistry() + registry.activeTasks.delete(taskID) + registry.completedTasks.delete(taskID) +} + +export function clearBackgroundTaskRegistryForTesting(): void { + const registry = getRegistry() + registry.activeTasks.clear() + registry.completedTasks.clear() +} diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index 73b2105d6..f69754622 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -6,6 +6,7 @@ import { getSessionAgent } from "../../features/claude-code-session-state" import { getFallbackModelsForSession } from "./fallback-models" import { prepareFallback } from "./fallback-state" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap" import { buildRetryModelPayload } from "./retry-model-payload" import { getLastUserRetryParts } from "./last-user-retry-parts" import { extractSessionMessages } from "./session-messages" @@ -143,7 +144,7 @@ export function createAutoRetryHelpers(deps: HookDeps) { path: { id: sessionID }, query: { directory: ctx.directory }, }) - const retryParts = getLastUserRetryParts(messagesResp) + const retryParts = getLastUserRetryParts(messagesResp, sessionID) if (retryParts.length > 0) { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { sessionID, @@ -239,6 +240,7 @@ export function createAutoRetryHelpers(deps: HookDeps) { sessionRetryInFlight.delete(sessionID) sessionAwaitingFallbackResult.delete(sessionID) clearSessionFallbackTimeout(sessionID) + clearDelegatedChildSessionBootstrap(sessionID) SessionCategoryRegistry.remove(sessionID) sessionStatusRetryKeys.delete(sessionID) cleanedCount++ diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index dbb4dc227..2d3196cdf 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -1,8 +1,13 @@ -import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test" -import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config" +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" +import { unsafeTestValue } from "../../../test-support/unsafe-test-value" +import type { OhMyOpenCodeConfig, RuntimeFallbackConfig } from "../../config" +import { + clearAllDelegatedChildSessionBootstrap, + getDelegatedChildSessionBootstrap, + registerDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" import * as loggerModule from "../../shared/logger" import { SessionCategoryRegistry } from "../../shared/session-category-registry" -import { unsafeTestValue } from "../../../test-support/unsafe-test-value" type RuntimeFallbackModule = typeof import("./hook") @@ -16,6 +21,7 @@ describe("runtime-fallback", () => { logCalls = [] toastCalls = [] SessionCategoryRegistry.clear() + clearAllDelegatedChildSessionBootstrap() const cacheBuster = `${Date.now()}-${Math.random()}` @@ -32,6 +38,7 @@ describe("runtime-fallback", () => { afterEach(() => { SessionCategoryRegistry.clear() + clearAllDelegatedChildSessionBootstrap() mock.restore() }) @@ -489,6 +496,108 @@ describe("runtime-fallback", () => { }) }) + test("should retry delegated child session from bootstrap when history has no user prompt", async () => { + const promptCalls: Array> = [] + const hook = createRuntimeFallbackHook( + createMockPluginInput({ + session: { + messages: async () => ({ data: [] }), + promptAsync: async (args) => { + promptCalls.push(args as Record) + return {} + }, + }, + }), + { + config: createMockConfig({ notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryModel( + "quick", + "anthropic/claude-haiku-4-5", + ["openai/gpt-5.4(high)"], + ), + }, + ) + const sessionID = "test-delegated-empty-history-bootstrap" + registerDelegatedChildSessionBootstrap({ + sessionID, + promptText: "inspect src/tools/delegate-task and report the issue", + category: "quick", + }) + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID, + error: { statusCode: 429, message: "Rate limit exceeded before history persisted" }, + }, + }, + }) + + expect(promptCalls).toHaveLength(1) + const promptBody = promptCalls[0]?.body as { + model?: { providerID?: string; modelID?: string } + parts?: Array<{ type?: string; text?: string }> + variant?: string + } | undefined + expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) + expect(promptBody?.variant).toBe("high") + expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task") + }) + + test("should discard delegated bootstrap once persisted user prompt exists", async () => { + const promptCalls: Array> = [] + const sessionID = "test-delegated-history-prefers-persisted-user" + const hook = createRuntimeFallbackHook( + createMockPluginInput({ + session: { + messages: async () => ({ + data: [ + { + info: { role: "user" }, + parts: [{ type: "text", text: "persisted child task prompt" }], + }, + ], + }), + promptAsync: async (args) => { + promptCalls.push(args as Record) + return {} + }, + }, + }), + { + config: createMockConfig({ notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryModel( + "test", + "anthropic/claude-haiku-4-5", + ["openai/gpt-5.4"], + ), + }, + ) + registerDelegatedChildSessionBootstrap({ + sessionID, + promptText: "bootstrap copy should not be reused", + }) + SessionCategoryRegistry.register(sessionID, "test") + + await hook.event({ + event: { + type: "session.error", + properties: { + sessionID, + error: { statusCode: 429, message: "Rate limit after prompt persisted" }, + }, + }, + }) + + expect(promptCalls).toHaveLength(1) + const promptBody = promptCalls[0]?.body as { + parts?: Array<{ type?: string; text?: string }> + } | undefined + expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt") + expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined() + }) + test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => { const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig({ notify_on_fallback: false }), diff --git a/src/hooks/runtime-fallback/last-user-retry-parts.ts b/src/hooks/runtime-fallback/last-user-retry-parts.ts index 899572a73..7705188a6 100644 --- a/src/hooks/runtime-fallback/last-user-retry-parts.ts +++ b/src/hooks/runtime-fallback/last-user-retry-parts.ts @@ -1,7 +1,12 @@ import { extractSessionMessages } from "./session-messages" +import { + clearDelegatedChildSessionBootstrap, + getDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" export function getLastUserRetryParts( messagesResponse: unknown, + sessionID?: string, ): Array<{ type: "text"; text: string }> { const messages = extractSessionMessages(messagesResponse) const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop() @@ -9,7 +14,7 @@ export function getLastUserRetryParts( lastUserMessage?.parts ?? (lastUserMessage?.info?.parts as Array<{ type?: string; text?: string }> | undefined) - return (lastUserParts ?? []) + const retryParts = (lastUserParts ?? []) .filter( (part): part is { type: "text"; text: string } => part.type === "text" @@ -17,4 +22,17 @@ export function getLastUserRetryParts( && part.text.length > 0, ) .map((part) => ({ type: "text" as const, text: part.text })) + + if (retryParts.length > 0) { + if (sessionID) { + clearDelegatedChildSessionBootstrap(sessionID) + } + return retryParts + } + + if (!sessionID) { + return retryParts + } + + return getDelegatedChildSessionBootstrap(sessionID)?.retryParts ?? [] } diff --git a/src/hooks/shared/prompt-async-gate.test.ts b/src/hooks/shared/prompt-async-gate.test.ts index b21abd163..450b70de4 100644 --- a/src/hooks/shared/prompt-async-gate.test.ts +++ b/src/hooks/shared/prompt-async-gate.test.ts @@ -91,6 +91,36 @@ describe("promptAsyncAfterSessionIdle", () => { expect(promptCalls).toBe(1) }) + test("#given SDK promptAsync depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => { + // given + const session = { + _client: { accepted: true }, + async promptAsync( + this: { _client: { accepted: boolean } }, + input: { path: { id: string }, body: { parts: unknown[] } }, + ) { + return { accepted: this._client.accepted, sessionID: input.path.id } + }, + } + const client = { session } + + // when + const result = await promptAsyncAfterSessionIdle({ + client, + sessionID: "ses_bound_prompt_async", + input: { path: { id: "ses_bound_prompt_async" }, body: { parts: [] } }, + source: "test:bound-prompt-async", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result).toEqual({ + status: "dispatched", + response: { accepted: true, sessionID: "ses_bound_prompt_async" }, + }) + }) + test("#given session.status reports busy #when an internal promptAsync is requested #then no prompt is sent", async () => { // given let promptCalls = 0 @@ -445,4 +475,34 @@ describe("promptAsyncAfterSessionIdle", () => { expect(second.status).toBe("reserved") expect(promptCalls).toBe(1) }) + + test("#given SDK prompt depends on its session receiver #when the gate dispatches #then method binding is preserved", async () => { + // given + const session = { + _client: { accepted: true }, + async prompt( + this: { _client: { accepted: boolean } }, + input: { path: { id: string }, body: { parts: unknown[] } }, + ) { + return { accepted: this._client.accepted, sessionID: input.path.id } + }, + } + const client = { session } + + // when + const result = await promptAfterSessionIdle({ + client, + sessionID: "ses_bound_prompt", + input: { path: { id: "ses_bound_prompt" }, body: { parts: [] } }, + source: "test:bound-prompt", + settleMs: 0, + postDispatchHoldMs: 0, + }) + + // then + expect(result).toEqual({ + status: "dispatched", + response: { accepted: true, sessionID: "ses_bound_prompt" }, + }) + }) }) diff --git a/src/shared/delegated-child-session-bootstrap.ts b/src/shared/delegated-child-session-bootstrap.ts new file mode 100644 index 000000000..c99322d70 --- /dev/null +++ b/src/shared/delegated-child-session-bootstrap.ts @@ -0,0 +1,71 @@ +import type { ModelFallbackControllerAccessor } from "../hooks/model-fallback" +import { createInternalAgentTextPart } from "./internal-initiator-marker" +import type { FallbackEntry } from "./model-requirements" +import { SessionCategoryRegistry } from "./session-category-registry" + +export type DelegatedChildSessionRetryPart = { + type: "text" + text: string +} + +export type DelegatedChildSessionBootstrap = { + retryParts: DelegatedChildSessionRetryPart[] + fallbackChain?: FallbackEntry[] + category?: string +} + +const delegatedChildSessionBootstraps = new Map() + +function cloneRetryParts(parts: DelegatedChildSessionRetryPart[]): DelegatedChildSessionRetryPart[] { + return parts.map((part) => ({ type: part.type, text: part.text })) +} + +function cloneFallbackChain(fallbackChain: FallbackEntry[] | undefined): FallbackEntry[] | undefined { + return fallbackChain?.map((entry) => ({ + ...entry, + providers: [...entry.providers], + })) +} + +export function registerDelegatedChildSessionBootstrap(_args: { + sessionID: string + promptText: string + fallbackChain?: FallbackEntry[] + category?: string + modelFallbackControllerAccessor?: ModelFallbackControllerAccessor +}): void { + const retryParts = [createInternalAgentTextPart(_args.promptText)] + const fallbackChain = cloneFallbackChain(_args.fallbackChain) + delegatedChildSessionBootstraps.set(_args.sessionID, { + retryParts, + ...(fallbackChain ? { fallbackChain } : {}), + ...(_args.category ? { category: _args.category } : {}), + }) + + _args.modelFallbackControllerAccessor?.setSessionFallbackChain(_args.sessionID, fallbackChain) + if (_args.category) { + SessionCategoryRegistry.register(_args.sessionID, _args.category) + } +} + +export function getDelegatedChildSessionBootstrap(_sessionID: string): DelegatedChildSessionBootstrap | undefined { + const bootstrap = delegatedChildSessionBootstraps.get(_sessionID) + if (!bootstrap) { + return undefined + } + + const fallbackChain = cloneFallbackChain(bootstrap.fallbackChain) + return { + retryParts: cloneRetryParts(bootstrap.retryParts), + ...(fallbackChain ? { fallbackChain } : {}), + ...(bootstrap.category ? { category: bootstrap.category } : {}), + } +} + +export function clearDelegatedChildSessionBootstrap(_sessionID: string): void { + delegatedChildSessionBootstraps.delete(_sessionID) +} + +export function clearAllDelegatedChildSessionBootstrap(): void { + delegatedChildSessionBootstraps.clear() +} diff --git a/src/shared/prompt-async-gate.ts b/src/shared/prompt-async-gate.ts index 6a967c5e9..3822ccf11 100644 --- a/src/shared/prompt-async-gate.ts +++ b/src/shared/prompt-async-gate.ts @@ -217,12 +217,13 @@ export async function promptAsyncAfterSessionIdle(arg } = args const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS - const promptAsync = client.session?.promptAsync + const session = client.session - if (typeof promptAsync !== "function") { + if (typeof session?.promptAsync !== "function") { log("[prompt-async-gate] promptAsync unavailable", { sessionID, source }) return { status: "unavailable" } } + const dispatchPromptAsync = session.promptAsync.bind(session) return dispatchAfterSessionIdle({ sessionName: "promptAsync", @@ -234,7 +235,7 @@ export async function promptAsyncAfterSessionIdle(arg postDispatchHoldMs, dispatchTimeoutMs, checkStatus: args.checkStatus !== false, - dispatch: (dispatchInput) => promptAsync(dispatchInput), + dispatch: (dispatchInput) => dispatchPromptAsync(dispatchInput), }) } @@ -257,12 +258,13 @@ export async function promptAfterSessionIdle(args: { } = args const postDispatchHoldMs = args.postDispatchHoldMs ?? DEFAULT_PROMPT_ASYNC_POST_DISPATCH_HOLD_MS const dispatchTimeoutMs = args.dispatchTimeoutMs ?? DEFAULT_PROMPT_DISPATCH_TIMEOUT_MS - const prompt = client.session?.prompt + const session = client.session - if (typeof prompt !== "function") { + if (typeof session?.prompt !== "function") { log("[prompt-async-gate] prompt unavailable", { sessionID, source }) return { status: "unavailable" } } + const dispatchPrompt = session.prompt.bind(session) return dispatchAfterSessionIdle({ sessionName: "prompt", @@ -274,7 +276,7 @@ export async function promptAfterSessionIdle(args: { postDispatchHoldMs, dispatchTimeoutMs, checkStatus: args.checkStatus !== false, - dispatch: (dispatchInput) => prompt(dispatchInput), + dispatch: (dispatchInput) => dispatchPrompt(dispatchInput), }) } diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index e1d792cfe..63f036611 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -27,6 +27,8 @@ describe("executeSyncTask - cleanup on error paths", () => { addTaskCalls = [] deleteCalls = [] addCalls = [] + const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") + clearAllDelegatedChildSessionBootstrap() clearRequireCache("./sync-task") @@ -62,6 +64,8 @@ describe("executeSyncTask - cleanup on error paths", () => { mock.restore() resetToastManager?.() resetToastManager = null + const { clearAllDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") + clearAllDelegatedChildSessionBootstrap() }) test("cleans up toast and subagentSessions when fetchSyncResult returns ok: false", async () => { @@ -664,6 +668,62 @@ describe("executeSyncTask - cleanup on error paths", () => { }) }) + test("registers child-session bootstrap before sync prompt and clears it after completion", async () => { + const mockClient = { + session: { + create: async () => ({ data: { id: "ignored" } }), + }, + } + + const { executeSyncTask } = require("./sync-task") + const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") + const observedBootstrapPrompts: string[] = [] + + const deps = { + createSyncSession: async () => ({ ok: true as const, sessionID: "ses_bootstrap_sync" }), + sendSyncPrompt: async (_client: unknown, input: { sessionID: string }) => { + const bootstrap = getDelegatedChildSessionBootstrap(input.sessionID) + observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "") + return null + }, + pollSyncSession: async () => null, + fetchSyncResult: async () => ({ ok: true as const, textContent: "sync result" }), + } + + const mockCtx = { + sessionID: "parent-session", + callID: "call-123", + metadata: () => {}, + } + + const mockExecutorCtx = { + client: mockClient, + directory: "/tmp", + onSyncSessionCreated: null, + modelFallbackControllerAccessor: { + setSessionFallbackChain: () => {}, + clearSessionFallbackChain: () => {}, + }, + } + + const args = { + prompt: "sync bootstrap prompt", + description: "sync bootstrap task", + category: "quick", + load_skills: [], + run_in_background: false, + command: null, + } + + const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { + sessionID: "parent-session", + }, "sisyphus-junior", undefined, undefined, undefined, undefined, deps) + + expect(result).toContain("sync result") + expect(observedBootstrapPrompts[0]).toContain("sync bootstrap prompt") + expect(getDelegatedChildSessionBootstrap("ses_bootstrap_sync")).toBeUndefined() + }) + test("replays sync session side effects for retry-created sessions", async () => { const mockClient = { session: { diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index 44a6f65ef..e597634a0 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -1,19 +1,24 @@ -import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" -import type { DelegateTaskArgs, ToolContextWithMetadata, DelegatedModelConfig } from "./types" -import type { ExecutorContext, ParentContext } from "./executor-types" +import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state" import { getTaskToastManager } from "../../features/task-toast-manager" +import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import { publishToolMetadata } from "../../features/tool-metadata-store" -import { subagentSessions, syncSubagentSessions, setSessionAgent } from "../../features/claude-code-session-state" -import { log } from "../../shared/logger" -import { SessionCategoryRegistry } from "../../shared/session-category-registry" -import { formatDuration } from "./time-formatter" -import { formatDetailedError } from "./error-formatting" -import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps" -import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback" import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract" -import { resolveMetadataModel } from "./resolve-metadata-model" -import { shouldRetryError } from "../../shared/model-error-classifier" import type { ModelFallbackState } from "../../hooks/model-fallback/hook" +import { + clearDelegatedChildSessionBootstrap, + registerDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" +import { log } from "../../shared/logger" +import { shouldRetryError } from "../../shared/model-error-classifier" +import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import { formatDetailedError } from "./error-formatting" +import type { ExecutorContext, ParentContext } from "./executor-types" +import { buildTaskPrompt } from "./prompt-builder" +import { resolveMetadataModel } from "./resolve-metadata-model" +import { type SyncTaskDeps, syncTaskDeps } from "./sync-task-deps" +import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback" +import { formatDuration } from "./time-formatter" +import type { DelegatedModelConfig, DelegateTaskArgs, ToolContextWithMetadata } from "./types" function shouldAttemptPollErrorRecovery(pollError: string): boolean { const trimmed = pollError.trim() @@ -107,11 +112,13 @@ export async function executeSyncTask( subagentSessions.add(newSessionID) syncSubagentSessions.add(newSessionID) setSessionAgent(newSessionID, agentToUse) - executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(newSessionID, fallbackChain) - - if (args.category) { - SessionCategoryRegistry.register(newSessionID, args.category) - } + registerDelegatedChildSessionBootstrap({ + sessionID: newSessionID, + promptText: buildTaskPrompt(args.prompt, agentToUse, executorCtx.sisyphusAgentConfig?.tdd), + fallbackChain, + category: args.category, + modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor, + }) if (onSyncSessionCreated) { log("[task] Invoking onSyncSessionCreated callback", { sessionID: newSessionID, parentID: parentContext.sessionID }) @@ -199,6 +206,7 @@ export async function executeSyncTask( const cleanupRetrySession = (currentSessionID: string): void => { subagentSessions.delete(currentSessionID) syncSubagentSessions.delete(currentSessionID) + clearDelegatedChildSessionBootstrap(currentSessionID) executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(currentSessionID) SessionCategoryRegistry.remove(currentSessionID) } @@ -364,6 +372,7 @@ ${buildTaskMetadataBlock({ if (syncSessionID) { subagentSessions.delete(syncSessionID) syncSubagentSessions.delete(syncSessionID) + clearDelegatedChildSessionBootstrap(syncSessionID) executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID) SessionCategoryRegistry.remove(syncSessionID) } From 7c2e2fe1fac9a6905e7934fcf067e7c667b3b627 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 16 May 2026 17:12:48 +0900 Subject: [PATCH 2/9] fix(background-agent): redact task registry views --- src/features/background-agent/manager.test.ts | 38 ++++++++++++ .../background-agent/task-registry.ts | 59 ++++++++++++++++--- 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 030b3670d..d1fca8183 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -6822,6 +6822,44 @@ describe("BackgroundManager regression fixes - resume and aborted notification", secondManager.shutdown() }) + test("should redact active task prompts resolved from an earlier plugin manager instance", () => { + //#given + const firstManager = createBackgroundManager() + const secondManager = createBackgroundManager() + const task: BackgroundTask = { + id: "task-cross-manager-active-redaction", + parentSessionId: "parent-session", + parentMessageId: "msg-1", + description: "cross manager active redaction", + prompt: "secret prompt", + agent: "explore", + status: "pending", + queuedAt: new Date(), + } + + //#when + ;(cast<{ addTask: (task: BackgroundTask) => void }>(firstManager)).addTask(task) + task.sessionId = "session-cross-manager-active-redaction" + task.status = "running" + task.startedAt = new Date() + task.progress = { + lastUpdate: new Date(), + toolCalls: 1, + countedToolPartIDs: new Set(["part-1"]), + } + + //#then + const localTask = firstManager.getTask(task.id) + const registeredTask = secondManager.getTask(task.id) + expect(localTask?.prompt).toBe("secret prompt") + expect(registeredTask?.sessionId).toBe(task.sessionId) + expect(registeredTask?.prompt).toBe("[redacted]") + expect(registeredTask?.progress?.countedToolPartIDs).toEqual(new Set(["part-1"])) + + firstManager.shutdown() + secondManager.shutdown() + }) + test("should resolve archived completed task from an earlier plugin manager instance", () => { //#given const firstManager = createBackgroundManager() diff --git a/src/features/background-agent/task-registry.ts b/src/features/background-agent/task-registry.ts index ae01889a9..8a027e667 100644 --- a/src/features/background-agent/task-registry.ts +++ b/src/features/background-agent/task-registry.ts @@ -4,7 +4,7 @@ const MAX_COMPLETED_TASK_REGISTRY_SIZE = 100 const REGISTRY_KEY = "__omoBackgroundTaskRegistry" type BackgroundTaskRegistry = { - activeTasks: Map + activeTasks: Map BackgroundTask> completedTasks: Map } @@ -22,28 +22,67 @@ const TERMINAL_TASK_STATUSES = new Set([ function getRegistry(): BackgroundTaskRegistry { const registryGlobal = globalThis as GlobalWithBackgroundTaskRegistry registryGlobal[REGISTRY_KEY] ??= { - activeTasks: new Map(), + activeTasks: new Map BackgroundTask>(), completedTasks: new Map(), } - return registryGlobal[REGISTRY_KEY] + const registry = registryGlobal[REGISTRY_KEY] + return registry } -function cloneCompletedTask(task: BackgroundTask): BackgroundTask { +function cloneProgress(progress: BackgroundTask["progress"]): BackgroundTask["progress"] { + if (!progress) { + return undefined + } + + return { + ...progress, + countedToolPartIDs: progress.countedToolPartIDs ? new Set(progress.countedToolPartIDs) : undefined, + } +} + +function cloneAttempts(attempts: BackgroundTask["attempts"]): BackgroundTask["attempts"] { + if (!attempts) { + return undefined + } + + return attempts.map((attempt) => ({ ...attempt })) +} + +function cloneRegisteredTask(task: BackgroundTask): BackgroundTask { return { id: task.id, + rootSessionId: task.rootSessionId, parentSessionId: task.parentSessionId, parentMessageId: task.parentMessageId, + teamRunId: task.teamRunId, description: task.description, prompt: "[redacted]", agent: task.agent, + spawnDepth: task.spawnDepth, sessionId: task.sessionId, status: task.status, queuedAt: task.queuedAt, startedAt: task.startedAt, completedAt: task.completedAt, + result: task.result, + progress: cloneProgress(task.progress), + parentModel: task.parentModel, model: task.model, + fallbackChain: task.fallbackChain, + attemptCount: task.attemptCount, + concurrencyKey: task.concurrencyKey, + concurrencyGroup: task.concurrencyGroup, + parentAgent: task.parentAgent, + parentTools: task.parentTools, + isUnstableAgent: task.isUnstableAgent, error: task.error, category: task.category, + retryNotification: task.retryNotification ? { ...task.retryNotification } : undefined, + attempts: cloneAttempts(task.attempts), + currentAttemptID: task.currentAttemptID, + lastMsgCount: task.lastMsgCount, + stablePolls: task.stablePolls, + consecutiveMissedPolls: task.consecutiveMissedPolls, } } @@ -60,7 +99,7 @@ function trimCompletedTasks(registry: BackgroundTaskRegistry): void { export function rememberBackgroundTask(task: BackgroundTask): void { const registry = getRegistry() registry.completedTasks.delete(task.id) - registry.activeTasks.set(task.id, task) + registry.activeTasks.set(task.id, () => cloneRegisteredTask(task)) } export function archiveBackgroundTask(task: BackgroundTask): void { @@ -70,13 +109,19 @@ export function archiveBackgroundTask(task: BackgroundTask): void { if (!task.sessionId || !TERMINAL_TASK_STATUSES.has(task.status)) { return } - registry.completedTasks.set(task.id, cloneCompletedTask(task)) + registry.completedTasks.set(task.id, cloneRegisteredTask(task)) trimCompletedTasks(registry) } export function getRegisteredBackgroundTask(taskID: string): BackgroundTask | undefined { const registry = getRegistry() - return registry.activeTasks.get(taskID) ?? registry.completedTasks.get(taskID) + const activeTask = registry.activeTasks.get(taskID) + if (activeTask) { + return activeTask() + } + + const completedTask = registry.completedTasks.get(taskID) + return completedTask ? cloneRegisteredTask(completedTask) : undefined } export function forgetBackgroundTask(taskID: string): void { From 761f682add97db9da478336d3eb1708c99a1e887 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:08:19 +0900 Subject: [PATCH 3/9] refactor(delegated-bootstrap): accept optional system and tools Add optional system and tools fields to DelegatedChildSessionBootstrap so callers can stash the original delegated context alongside retry parts. Backward compatible - existing callers stay unchanged. --- src/shared/delegated-child-session-bootstrap.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/shared/delegated-child-session-bootstrap.ts b/src/shared/delegated-child-session-bootstrap.ts index c99322d70..a9d360829 100644 --- a/src/shared/delegated-child-session-bootstrap.ts +++ b/src/shared/delegated-child-session-bootstrap.ts @@ -12,6 +12,8 @@ export type DelegatedChildSessionBootstrap = { retryParts: DelegatedChildSessionRetryPart[] fallbackChain?: FallbackEntry[] category?: string + system?: string + tools?: Record } const delegatedChildSessionBootstraps = new Map() @@ -27,19 +29,28 @@ function cloneFallbackChain(fallbackChain: FallbackEntry[] | undefined): Fallbac })) } +function cloneTools(tools: Record | undefined): Record | undefined { + return tools ? { ...tools } : undefined +} + export function registerDelegatedChildSessionBootstrap(_args: { sessionID: string promptText: string fallbackChain?: FallbackEntry[] category?: string + system?: string + tools?: Record modelFallbackControllerAccessor?: ModelFallbackControllerAccessor }): void { const retryParts = [createInternalAgentTextPart(_args.promptText)] const fallbackChain = cloneFallbackChain(_args.fallbackChain) + const tools = cloneTools(_args.tools) delegatedChildSessionBootstraps.set(_args.sessionID, { retryParts, ...(fallbackChain ? { fallbackChain } : {}), ...(_args.category ? { category: _args.category } : {}), + ...(_args.system ? { system: _args.system } : {}), + ...(tools ? { tools } : {}), }) _args.modelFallbackControllerAccessor?.setSessionFallbackChain(_args.sessionID, fallbackChain) @@ -55,10 +66,13 @@ export function getDelegatedChildSessionBootstrap(_sessionID: string): Delegated } const fallbackChain = cloneFallbackChain(bootstrap.fallbackChain) + const tools = cloneTools(bootstrap.tools) return { retryParts: cloneRetryParts(bootstrap.retryParts), ...(fallbackChain ? { fallbackChain } : {}), ...(bootstrap.category ? { category: bootstrap.category } : {}), + ...(bootstrap.system ? { system: bootstrap.system } : {}), + ...(tools ? { tools } : {}), } } From ba648685d4340c44ff4bf6a0fb3403a243359ef5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:08:30 +0900 Subject: [PATCH 4/9] fix(runtime-fallback): carry delegated system and tools through bootstrap retry When the first prompt fails before any durable user message persists, runtime fallback retry was rebuilding the request from parts alone and losing the delegated agent system prompt and tool gates. Now it threads bootstrap.system and bootstrap.tools into the retry body alongside the captured retry parts, so the retried prompt keeps the same scope as the initial delegate launch. --- src/hooks/runtime-fallback/auto-retry.ts | 7 ++-- src/hooks/runtime-fallback/index.test.ts | 21 ++++++++++-- .../runtime-fallback/last-user-retry-parts.ts | 32 ++++++++++++++++--- src/hooks/runtime-fallback/types.ts | 2 ++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/src/hooks/runtime-fallback/auto-retry.ts b/src/hooks/runtime-fallback/auto-retry.ts index f69754622..74d7932b5 100644 --- a/src/hooks/runtime-fallback/auto-retry.ts +++ b/src/hooks/runtime-fallback/auto-retry.ts @@ -8,7 +8,7 @@ import { prepareFallback } from "./fallback-state" import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { clearDelegatedChildSessionBootstrap } from "../../shared/delegated-child-session-bootstrap" import { buildRetryModelPayload } from "./retry-model-payload" -import { getLastUserRetryParts } from "./last-user-retry-parts" +import { getLastUserRetryPayload } from "./last-user-retry-parts" import { extractSessionMessages } from "./session-messages" import { resolveRegisteredAgentName } from "../../features/claude-code-session-state" import { @@ -144,7 +144,8 @@ export function createAutoRetryHelpers(deps: HookDeps) { path: { id: sessionID }, query: { directory: ctx.directory }, }) - const retryParts = getLastUserRetryParts(messagesResp, sessionID) + const retryPayload = getLastUserRetryPayload(messagesResp, sessionID) + const retryParts = retryPayload.retryParts if (retryParts.length > 0) { log(`[${HOOK_NAME}] Auto-retrying with fallback model (${source})`, { sessionID, @@ -166,6 +167,8 @@ export function createAutoRetryHelpers(deps: HookDeps) { body: { ...(launchAgent ? { agent: launchAgent } : {}), ...retryModelPayload, + ...(retryPayload.system ? { system: retryPayload.system } : {}), + ...(retryPayload.tools ? { tools: retryPayload.tools } : {}), parts: retryParts, }, query: { directory: ctx.directory }, diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 2d3196cdf..2f1949781 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -8,6 +8,7 @@ import { } from "../../shared/delegated-child-session-bootstrap" import * as loggerModule from "../../shared/logger" import { SessionCategoryRegistry } from "../../shared/session-category-registry" +import type { RuntimeFallbackPluginInput } from "./types" type RuntimeFallbackModule = typeof import("./hook") @@ -49,8 +50,8 @@ describe("runtime-fallback", () => { abort?: (args: unknown) => Promise status?: () => Promise } - }) { - return unsafeTestValue({ + }): RuntimeFallbackPluginInput { + return unsafeTestValue({ client: { tui: { showToast: async (opts: { body: { title: string; message: string; variant: string; duration: number } }) => { @@ -522,6 +523,8 @@ describe("runtime-fallback", () => { sessionID, promptText: "inspect src/tools/delegate-task and report the issue", category: "quick", + system: "delegated child system prompt", + tools: { call_omo_agent: true, question: false, task: false }, }) await hook.event({ @@ -538,14 +541,19 @@ describe("runtime-fallback", () => { const promptBody = promptCalls[0]?.body as { model?: { providerID?: string; modelID?: string } parts?: Array<{ type?: string; text?: string }> + system?: string + tools?: Record variant?: string } | undefined expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" }) expect(promptBody?.variant).toBe("high") + expect(promptBody?.system).toBe("delegated child system prompt") + expect(promptBody?.tools?.question).toBe(false) + expect(promptBody?.tools?.call_omo_agent).toBe(true) expect(promptBody?.parts?.[0]?.text).toContain("inspect src/tools/delegate-task") }) - test("should discard delegated bootstrap once persisted user prompt exists", async () => { + test("should use persisted user prompt while preserving delegated bootstrap launch context", async () => { const promptCalls: Array> = [] const sessionID = "test-delegated-history-prefers-persisted-user" const hook = createRuntimeFallbackHook( @@ -577,6 +585,8 @@ describe("runtime-fallback", () => { registerDelegatedChildSessionBootstrap({ sessionID, promptText: "bootstrap copy should not be reused", + system: "persisted delegated child system prompt", + tools: { call_omo_agent: true, question: false, task: false }, }) SessionCategoryRegistry.register(sessionID, "test") @@ -593,8 +603,13 @@ describe("runtime-fallback", () => { expect(promptCalls).toHaveLength(1) const promptBody = promptCalls[0]?.body as { parts?: Array<{ type?: string; text?: string }> + system?: string + tools?: Record } | undefined expect(promptBody?.parts?.[0]?.text).toBe("persisted child task prompt") + expect(promptBody?.system).toBe("persisted delegated child system prompt") + expect(promptBody?.tools?.question).toBe(false) + expect(promptBody?.tools?.call_omo_agent).toBe(true) expect(getDelegatedChildSessionBootstrap(sessionID)).toBeUndefined() }) diff --git a/src/hooks/runtime-fallback/last-user-retry-parts.ts b/src/hooks/runtime-fallback/last-user-retry-parts.ts index 7705188a6..38a73eeec 100644 --- a/src/hooks/runtime-fallback/last-user-retry-parts.ts +++ b/src/hooks/runtime-fallback/last-user-retry-parts.ts @@ -4,10 +4,26 @@ import { getDelegatedChildSessionBootstrap, } from "../../shared/delegated-child-session-bootstrap" +type RetryPart = { type: "text"; text: string } + +export type LastUserRetryPayload = { + retryParts: RetryPart[] + system?: string + tools?: Record +} + export function getLastUserRetryParts( messagesResponse: unknown, sessionID?: string, -): Array<{ type: "text"; text: string }> { +): RetryPart[] { + return getLastUserRetryPayload(messagesResponse, sessionID).retryParts +} + +export function getLastUserRetryPayload( + messagesResponse: unknown, + sessionID?: string, +): LastUserRetryPayload { + const bootstrap = sessionID ? getDelegatedChildSessionBootstrap(sessionID) : undefined const messages = extractSessionMessages(messagesResponse) const lastUserMessage = messages?.filter((message) => message.info?.role === "user").pop() const lastUserParts = @@ -27,12 +43,20 @@ export function getLastUserRetryParts( if (sessionID) { clearDelegatedChildSessionBootstrap(sessionID) } - return retryParts + return { + retryParts, + ...(bootstrap?.system ? { system: bootstrap.system } : {}), + ...(bootstrap?.tools ? { tools: bootstrap.tools } : {}), + } } if (!sessionID) { - return retryParts + return { retryParts } } - return getDelegatedChildSessionBootstrap(sessionID)?.retryParts ?? [] + return { + retryParts: bootstrap?.retryParts ?? [], + ...(bootstrap?.system ? { system: bootstrap.system } : {}), + ...(bootstrap?.tools ? { tools: bootstrap.tools } : {}), + } } diff --git a/src/hooks/runtime-fallback/types.ts b/src/hooks/runtime-fallback/types.ts index c05bfe952..aaa6436e1 100644 --- a/src/hooks/runtime-fallback/types.ts +++ b/src/hooks/runtime-fallback/types.ts @@ -16,6 +16,8 @@ export interface RuntimeFallbackPluginInput { body: { agent?: string model: { providerID: string; modelID: string } + system?: string + tools?: Record parts: Array<{ type: "text"; text: string }> } query: { directory: string } From 097d7dc547c3dd2df4ff894b15e54e5ebc58e0b6 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:08:44 +0900 Subject: [PATCH 5/9] fix(background-agent): keep delegated skill, permission, and child agent across retries Three coupled gaps surfaced after the initial spawn fix: 1. fallback-retry-handler dropped task.skillContent and task.sessionPermission when rebuilding LaunchInput, so the retried background task lost the delegated system prompt and question-deny permission rule. 2. manager.startTask never bound the child sessionID to the resolved agent via setSessionAgent, leaving runtime fallback and other hooks with no idea which agent owned the new child session. 3. The fallback-to-general path in spawner.ts rebuilt the prompt body without going through buildFallbackBody, so bootstrap state, session tools, and session agent updates drifted apart. Persist skillContent and sessionPermission on BackgroundTask, bind setSessionAgent/updateSessionAgent at session creation and on fallback, and route the FALLBACK_AGENT retry through buildFallbackBody so the prompt body, bootstrap tools, and session registries all agree. --- .../fallback-retry-handler.test.ts | 24 ++- .../fallback-retry-handler.ts | 2 + src/features/background-agent/manager.test.ts | 155 ++++++++++++++---- src/features/background-agent/manager.ts | 73 +++++---- src/features/background-agent/spawner.test.ts | 6 +- src/features/background-agent/spawner.ts | 42 +++-- src/features/background-agent/types.ts | 2 + 7 files changed, 230 insertions(+), 74 deletions(-) diff --git a/src/features/background-agent/fallback-retry-handler.test.ts b/src/features/background-agent/fallback-retry-handler.test.ts index 53b7b7ef8..fc86f554f 100644 --- a/src/features/background-agent/fallback-retry-handler.test.ts +++ b/src/features/background-agent/fallback-retry-handler.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler" import type { FallbackEntry } from "../../shared/model-requirements" +import type { ProviderModelsCache } from "../../shared/connected-providers-cache" +import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission" const sharedLogMock = mock(() => {}) const readConnectedProvidersCacheMock = mock(() => null) -const readProviderModelsCacheMock = mock((): { connected: string[] } | null => null) +const readProviderModelsCacheMock = mock((): ProviderModelsCache | null => null) const shouldRetryErrorMock = mock(() => true) const getNextFallbackMock = mock((chain: FallbackEntry[], attempt: number) => chain[attempt]) const hasMoreFallbacksMock = mock((chain: FallbackEntry[], attempt: number) => attempt < chain.length) @@ -258,6 +260,20 @@ describe("tryFallbackRetry", () => { expect(retryInput?.onSessionCreated).toBe(onSessionCreated) }) + test("preserves delegated launch context in retry input", async () => { + const args = createDefaultArgs({ + skillContent: "delegated skill system", + sessionPermission: QUESTION_DENIED_SESSION_PERMISSION, + }) + + await tryFallbackRetry(args) + + const key = `${args.task.model!.providerID}/${args.task.model!.modelID}` + const retryInput = args.queuesByKey.get(key)?.[0]?.input + expect(retryInput?.skillContent).toBe("delegated skill system") + expect(retryInput?.sessionPermission).toEqual(QUESTION_DENIED_SESSION_PERMISSION) + }) + test("finalizes the failed attempt, creates a new pending attempt, and enqueues its explicit attemptID", async () => { const args = createDefaultArgs({ status: "running", @@ -416,7 +432,11 @@ describe("tryFallbackRetry", () => { describe("#given disconnected fallback providers with connected preferred provider", () => { test("keeps fallback entry and selects connected preferred provider", async () => { - readProviderModelsCacheMock.mockReturnValueOnce({ connected: ["provider-a"] }) + readProviderModelsCacheMock.mockReturnValueOnce({ + connected: ["provider-a"], + models: {}, + updatedAt: new Date("2026-05-16T00:00:00.000Z").toISOString(), + }) selectFallbackProviderMock.mockImplementationOnce( (_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b", ) diff --git a/src/features/background-agent/fallback-retry-handler.ts b/src/features/background-agent/fallback-retry-handler.ts index 3cf1453ef..92a92de95 100644 --- a/src/features/background-agent/fallback-retry-handler.ts +++ b/src/features/background-agent/fallback-retry-handler.ts @@ -197,6 +197,8 @@ export async function tryFallbackRetry(args: { teamRunId: task.teamRunId, model: nextModel, fallbackChain: task.fallbackChain, + skillContent: task.skillContent, + sessionPermission: task.sessionPermission, category: task.category, isUnstableAgent: task.isUnstableAgent, onSessionCreated: task.onSessionCreated, diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index d1fca8183..123f6e23b 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,25 +1,28 @@ -declare const require: (name: string) => any -const { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } = require("bun:test") - -afterAll(() => { mock.restore() }) - -import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state" import { tmpdir } from "node:os" +import { describe, test, expect, beforeEach, afterEach, afterAll, spyOn, mock } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import * as sharedModule from "../../shared" -import { _resetForTesting as resetClaudeCodeSessionState, registerAgentName, subagentSessions } from "../claude-code-session-state" -import type { BackgroundTask, ResumeInput } from "./types" -import { MIN_IDLE_TIME_MS } from "./constants" -import { BackgroundManager } from "./manager" -import { ConcurrencyManager } from "./concurrency" -import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate" -import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager" -import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup" -import { clearBackgroundTaskRegistryForTesting } from "./task-registry" import { clearAllDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap, } from "../../shared/delegated-child-session-bootstrap" +import { promptAsyncAfterSessionIdle } from "../../shared/prompt-async-gate" +import { clearSessionPromptParams, getSessionPromptParams } from "../../shared/session-prompt-params-state" +import { + getSessionAgent, + registerAgentName, + _resetForTesting as resetClaudeCodeSessionState, + subagentSessions, +} from "../claude-code-session-state" +import { _resetTaskToastManagerForTesting, initTaskToastManager } from "../task-toast-manager/manager" +import type { ConcurrencyManager } from "./concurrency" +import { MIN_IDLE_TIME_MS } from "./constants" +import { BackgroundManager } from "./manager" +import { _resetForTesting as resetProcessCleanupState } from "./process-cleanup" +import { clearBackgroundTaskRegistryForTesting } from "./task-registry" +import type { BackgroundTask, ResumeInput } from "./types" + +afterAll(() => { mock.restore() }) afterEach(() => { clearBackgroundTaskRegistryForTesting() @@ -194,6 +197,30 @@ function cast(value: unknown): T { return value as T } +async function expectRejectsWithMessage(promise: Promise, expectedMessage: string): Promise { + await promise.then( + () => { + throw new Error(`Expected promise to reject with ${expectedMessage}`) + }, + (error: unknown) => { + expect(String(error)).toContain(expectedMessage) + }, + ) +} + +async function expectResolvesDefined(promise: Promise): Promise { + const result = await promise + expect(result).toBeDefined() +} + +async function expectResolvesMatchObject( + promise: Promise, + expected: Partial, +): Promise { + const result = await promise + expect(result).toMatchObject(expected) +} + function createPluginInput(client: unknown, directory = tmpdir()): PluginInput { return cast({ client, directory }) } @@ -492,6 +519,7 @@ describe("BackgroundManager delegated child-session bootstrap", () => { queuedAt: new Date(), prompt: "background bootstrap prompt", agent: "sisyphus-junior", + skillContent: "background delegated skill system", category: "quick", model: { providerID: "anthropic", modelID: "claude-haiku-4-5" }, fallbackChain: [{ model: "gpt-5.4", providers: ["openai"], variant: "high" }], @@ -508,6 +536,7 @@ describe("BackgroundManager delegated child-session bootstrap", () => { parentTools: task.parentTools, model: task.model, fallbackChain: task.fallbackChain, + skillContent: task.skillContent, category: task.category, } @@ -519,6 +548,10 @@ describe("BackgroundManager delegated child-session bootstrap", () => { //#then expect(observedBootstrapPrompts[0]).toContain("background bootstrap prompt") + const bootstrap = getDelegatedChildSessionBootstrap("ses_background_bootstrap") + expect(bootstrap?.system).toBe("background delegated skill system") + expect(bootstrap?.tools?.question).toBe(false) + expect(bootstrap?.tools?.task).toBe(false) expect(getDelegatedChildSessionBootstrap("ses_background_bootstrap")).toBeDefined() const completed = await tryCompleteTaskForTest(manager, task) @@ -708,7 +741,13 @@ describe("BackgroundManager retry observability", () => { //#then expect(queuePendingParentWake).toHaveBeenCalledTimes(1) - const [sessionID, notification, promptContext, shouldReply] = queuePendingParentWake.mock.calls[0] + const retryingCall = cast, boolean]>>( + queuePendingParentWake.mock.calls, + )[0] + if (!retryingCall) { + throw new Error("Expected retrying parent wake call") + } + const [sessionID, notification, promptContext, shouldReply] = retryingCall expect(sessionID).toBe("parent-session") expect(promptContext).toEqual({}) expect(shouldReply).toBe(false) @@ -2840,7 +2879,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const result = manager.launch(input) // then - await expect(result).rejects.toThrow("Agent parameter is required after sanitization") + await expectRejectsWithMessage(result, "Agent parameter is required after sanitization") }) test("should initialize attempt state for a newly launched task", async () => { @@ -3162,7 +3201,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const result = manager.launch(input) // then - await expect(result).rejects.toThrow("background_task.maxDepth=3") + await expectRejectsWithMessage(result, "background_task.maxDepth=3") }) test("allows multiple descendants without a root spawn cap", async () => { @@ -3191,7 +3230,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const result = manager.launch(input) // then - await expect(result).resolves.toBeDefined() + await expectResolvesDefined(result) }) test("allows spawn assertions after reserveSubagentSpawn without a root spawn cap", async () => { @@ -3212,7 +3251,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const result = manager.assertCanSpawn("session-root") // then - await expect(result).resolves.toMatchObject({ + await expectResolvesMatchObject(result, { rootSessionID: "session-root", childDepth: 1, }) @@ -3245,7 +3284,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { const result = manager.launch(input) // then - await expect(result).rejects.toThrow("background_task.maxDepth cannot be enforced safely") + await expectRejectsWithMessage(result, "background_task.maxDepth cannot be enforced safely") }) test("allows replacement launch when a queued task is cancelled before session starts", async () => { @@ -3745,7 +3784,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { // Complete via internal method (session.status events go through the poller, not handleEvent) await tryCompleteTaskForTest(manager, internalTask) - await expect(manager.launch(input)).resolves.toBeDefined() + await expectResolvesDefined(manager.launch(input)) }) test("allows relaunch after running task is cancelled", async () => { @@ -3774,7 +3813,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { await manager.cancelTask(task.id) - await expect(manager.launch(input)).resolves.toBeDefined() + await expectResolvesDefined(manager.launch(input)) }) test("allows relaunch after task errors", async () => { @@ -3807,7 +3846,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { }) await new Promise((resolve) => setTimeout(resolve, 100)) - await expect(manager.launch(input)).resolves.toBeDefined() + await expectResolvesDefined(manager.launch(input)) }) test("allows repeated relaunch after pending tasks are cancelled", async () => { @@ -3835,8 +3874,8 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => { await manager.cancelTask(task1.id) await manager.cancelTask(task2.id) - await expect(manager.launch(input)).resolves.toBeDefined() - await expect(manager.launch(input)).resolves.toBeDefined() + await expectResolvesDefined(manager.launch(input)) + await expectResolvesDefined(manager.launch(input)) }) }) @@ -5050,10 +5089,12 @@ describe("BackgroundManager.handleEvent - session.error", () => { const mockVerifySessionExists = (manager: BackgroundManager, sessionExists: boolean): void => { verifySessionExistsSpy?.mockRestore() - verifySessionExistsSpy = spyOn( + const spy = spyOn( cast<{ verifySessionExists: (sessionID: string) => Promise }>(manager), "verifySessionExists", - ).mockResolvedValue(sessionExists) + ) + spy.mockImplementation(async () => sessionExists) + verifySessionExistsSpy = spy } const stubProcessKey = (manager: BackgroundManager) => { @@ -7072,6 +7113,62 @@ describe("BackgroundManager - tool permission spread order", () => { manager.shutdown() }) + test("startTask updates tracked session agent when launch falls back to general", async () => { + //#given + const promptCalls: Array<{ path: { id: string }; body: Record }> = [] + let promptCallCount = 0 + const client = { + session: { + get: async () => ({ data: { directory: "/test/dir" } }), + create: async () => ({ data: { id: "session-manager-fallback" } }), + promptAsync: async (args: { path: { id: string }; body: Record }) => { + promptCallCount++ + promptCalls.push(args) + if (promptCallCount === 1) { + throw new Error("Agent not found: missing-agent") + } + return {} + }, + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + const task: BackgroundTask = { + id: "task-manager-fallback", + status: "pending", + queuedAt: new Date(), + description: "test task", + prompt: "test prompt", + agent: "missing-agent", + parentSessionId: "parent-session", + parentMessageId: "parent-message", + } + const input: import("./types").LaunchInput = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + } + + try { + //#when + await (cast<{ startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput }) => Promise }>(manager)) + .startTask({ task, input }) + await new Promise((resolve) => setTimeout(resolve, 50)) + + //#then + expect(promptCalls).toHaveLength(2) + expect(promptCalls[0].body.agent).toBe("missing-agent") + expect(promptCalls[1].body.agent).toBe("general") + expect(task.agent).toBe("general") + expect(getSessionAgent("session-manager-fallback")).toBe("general") + expect(getDelegatedChildSessionBootstrap("session-manager-fallback")?.tools?.call_omo_agent).toBe(true) + } finally { + manager.shutdown() + clearAllDelegatedChildSessionBootstrap() + } + }) + test("resume respects explore agent restrictions", async () => { //#given let capturedTools: Record | undefined @@ -7216,6 +7313,7 @@ describe("BackgroundManager.launch - attempt state initialization", () => { describe("BackgroundManager attempt lifecycle bindings", () => { test("startTask binds the created session to the queued attempt ID and mirrors task projection", async () => { //#given + resetClaudeCodeSessionState() const client = { session: { get: async () => ({ data: { directory: "/test/dir" } }), @@ -7288,6 +7386,7 @@ describe("BackgroundManager attempt lifecycle bindings", () => { status: "error", error: "first attempt failed", }) + expect(getSessionAgent("session-attempt-2")).toBe("sisyphus-junior") manager.shutdown() }) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 9f96aa4e4..354db77bf 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -28,7 +28,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" import { isInsideTmux } from "../../shared/tmux" -import { subagentSessions } from "../claude-code-session-state" +import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state" import { MESSAGE_STORAGE } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" import { abortWithTimeout } from "./abort-with-timeout" @@ -574,6 +574,8 @@ export class BackgroundManager { parentTools: input.parentTools, model: input.model, fallbackChain: input.fallbackChain, + skillContent: input.skillContent, + sessionPermission: input.sessionPermission, attemptCount: 0, category: input.category, onSessionCreated: input.onSessionCreated, @@ -756,6 +758,7 @@ export class BackgroundManager { await input.onSessionCreated?.(sessionID) this.settlePreStartDescendantReservation(task) subagentSessions.add(sessionID) + setSessionAgent(sessionID, input.agent) if (this.tasks.get(task.id)?.status === "cancelled") { clearDelegatedChildSessionBootstrap(sessionID) @@ -826,12 +829,39 @@ The fallback retry session is now created and can be inspected directly. this.taskHistory.record(input.parentSessionId, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt }) this.startPolling() + // Fire-and-forget prompt via promptAsync (no response body needed) + // OpenCode prompt payload accepts model provider/model IDs and top-level variant only. + // Temperature/topP and provider-specific options are applied through chat.params. + const launchModel = input.model + ? { + providerID: input.model.providerID, + modelID: input.model.modelID, + } + : undefined + const launchVariant = input.model?.variant + + if (input.model) { + applySessionPromptParams(sessionID, input.model) + } + + const launchTools = { + task: false, + call_omo_agent: true, + question: false, + ...getAgentToolRestrictions(input.agent, { + includeTeamToolDenylist: input.teamRunId === undefined, + }), + } + setSessionTools(sessionID, launchTools) + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) registerDelegatedChildSessionBootstrap({ sessionID, promptText: input.prompt, fallbackChain: input.fallbackChain, category: input.category, + system: input.skillContent, + tools: launchTools, modelFallbackControllerAccessor: this.modelFallbackControllerAccessor, }) @@ -848,38 +878,12 @@ The fallback retry session is now created and can be inspected directly. promptLength: input.prompt.length, }) - // Fire-and-forget prompt via promptAsync (no response body needed) - // OpenCode prompt payload accepts model provider/model IDs and top-level variant only. - // Temperature/topP and provider-specific options are applied through chat.params. - const launchModel = input.model - ? { - providerID: input.model.providerID, - modelID: input.model.modelID, - } - : undefined - const launchVariant = input.model?.variant - - if (input.model) { - applySessionPromptParams(sessionID, input.model) - } - const promptBody = { agent: input.agent, ...(launchModel ? { model: launchModel } : {}), ...(launchVariant ? { variant: launchVariant } : {}), system: input.skillContent, - tools: (() => { - const tools = { - task: false, - call_omo_agent: true, - question: false, - ...getAgentToolRestrictions(input.agent, { - includeTeamToolDenylist: input.teamRunId === undefined, - }), - } - setSessionTools(sessionID, tools) - return tools - })(), + tools: launchTools, parts: [createInternalAgentTextPart(input.prompt)], } @@ -898,7 +902,18 @@ The fallback retry session is now created and can be inspected directly. const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, { includeTeamToolDenylist: input.teamRunId === undefined, }) - setSessionTools(sessionID, fallbackBody.tools as Record) + const fallbackTools = fallbackBody.tools as Record + setSessionTools(sessionID, fallbackTools) + updateSessionAgent(sessionID, FALLBACK_AGENT) + registerDelegatedChildSessionBootstrap({ + sessionID, + promptText: input.prompt, + fallbackChain: input.fallbackChain, + category: input.category, + system: input.skillContent, + tools: fallbackTools, + modelFallbackControllerAccessor: this.modelFallbackControllerAccessor, + }) await promptWithRetryInDirectory(this.client, { path: { id: sessionID }, body: fallbackBody, diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index f19c6ea7b..c1a31e01c 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -1,10 +1,10 @@ -import { describe, test, expect, mock, afterEach } from "bun:test" -import { createTask, startTask } from "./spawner" -import type { BackgroundTask } from "./types" +import { afterEach, describe, expect, mock, test } from "bun:test" import { clearSessionPromptParams, getSessionPromptParams, } from "../../shared/session-prompt-params-state" +import { createTask, startTask } from "./spawner" +import type { BackgroundTask } from "./types" describe("background-agent spawner agent-not-found fallback", () => { afterEach(() => { diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index e690c2f18..b61fe5e04 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -1,13 +1,14 @@ -import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" -import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants" -import { log, getAgentToolRestrictions, createInternalAgentTextPart, promptWithRetryInDirectory } from "../../shared" +import { createInternalAgentTextPart, getAgentToolRestrictions, log, promptWithRetryInDirectory } from "../../shared" +import { stripAgentListSortPrefix } from "../../shared/agent-display-names" import { releasePromptAsyncReservation } from "../../shared/prompt-async-gate" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" -import { subagentSessions } from "../claude-code-session-state" -import { getTaskToastManager } from "../task-toast-manager" +import { setSessionTools } from "../../shared/session-tools-store" import { isInsideTmux } from "../../shared/tmux" -import { stripAgentListSortPrefix } from "../../shared/agent-display-names" +import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state" +import { getTaskToastManager } from "../task-toast-manager" import type { ConcurrencyManager } from "./concurrency" +import type { OnSubagentSessionCreated, OpencodeClient, QueueItem } from "./constants" +import type { BackgroundTask, LaunchInput, ResumeInput } from "./types" export const FALLBACK_AGENT = "general" @@ -65,7 +66,13 @@ export function createTask(input: LaunchInput): BackgroundTask { teamRunId: input.teamRunId, parentModel: input.parentModel, parentAgent: input.parentAgent, + parentTools: input.parentTools, model: input.model, + fallbackChain: input.fallbackChain, + skillContent: input.skillContent, + sessionPermission: input.sessionPermission, + category: input.category, + isUnstableAgent: input.isUnstableAgent, onSessionCreated: input.onSessionCreated, } } @@ -118,6 +125,7 @@ export async function startTask( const sessionID = createResult.data.id await input.onSessionCreated?.(sessionID) subagentSessions.add(sessionID) + setSessionAgent(sessionID, input.agent) task.status = "running" task.startedAt = new Date() @@ -170,6 +178,7 @@ export async function startTask( }, parts: [createInternalAgentTextPart(input.prompt)], } + setSessionTools(sessionID, promptBody.tools) // Must fire BEFORE tmux callback: attach client needs session activity to render TUI. const promptChain = promptWithRetryInDirectory(client, { @@ -184,11 +193,15 @@ export async function startTask( }) try { releasePromptAsyncReservation(sessionID, "model-suggestion-retry") + const fallbackBody = buildFallbackBody(promptBody, FALLBACK_AGENT, { + includeTeamToolDenylist: input.teamRunId === undefined, + }) + const fallbackTools = fallbackBody.tools as Record + setSessionTools(sessionID, fallbackTools) + updateSessionAgent(sessionID, FALLBACK_AGENT) await promptWithRetryInDirectory(client, { path: { id: sessionID }, - body: buildFallbackBody(promptBody, FALLBACK_AGENT, { - includeTeamToolDenylist: input.teamRunId === undefined, - }), + body: fallbackBody, }, parentDirectory) task.agent = FALLBACK_AGENT return @@ -310,6 +323,7 @@ export async function resumeTask( }, parts: [createInternalAgentTextPart(input.prompt)], } + setSessionTools(sessionID, resumeBody.tools) promptWithRetryInDirectory(client, { path: { id: sessionID }, @@ -323,11 +337,15 @@ export async function resumeTask( }) try { releasePromptAsyncReservation(sessionID, "model-suggestion-retry") + const fallbackBody = buildFallbackBody(resumeBody, FALLBACK_AGENT, { + includeTeamToolDenylist: task.teamRunId === undefined, + }) + const fallbackTools = fallbackBody.tools as Record + setSessionTools(sessionID, fallbackTools) + updateSessionAgent(sessionID, FALLBACK_AGENT) await promptWithRetryInDirectory(client, { path: { id: sessionID }, - body: buildFallbackBody(resumeBody, FALLBACK_AGENT, { - includeTeamToolDenylist: task.teamRunId === undefined, - }), + body: fallbackBody, }, directory) task.agent = FALLBACK_AGENT return diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 76c242a41..f864a3fa3 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -73,6 +73,8 @@ export interface BackgroundTask { parentAgent?: string /** Parent session's tool restrictions for notification prompts */ parentTools?: Record + skillContent?: string + sessionPermission?: SessionPermissionRule[] /** Marks if the task was launched from an unstable agent/category */ isUnstableAgent?: boolean /** Category used for this task (e.g., 'quick', 'visual-engineering') */ From ea5f6ddfd7a243d969ca23d17aca5ff4a58b981f Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:08:56 +0900 Subject: [PATCH 6/9] fix(call-omo-agent): register bootstrap and session agent before sync prompt dispatch call_omo_agent sync path created the child OpenCode session and went straight into promptAsync without registering child session agent, session tools, or bootstrap state. If first dispatch failed before any durable user message persisted, runtime fallback could not reconstruct the original prompt or the agent identity for that child session. Bind setSessionAgent and setSessionTools to the child session id with the same tool restrictions that the prompt body sends, register a delegated child session bootstrap with the prompt text, fallback chain, and prompt tools, then clean bootstrap + session tools in finally for sessions this call created. --- .../call-omo-agent/sync-executor.test.ts | 61 ++++++++++++++++++- src/tools/call-omo-agent/sync-executor.ts | 50 +++++++++++---- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index 3b0e0db5f..58d56a16d 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -1,5 +1,5 @@ import { unsafeTestValue } from "../../../test-support/unsafe-test-value" -const { describe, test, expect, mock } = require("bun:test") +import { describe, test, expect, mock } from "bun:test" type ExecuteSync = typeof import("./sync-executor").executeSync @@ -13,6 +13,7 @@ type PromptAsyncInput = { variant?: string temperature?: number topP?: number + maxOutputTokens?: number options?: Record } } @@ -342,6 +343,64 @@ describe("executeSync", () => { expect(deps.setSessionFallbackChain).toHaveBeenCalledWith("ses-fallback", fallbackChain) }) + test("registers child-session bootstrap and tracked prompt state before sync prompt dispatch", async () => { + //#given + const executeSync = await importExecuteSync() + const { _resetForTesting, getSessionAgent } = require("../../features/claude-code-session-state") + const { clearAllDelegatedChildSessionBootstrap, getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") + const { clearSessionTools, getSessionTools } = require("../../shared/session-tools-store") + const deps = createDependencies({ + createOrGetSession: mock(async () => ({ sessionID: "ses-call-bootstrap", isNew: true })), + }) + const toolContext = createToolContext() + const observed: Array<{ + agent: string | undefined + tools: Record | undefined + bootstrap: ReturnType + }> = [] + const recorder = createPromptAsyncRecorder(async () => { + observed.push({ + agent: getSessionAgent("ses-call-bootstrap"), + tools: getSessionTools("ses-call-bootstrap"), + bootstrap: getDelegatedChildSessionBootstrap("ses-call-bootstrap"), + }) + return { data: {} } + }) + const args = { + subagent_type: "explore", + description: "bootstrap state", + prompt: "collect bootstrap evidence", + run_in_background: false, + } + const fallbackChain = [ + { providers: ["openai"], model: "gpt-5.4", variant: "high" }, + ] + + try { + //#when + await executeSync( + args, + toolContext, + createContext(recorder.promptAsync) as never, + deps, + fallbackChain + ) + + //#then + expect(observed[0]?.agent).toBe("explore") + expect(observed[0]?.tools?.question).toBe(false) + expect(observed[0]?.tools?.task).toBe(false) + expect(observed[0]?.bootstrap?.retryParts[0]?.text).toContain("collect bootstrap evidence") + expect(observed[0]?.bootstrap?.tools?.question).toBe(false) + expect(observed[0]?.bootstrap?.fallbackChain?.[0]?.model).toBe("gpt-5.4") + expect(getDelegatedChildSessionBootstrap("ses-call-bootstrap")).toBeUndefined() + } finally { + clearAllDelegatedChildSessionBootstrap() + clearSessionTools() + _resetForTesting() + } + }) + test("returns dedicated agent-not-found error with task metadata", async () => { //#given const executeSync = await importExecuteSync() diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index dfb47b62b..59a802263 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -1,15 +1,20 @@ -import type { CallOmoAgentArgs } from "./types" import type { PluginInput } from "@opencode-ai/plugin" -import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state" -import { getAgentToolRestrictions, log } from "../../shared" -import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" -import type { DelegatedModelConfig } from "../../shared/model-resolution-types" -import type { FallbackEntry } from "../../shared/model-requirements" -import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names" +import { setSessionAgent, subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state" import { promptAsyncAfterSessionIdle } from "../../hooks/shared/prompt-async-gate" +import { getAgentToolRestrictions, log } from "../../shared" +import { getAgentDisplayName, stripAgentListSortPrefix } from "../../shared/agent-display-names" +import { + clearDelegatedChildSessionBootstrap, + registerDelegatedChildSessionBootstrap, +} from "../../shared/delegated-child-session-bootstrap" +import type { FallbackEntry } from "../../shared/model-requirements" +import type { DelegatedModelConfig } from "../../shared/model-resolution-types" +import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" +import { deleteSessionTools, setSessionTools } from "../../shared/session-tools-store" import { waitForCompletion } from "./completion-poller" import { processMessages } from "./message-processor" import { createOrGetSession } from "./session-creator" +import type { CallOmoAgentArgs } from "./types" type SessionWithPromptAsync = { promptAsync: (opts: { path: { id: string }; body: Record }) => Promise @@ -58,6 +63,14 @@ function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): R } } +function buildSyncPromptTools(agent: string): Record { + return { + ...getAgentToolRestrictions(agent), + task: false, + question: false, + } +} + export async function executeSync( args: CallOmoAgentArgs, toolContext: { @@ -105,6 +118,16 @@ export async function executeSync( log(`[call_omo_agent] Sending prompt to session ${sessionID}`) log(`[call_omo_agent] Prompt text:`, args.prompt.substring(0, 100)) const normalizedSubagentType = stripAgentListSortPrefix(args.subagent_type) + const promptAgent = getAgentDisplayName(normalizedSubagentType) + const promptTools = buildSyncPromptTools(normalizedSubagentType) + setSessionAgent(sessionID, promptAgent) + setSessionTools(sessionID, promptTools) + registerDelegatedChildSessionBootstrap({ + sessionID, + promptText: args.prompt, + fallbackChain, + tools: promptTools, + }) try { if (!hasPromptAsync(ctx.client.session)) { @@ -119,12 +142,8 @@ export async function executeSync( input: { path: { id: sessionID }, body: { - agent: getAgentDisplayName(normalizedSubagentType), - tools: { - ...getAgentToolRestrictions(normalizedSubagentType), - task: false, - question: false, - }, + agent: promptAgent, + tools: promptTools, parts: [{ type: "text", text: args.prompt }], ...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}), ...(model?.variant ? { variant: model.variant } : {}), @@ -160,9 +179,14 @@ export async function executeSync( deps.clearSessionFallbackChain(sessionID) } + if (sessionID) { + clearDelegatedChildSessionBootstrap(sessionID) + } + if (sessionID && createdSessionForExecution) { subagentSessions.delete(sessionID) syncSubagentSessions.delete(sessionID) + deleteSessionTools(sessionID) } } } From 791fbf3e55f480ca4c42e57f340071bdb340d3c0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:09:11 +0900 Subject: [PATCH 7/9] refactor(delegate-task): share buildSyncPromptTools between bootstrap and prompt dispatch Two call sites built the sync delegate tool gate independently: sync-prompt-sender's prompt body construction and sync-task's bootstrap registration. Drift between them would let bootstrap claim one tool set while the actual prompt sent a different one. Extract buildSyncPromptTools and route both call sites through it so the registered bootstrap and the dispatched prompt always agree. --- src/tools/delegate-task/sync-prompt-sender.ts | 33 ++++++++++--------- src/tools/delegate-task/sync-task.test.ts | 11 +++++-- src/tools/delegate-task/sync-task.ts | 10 +++--- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/tools/delegate-task/sync-prompt-sender.ts b/src/tools/delegate-task/sync-prompt-sender.ts index bfdac5fba..a67e0db39 100644 --- a/src/tools/delegate-task/sync-prompt-sender.ts +++ b/src/tools/delegate-task/sync-prompt-sender.ts @@ -1,18 +1,18 @@ -import type { DelegateTaskArgs, OpencodeClient, DelegatedModelConfig } from "./types" import type { SisyphusAgentConfig } from "../../config/schema" -import { isPlanFamily } from "./constants" -import { buildTaskPrompt } from "./prompt-builder" +import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names" +import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" +import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker" import { promptSyncWithModelSuggestionRetry, promptWithModelSuggestionRetry, } from "../../shared/model-suggestion-retry" -import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route" -import { formatDetailedError } from "./error-formatting" -import { getAgentToolRestrictions } from "../../shared/agent-tool-restrictions" -import { stripInvisibleAgentCharacters } from "../../shared/agent-display-names" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" +import { routePromptRetry, routePromptSyncRetry } from "../../shared/session-route" import { setSessionTools } from "../../shared/session-tools-store" -import { createInternalAgentTextPart } from "../../shared/internal-initiator-marker" +import { isPlanFamily } from "./constants" +import { formatDetailedError } from "./error-formatting" +import { buildTaskPrompt } from "./prompt-builder" +import type { DelegatedModelConfig, DelegateTaskArgs, OpencodeClient } from "./types" type SendSyncPromptDeps = { promptWithModelSuggestionRetry: typeof promptWithModelSuggestionRetry @@ -52,6 +52,15 @@ function isUnexpectedEofError(error: unknown): boolean { return lowered.includes("unexpected eof") || lowered.includes("json parse error") } +export function buildSyncPromptTools(agentToUse: string): Record { + return { + task: isPlanFamily(agentToUse), + call_omo_agent: true, + question: false, + ...getAgentToolRestrictions(agentToUse), + } +} + export async function sendSyncPrompt( client: OpencodeClient, input: { @@ -67,15 +76,9 @@ export async function sendSyncPrompt( }, deps: SendSyncPromptDeps = sendSyncPromptDeps ): Promise { - const allowTask = isPlanFamily(input.agentToUse) const tddEnabled = input.sisyphusAgentConfig?.tdd const effectivePrompt = buildTaskPrompt(input.args.prompt, input.agentToUse, tddEnabled) - const tools = { - task: allowTask, - call_omo_agent: true, - question: false, - ...getAgentToolRestrictions(input.agentToUse), - } + const tools = buildSyncPromptTools(input.agentToUse) setSessionTools(input.sessionID, tools) applySessionPromptParams(input.sessionID, input.categoryModel) diff --git a/src/tools/delegate-task/sync-task.test.ts b/src/tools/delegate-task/sync-task.test.ts index 63f036611..b7ff4a7db 100644 --- a/src/tools/delegate-task/sync-task.test.ts +++ b/src/tools/delegate-task/sync-task.test.ts @@ -1,4 +1,4 @@ -const { describe, test, expect, beforeEach, afterEach, mock, spyOn } = require("bun:test") +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" function clearRequireCache(modulePath: string): void { const resolvedPath = require.resolve(modulePath) @@ -678,12 +678,16 @@ describe("executeSyncTask - cleanup on error paths", () => { const { executeSyncTask } = require("./sync-task") const { getDelegatedChildSessionBootstrap } = require("../../shared/delegated-child-session-bootstrap") const observedBootstrapPrompts: string[] = [] + const observedBootstrapSystems: Array = [] + const observedBootstrapTools: Array | undefined> = [] const deps = { createSyncSession: async () => ({ ok: true as const, sessionID: "ses_bootstrap_sync" }), sendSyncPrompt: async (_client: unknown, input: { sessionID: string }) => { const bootstrap = getDelegatedChildSessionBootstrap(input.sessionID) observedBootstrapPrompts.push(bootstrap?.retryParts[0]?.text ?? "") + observedBootstrapSystems.push(bootstrap?.system) + observedBootstrapTools.push(bootstrap?.tools) return null }, pollSyncSession: async () => null, @@ -717,10 +721,13 @@ describe("executeSyncTask - cleanup on error paths", () => { const result = await executeSyncTask(args, mockCtx, mockExecutorCtx, { sessionID: "parent-session", - }, "sisyphus-junior", undefined, undefined, undefined, undefined, deps) + }, "sisyphus-junior", undefined, "sync delegated skill system", undefined, undefined, deps) expect(result).toContain("sync result") expect(observedBootstrapPrompts[0]).toContain("sync bootstrap prompt") + expect(observedBootstrapSystems[0]).toBe("sync delegated skill system") + expect(observedBootstrapTools[0]?.question).toBe(false) + expect(observedBootstrapTools[0]?.call_omo_agent).toBe(true) expect(getDelegatedChildSessionBootstrap("ses_bootstrap_sync")).toBeUndefined() }) diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index e597634a0..4b2f8dd87 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -15,6 +15,7 @@ import { formatDetailedError } from "./error-formatting" import type { ExecutorContext, ParentContext } from "./executor-types" import { buildTaskPrompt } from "./prompt-builder" import { resolveMetadataModel } from "./resolve-metadata-model" +import { buildSyncPromptTools } from "./sync-prompt-sender" import { type SyncTaskDeps, syncTaskDeps } from "./sync-task-deps" import { getNextSyncFallbackModel, retrySyncPromptWithFallbacks } from "./sync-task-fallback" import { formatDuration } from "./time-formatter" @@ -117,6 +118,8 @@ export async function executeSyncTask( promptText: buildTaskPrompt(args.prompt, agentToUse, executorCtx.sisyphusAgentConfig?.tdd), fallbackChain, category: args.category, + system: systemContent, + tools: buildSyncPromptTools(agentToUse), modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor, }) @@ -138,7 +141,6 @@ export async function executeSyncTask( const publishSyncMetadata = async ( currentSessionID: string, currentModel: DelegatedModelConfig | undefined, - currentTaskId: string, spawnDepth: number, ): Promise => { await publishToolMetadata(ctx, { @@ -178,7 +180,7 @@ export async function executeSyncTask( modelInfo, }) } - await publishSyncMetadata(sessionID, categoryModel, taskId, spawnContext.childDepth) + await publishSyncMetadata(sessionID, categoryModel, spawnContext.childDepth) const syncPromptInput = { sessionID, @@ -312,7 +314,7 @@ export async function executeSyncTask( }) } if (taskId) { - await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId, spawnContext.childDepth) + await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth) } continue } @@ -337,7 +339,7 @@ export async function executeSyncTask( modelRoutingNote = `\nModel: ${actualModelStr}${args.category ? ` (category: ${args.category})` : ""}` } - await publishSyncMetadata(activeSessionID, effectiveCategoryModel, taskId!, spawnContext.childDepth) + await publishSyncMetadata(activeSessionID, effectiveCategoryModel, spawnContext.childDepth) return `Task completed in ${duration}. From cc97a023cc4b73a0b6937f8f9a0d12b7c5531b19 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:09:21 +0900 Subject: [PATCH 8/9] test(agents): drop unsafe AgentFactory cast and add typed empty skills Tests that mock an AgentFactory were using an `as AgentFactory` cast and a separate mutation of `mockFactory.mode` to satisfy the type. Replace with Object.assign so the factory type is constructed correctly without casts. Also type the empty discoveredSkills fixture so its element type is inferred from the function signature instead of collapsing to never[]. --- src/agents/agent-builder.test.ts | 19 ++++++++----------- .../builtin-agents/available-skills.test.ts | 6 ++++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/src/agents/agent-builder.test.ts b/src/agents/agent-builder.test.ts index 56abb8ac7..f9be614aa 100644 --- a/src/agents/agent-builder.test.ts +++ b/src/agents/agent-builder.test.ts @@ -1,16 +1,15 @@ -import { describe, test, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { buildAgent } from "./agent-builder" import type { AgentFactory } from "./types" describe("#given an agent factory with mode", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "test-agent", description: "Test", instructions: "test", model, temperature: 0.1, - })) as AgentFactory - mockFactory.mode = "subagent" + }), { mode: "subagent" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") @@ -19,14 +18,13 @@ describe("#given an agent factory with mode", () => { }) describe("#given an agent factory with mode=primary", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "primary-agent", description: "Primary Test", instructions: "test", model, temperature: 0.1, - })) as AgentFactory - mockFactory.mode = "primary" + }), { mode: "primary" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") @@ -50,15 +48,14 @@ describe("#given an agent config object without mode", () => { }) describe("#given an agent factory with mode but config already has mode", () => { - const mockFactory = ((model: string) => ({ + const mockFactory: AgentFactory = Object.assign((model: string) => ({ name: "override-agent", description: "Override Test", instructions: "test", model, temperature: 0.1, - mode: "all", - })) as AgentFactory - mockFactory.mode = "subagent" + mode: "all" as const, + }), { mode: "subagent" as const }) test("#when building agent from factory", () => { const agent = buildAgent(mockFactory, "test-model") diff --git a/src/agents/builtin-agents/available-skills.test.ts b/src/agents/builtin-agents/available-skills.test.ts index 2505af5bb..fe5ea8441 100644 --- a/src/agents/builtin-agents/available-skills.test.ts +++ b/src/agents/builtin-agents/available-skills.test.ts @@ -2,10 +2,12 @@ import { describe, expect, test } from "bun:test" import { buildAvailableSkills } from "./available-skills" +type DiscoveredSkills = Parameters[0] + describe("buildAvailableSkills", () => { test("includes team-mode when team mode is enabled", () => { // given - const discoveredSkills = [] + const discoveredSkills: DiscoveredSkills = [] // when const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, true) @@ -16,7 +18,7 @@ describe("buildAvailableSkills", () => { test("excludes team-mode when team mode is disabled", () => { // given - const discoveredSkills = [] + const discoveredSkills: DiscoveredSkills = [] // when const availableSkills = buildAvailableSkills(discoveredSkills, undefined, undefined, false) From d3318617d09586ae65d8defc39bfa791e70f7c83 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sun, 17 May 2026 00:30:50 +0900 Subject: [PATCH 9/9] fix(background-agent): clean child session-agent state on pre-start abort and normalize stored agent Two adjacent gaps cubic flagged on the previous diff: 1. spawner.startTask stored input.agent (potentially prefixed with sort marker and ZWSP) in setSessionAgent, but the prompt body used the stripped/normalized form. The session-agent registry therefore did not match what promptAsync actually dispatched. Capture the normalized agent once at the top of startTask and use it for setSessionAgent plus the launch log lines. 2. manager.startTask wrote setSessionAgent(sessionID, input.agent) before the cancelled and stale-attempt cleanup branches, but those branches only cleared subagentSessions and the delegated bootstrap. The session->agent mapping survived as orphan state after an aborted launch. Call clearSessionAgent inside both early-return paths so nothing remains tied to a session we just aborted. Adds focused tests for both: spawner persistence parity with promptAsync and manager cancellation cleanup leaving getSessionAgent undefined. --- src/features/background-agent/manager.test.ts | 61 +++++++++++++++++++ src/features/background-agent/manager.ts | 4 +- src/features/background-agent/spawner.test.ts | 61 +++++++++++++++++++ src/features/background-agent/spawner.ts | 8 +-- 4 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 123f6e23b..edb79de57 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -7391,6 +7391,67 @@ describe("BackgroundManager attempt lifecycle bindings", () => { manager.shutdown() }) + test("startTask clears child session agent state when task is cancelled before launch binding", async () => { + //#given + resetClaudeCodeSessionState() + const sessionID = "session-cancelled-prelaunch" + const client = { + session: { + get: async () => ({ data: { directory: "/test/dir" } }), + create: async () => ({ data: { id: sessionID } }), + promptAsync: async () => ({}), + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + const task: BackgroundTask = { + id: "task-cancel-prelaunch", + status: "pending", + queuedAt: new Date(), + description: "cancel before bind", + prompt: "continue", + agent: "sisyphus-junior", + parentSessionId: "parent-session", + parentMessageId: "parent-message", + model: { providerID: "anthropic", modelID: "claude-haiku-4.5" }, + attempts: [ + { + attemptId: "attempt-1", + attemptNumber: 1, + providerId: "anthropic", + modelId: "claude-haiku-4.5", + status: "pending", + }, + ], + currentAttemptID: "attempt-1", + attemptCount: 1, + } + const input: import("./types").LaunchInput = { + description: task.description, + prompt: task.prompt, + agent: task.agent, + parentSessionId: task.parentSessionId, + parentMessageId: task.parentMessageId, + model: task.model, + onSessionCreated: async () => { + // simulate parent flipping task to cancelled between create and bind + task.status = "cancelled" + const internal = cast<{ tasks: Map }>(manager) + internal.tasks.set(task.id, task) + }, + } + + //#when + await (cast<{ + startTask: (item: { task: BackgroundTask; input: import("./types").LaunchInput; attemptID: string }) => Promise + }>(manager)).startTask({ task, input, attemptID: "attempt-1" }) + + //#then + expect(getSessionAgent(sessionID)).toBeUndefined() + + manager.shutdown() + }) + test("historical attempt session IDs resolve to the task while stale session.error events leave the current attempt unchanged", async () => { //#given const manager = createBackgroundManager() diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 354db77bf..a93a7f6b6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -28,7 +28,7 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry" import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers" import { setSessionTools } from "../../shared/session-tools-store" import { isInsideTmux } from "../../shared/tmux" -import { setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state" +import { clearSessionAgent, setSessionAgent, subagentSessions, updateSessionAgent } from "../claude-code-session-state" import { MESSAGE_STORAGE } from "../hook-message-injector" import { getTaskToastManager } from "../task-toast-manager" import { abortWithTimeout } from "./abort-with-timeout" @@ -762,6 +762,7 @@ export class BackgroundManager { if (this.tasks.get(task.id)?.status === "cancelled") { clearDelegatedChildSessionBootstrap(sessionID) + clearSessionAgent(sessionID) await this.abortSessionWithLogging(sessionID, "cancelled during launch setup") subagentSessions.delete(sessionID) if (task.rootSessionId) { @@ -774,6 +775,7 @@ export class BackgroundManager { const boundAttempt = bindAttemptSession(task, attemptID, sessionID, input.model) if (!boundAttempt) { clearDelegatedChildSessionBootstrap(sessionID) + clearSessionAgent(sessionID) await this.abortSessionWithLogging(sessionID, "stale attempt binding cleanup") subagentSessions.delete(sessionID) if (task.rootSessionId) { diff --git a/src/features/background-agent/spawner.test.ts b/src/features/background-agent/spawner.test.ts index c1a31e01c..558cbefb7 100644 --- a/src/features/background-agent/spawner.test.ts +++ b/src/features/background-agent/spawner.test.ts @@ -694,6 +694,67 @@ describe("background-agent spawner fallback model promotion", () => { expect(promptCalls).toHaveLength(1) expect(promptCalls[0]?.body?.agent).toBe("Hephaestus - Deep Agent") }) + + test("persists the same normalized agent used by promptAsync into session-agent state (GH-3259 follow-up)", async () => { + //#given - ZWSP+sort-prefix wrapped agent name + const promptCalls: Array<{ body?: { agent?: string } }> = [] + const sessionID = "ses_child_normalized" + const wrappedAgent = "\u200B\u200B5|Hephaestus - Deep Agent" + + const client = { + session: { + get: async () => ({ data: { directory: "/parent/dir" } }), + create: async () => ({ data: { id: sessionID } }), + promptAsync: async (args?: { body?: { agent?: string } }) => { + promptCalls.push(args ?? {}) + return {} + }, + }, + } + + const { _resetForTesting: resetState, getSessionAgent } = await import("../claude-code-session-state") + resetState() + + const task = createTask({ + description: "Normalized agent storage", + prompt: "Do work", + agent: wrappedAgent, + 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, + }, + } + + const ctx = { + client, + directory: "/fallback", + concurrencyManager: { release: () => {} }, + tmuxEnabled: false, + onTaskError: () => {}, + } + + //#when + await startTask(item as never, ctx as never) + await new Promise((resolve) => setTimeout(resolve, 0)) + + //#then + expect(promptCalls).toHaveLength(1) + const dispatchedAgent = promptCalls[0]?.body?.agent + expect(dispatchedAgent).toBe("Hephaestus - Deep Agent") + expect(getSessionAgent(sessionID)).toBe(dispatchedAgent) + }) }) describe("background-agent spawner tmux callback ordering", () => { diff --git a/src/features/background-agent/spawner.ts b/src/features/background-agent/spawner.ts index b61fe5e04..cb2961aea 100644 --- a/src/features/background-agent/spawner.ts +++ b/src/features/background-agent/spawner.ts @@ -123,9 +123,10 @@ export async function startTask( } const sessionID = createResult.data.id + const normalizedAgent = stripAgentListSortPrefix(input.agent) await input.onSessionCreated?.(sessionID) subagentSessions.add(sessionID) - setSessionAgent(sessionID, input.agent) + setSessionAgent(sessionID, normalizedAgent) task.status = "running" task.startedAt = new Date() @@ -137,7 +138,7 @@ export async function startTask( task.concurrencyKey = concurrencyKey task.concurrencyGroup = concurrencyKey - log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent }) + log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: normalizedAgent }) const toastManager = getTaskToastManager() if (toastManager) { @@ -146,7 +147,7 @@ export async function startTask( log("[background-agent] Calling prompt (fire-and-forget) for launch with:", { sessionID, - agent: input.agent, + agent: normalizedAgent, model: input.model, hasSkillContent: !!input.skillContent, promptLength: input.prompt.length, @@ -159,7 +160,6 @@ export async function startTask( } : undefined const launchVariant = input.model?.variant - const normalizedAgent = stripAgentListSortPrefix(input.agent) applySessionPromptParams(sessionID, input.model)