From d44cd1c1a08c1ded2210896847ee3deaeef4f090 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 18 May 2026 14:05:01 +0900 Subject: [PATCH] fix(background-agent): preserve parent agent on retry wakes Background fallback retry notifications were queued as bare internal user messages, so OpenCode could treat the notification as a new default-agent turn. Reuse the same parent prompt context resolver used by completion notifications for retrying and retry-ready wakes, and pin regression coverage for Hephaestus parent sessions plus missing-context fallbacks. --- src/features/background-agent/manager.test.ts | 162 +++++++++++++++++- src/features/background-agent/manager.ts | 151 ++++++++-------- .../parent-wake-user-message-race.test.ts | 51 +++++- 3 files changed, 287 insertions(+), 77 deletions(-) diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 34ebe2420..761cf7866 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -697,6 +697,19 @@ describe("BackgroundManager retry observability", () => { //#given const client = { session: { + messages: async () => [ + { + info: { + agent: "hephaestus", + model: { + providerID: "openai", + modelID: "gpt-5", + variant: "xhigh", + }, + tools: { bash: "allow", edit: "deny" }, + }, + }, + ], abort: async () => ({}), }, } @@ -749,7 +762,12 @@ describe("BackgroundManager retry observability", () => { } const [sessionID, notification, promptContext, shouldReply] = retryingCall expect(sessionID).toBe("parent-session") - expect(promptContext).toEqual({}) + expect(promptContext).toEqual({ + agent: "hephaestus", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "xhigh", + tools: { bash: true, edit: false }, + }) expect(shouldReply).toBe(false) expect(notification).toContain("[BACKGROUND TASK RETRYING]") expect(notification).toContain("ses_retry_visibility") @@ -757,6 +775,123 @@ describe("BackgroundManager retry observability", () => { expect(notification).toContain("anthropic/claude-haiku-4.5") }) + test("falls back to task parent agent when retrying wake cannot load parent messages", async () => { + //#given + const client = { + session: { + messages: async () => { + throw new Error("parent messages unavailable") + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + const task = createMockTask({ + id: "bg_retry_parent_agent_fallback", + parentSessionId: "parent-session-agent-fallback", + parentAgent: "hephaestus", + parentTools: { bash: true }, + fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }], + attemptCount: 0, + status: "running", + attempts: [ + { + attemptId: "att_retry_parent_agent_fallback", + attemptNumber: 1, + sessionId: "ses_retry_parent_agent_fallback", + providerId: "genai-proxy-openai", + modelId: "gpt-5.4-mini", + status: "running", + }, + ], + currentAttemptID: "att_retry_parent_agent_fallback", + }) + getTaskMap(manager).set(task.id, task) + const queuePendingParentWake = mock(() => {}) + ;(cast<{ + queuePendingParentWake: ( + sessionId: string, + notification: string, + promptContext: Record, + shouldReply: boolean, + delayMs?: number, + ) => void + }>(manager)).queuePendingParentWake = queuePendingParentWake + + //#when + await (cast<{ + tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise + }>(manager)).tryFallbackRetry(task, { + name: "APIError", + message: "Forbidden: Selected provider is forbidden", + }, "promptAsync.launch") + + //#then + const retryingCall = cast, boolean]>>( + queuePendingParentWake.mock.calls, + )[0] + expect(retryingCall?.[2]).toEqual({ + agent: "hephaestus", + tools: { bash: true }, + }) + }) + + test("does not invent a parent agent when retrying wake has no context source", async () => { + //#given + const client = { + session: { + messages: async () => { + throw new Error("parent messages unavailable") + }, + abort: async () => ({}), + }, + } + const manager = new BackgroundManager({ pluginContext: createPluginInput(client) }) + const task = createMockTask({ + id: "bg_retry_no_parent_context", + parentSessionId: "parent-session-no-context", + fallbackChain: [{ model: "claude-haiku-4-5", providers: ["anthropic"] }], + attemptCount: 0, + status: "running", + attempts: [ + { + attemptId: "att_retry_no_parent_context", + attemptNumber: 1, + sessionId: "ses_retry_no_parent_context", + providerId: "genai-proxy-openai", + modelId: "gpt-5.4-mini", + status: "running", + }, + ], + currentAttemptID: "att_retry_no_parent_context", + }) + getTaskMap(manager).set(task.id, task) + const queuePendingParentWake = mock(() => {}) + ;(cast<{ + queuePendingParentWake: ( + sessionId: string, + notification: string, + promptContext: Record, + shouldReply: boolean, + delayMs?: number, + ) => void + }>(manager)).queuePendingParentWake = queuePendingParentWake + + //#when + await (cast<{ + tryFallbackRetry: (task: BackgroundTask, errorInfo: { name?: string; message?: string }, source: string) => Promise + }>(manager)).tryFallbackRetry(task, { + name: "APIError", + message: "Forbidden: Selected provider is forbidden", + }, "promptAsync.launch") + + //#then + const retryingCall = cast, boolean]>>( + queuePendingParentWake.mock.calls, + )[0] + expect(retryingCall?.[2]).toEqual({}) + }) + test("queues a second parent-visible notification once the retry session ID is created", async () => { //#given const queuePendingParentWake = mock(() => {}) @@ -764,6 +899,19 @@ describe("BackgroundManager retry observability", () => { session: { get: async () => ({ data: { directory: tmpdir() } }), create: async () => ({ data: { id: "ses_retry_created" } }), + messages: async () => [ + { + info: { + agent: "hephaestus", + model: { + providerID: "openai", + modelID: "gpt-5", + variant: "xhigh", + }, + tools: { bash: "allow", edit: "deny" }, + }, + }, + ], promptAsync: async () => ({}), }, } @@ -837,12 +985,18 @@ describe("BackgroundManager retry observability", () => { }>(manager)).startTask(item) //#then - const notifications = cast, boolean, number | undefined]>>( + const retryReadyCall = cast, boolean, number | undefined]>>( queuePendingParentWake.mock.calls, - ).map((call) => call[1]) - const retryReadyNotification = notifications.find((notification) => notification.includes("[BACKGROUND TASK RETRY SESSION READY]")) + ).find((call) => call[1].includes("[BACKGROUND TASK RETRY SESSION READY]")) + const retryReadyNotification = retryReadyCall?.[1] const expectedRetryLink = `http://127.0.0.1:4096/${Buffer.from(tmpdir()).toString("base64url")}/session/ses_retry_created` expect(retryReadyNotification).toBeDefined() + expect(retryReadyCall?.[2]).toEqual({ + agent: "hephaestus", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "xhigh", + tools: { bash: true, edit: false }, + }) expect(retryReadyNotification).toContain("**Retry attempt:** 2") expect(retryReadyNotification).toContain("ses_retry_created") expect(retryReadyNotification).toContain(expectedRetryLink) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 902b0b986..064d5fa6f 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -818,6 +818,7 @@ export class BackgroundManager { ? `\n- Error: ${failedError}` : "" const retryModel = formatAttemptModelSummary(boundAttempt) ?? task.retryNotification.nextModel + const parentPromptContext = await this.resolveParentWakePromptContext(task) this.queuePendingParentWake( task.parentSessionId, ` @@ -830,7 +831,7 @@ export class BackgroundManager { The fallback retry session is now created and can be inspected directly. `, - {}, + parentPromptContext, false, PENDING_PARENT_WAKE_DEBOUNCE_MS, ) @@ -1920,6 +1921,7 @@ The fallback retry session is now created and can be inspected directly. source: string, ): Promise { const previousSessionID = task.sessionId + let retryingNotification: string | undefined const result = tryFallbackRetry({ task, errorInfo, @@ -1938,22 +1940,26 @@ The fallback retry session is now created and can be inspected directly. const failedModelLine = failedModel ? `\n- Failed model: \`${failedModel}\`` : "" const failedErrorLine = previousAttempt?.error ? `\n- Error: ${previousAttempt.error}` : "" const nextModel = formatAttemptModelSummary(currentAttempt) - this.queuePendingParentWake( - task.parentSessionId, - ` + retryingNotification = ` [BACKGROUND TASK RETRYING] **ID:** \`${task.id}\` **Description:** ${task.description}${sourceText}${failedSessionLine}${failedModelLine}${failedErrorLine}${nextModel ? `\n- Next model: \`${nextModel}\`` : ""} The task was re-queued on a fallback model after a retryable failure. -`, - {}, - false, - PENDING_PARENT_WAKE_DEBOUNCE_MS, - ) +` }, }) const retried = await result + if (retried && retryingNotification) { + const parentPromptContext = await this.resolveParentWakePromptContext(task) + this.queuePendingParentWake( + task.parentSessionId, + retryingNotification, + parentPromptContext, + false, + PENDING_PARENT_WAKE_DEBOUNCE_MS, + ) + } if (retried && previousSessionID) { this.clearSessionOutputObserved(previousSessionID) this.clearSessionTodoObservation(previousSessionID) @@ -2412,76 +2418,18 @@ The task was re-queued on a fallback model after a retryable failure. completedTasks, }) - let agent: string | undefined = task.parentAgent - let model: { providerID: string; modelID: string } | undefined - let tools: Record | undefined = task.parentTools - let promptContext: ReturnType = null - if (this.enableParentSessionNotifications) { - try { - const messagesResp = await messagesInDirectory(this.client, { - path: { id: task.parentSessionId }, - }, this.directory) - const messages = normalizeSDKResponse(messagesResp, [] as Array<{ - info?: { - agent?: string - model?: { providerID: string; modelID: string } - modelID?: string - providerID?: string - tools?: Record - } - }>) - promptContext = resolvePromptContextFromSessionMessages( - messages, - task.parentSessionId, - ) - const normalizedTools = isRecord(promptContext?.tools) - ? normalizePromptTools(promptContext.tools) - : undefined - - if (promptContext?.agent || promptContext?.model || normalizedTools) { - agent = promptContext?.agent ?? task.parentAgent - model = promptContext?.model?.providerID && promptContext.model.modelID - ? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID } - : undefined - tools = normalizedTools ?? tools - } - } catch (error) { - if (isAbortedSessionError(error)) { - log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", { - taskId: task.id, - parentSessionID: task.parentSessionId, - }) - } - const messageDir = join(MESSAGE_STORAGE, task.parentSessionId) - const currentMessage = messageDir - ? findNearestMessageExcludingCompaction(messageDir, task.parentSessionId) - : null - agent = currentMessage?.agent ?? task.parentAgent - model = currentMessage?.model?.providerID && currentMessage?.model?.modelID - ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } - : undefined - tools = normalizePromptTools(currentMessage?.tools) ?? tools - } - - const resolvedTools = resolveInheritedPromptTools(task.parentSessionId, tools) + const parentPromptContext = await this.resolveParentWakePromptContext(task) log("[background-agent] notifyParentSession context:", { taskId: task.id, - resolvedAgent: agent, - resolvedModel: model, + resolvedAgent: parentPromptContext.agent, + resolvedModel: parentPromptContext.model, }) const isTaskFailure = task.status === "error" || task.status === "cancelled" || task.status === "interrupt" const shouldReply = allComplete || isTaskFailure - const variant = promptContext?.model?.variant - const parentPromptContext: ParentWakePromptContext = { - ...(agent !== undefined ? { agent } : {}), - ...(model !== undefined ? { model } : {}), - ...(variant !== undefined ? { variant } : {}), - ...(resolvedTools ? { tools: resolvedTools } : {}), - } const shouldDeferNotification = await this.isSessionActive(task.parentSessionId) if (shouldDeferNotification) { @@ -2519,6 +2467,69 @@ The task was re-queued on a fallback model after a retryable failure. } } + private async resolveParentWakePromptContext(task: BackgroundTask): Promise { + let agent: string | undefined = task.parentAgent + let model: { providerID: string; modelID: string } | undefined + let tools: Record | undefined = task.parentTools + let variant: string | undefined + + try { + const messagesResp = await messagesInDirectory(this.client, { + path: { id: task.parentSessionId }, + }, this.directory) + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ + info?: { + agent?: string + model?: { providerID: string; modelID: string; variant?: string } + modelID?: string + providerID?: string + tools?: Record + } + }>) + const promptContext = resolvePromptContextFromSessionMessages( + messages, + task.parentSessionId, + ) + const normalizedTools = isRecord(promptContext?.tools) + ? normalizePromptTools(promptContext.tools) + : undefined + + if (promptContext?.agent || promptContext?.model || normalizedTools) { + agent = promptContext?.agent ?? task.parentAgent + model = promptContext?.model?.providerID && promptContext.model.modelID + ? { providerID: promptContext.model.providerID, modelID: promptContext.model.modelID } + : undefined + variant = promptContext?.model?.variant + tools = normalizedTools ?? tools + } + } catch (error) { + if (isAbortedSessionError(error)) { + log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", { + taskId: task.id, + parentSessionID: task.parentSessionId, + }) + } + const messageDir = join(MESSAGE_STORAGE, task.parentSessionId) + const currentMessage = messageDir + ? findNearestMessageExcludingCompaction(messageDir, task.parentSessionId) + : null + agent = currentMessage?.agent ?? task.parentAgent + model = currentMessage?.model?.providerID && currentMessage?.model?.modelID + ? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID } + : undefined + variant = currentMessage?.model?.variant + tools = normalizePromptTools(currentMessage?.tools) ?? tools + } + + const resolvedTools = resolveInheritedPromptTools(task.parentSessionId, tools) + return { + ...(agent !== undefined ? { agent } : {}), + ...(model !== undefined ? { model } : {}), + ...(variant !== undefined ? { variant } : {}), + ...(resolvedTools ? { tools: resolvedTools } : {}), + } + } + private async isSessionActive(sessionID: string): Promise { return isOpenCodeSessionActive(this.client, sessionID) } diff --git a/src/features/background-agent/parent-wake-user-message-race.test.ts b/src/features/background-agent/parent-wake-user-message-race.test.ts index 9309f43c5..0729e9164 100644 --- a/src/features/background-agent/parent-wake-user-message-race.test.ts +++ b/src/features/background-agent/parent-wake-user-message-race.test.ts @@ -6,6 +6,10 @@ type PromptAsyncCall = { path: { id: string } body: { noReply?: boolean + agent?: string + model?: { providerID: string; modelID: string } + variant?: string + tools?: Record parts?: unknown[] } query?: { @@ -40,9 +44,7 @@ function createNotifier(args: { }, abort: async () => ({ data: {} }), }, - } as unknown as Parameters[0] extends never - ? never - : ConstructorParameters[0]["client"] + } as unknown as ConstructorParameters[0]["client"] const notifier = new ParentWakeNotifier( { @@ -139,6 +141,49 @@ describe("ParentWakeNotifier — user message race guard (issue #4120)", () => { releaseAllPromptAsyncReservationsForTesting() }) + test("#given pending wake has parent prompt context #when flushing #then promptAsync receives the context", async () => { + // given + const { notifier, promptAsyncCalls } = createNotifier({ + sessionMessages: [ + { + info: { + role: "assistant", + finish: "stop", + time: { created: Date.now() - 100 }, + }, + }, + ], + }) + notifier.queuePendingParentWake( + "parent-context", + "task retrying", + { + agent: "hephaestus", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "xhigh", + tools: { bash: true, edit: false }, + }, + false, + ) + + // when + await notifier.flushPendingParentWake("parent-context") + + // then + expect(promptAsyncCalls).toHaveLength(1) + expect(promptAsyncCalls[0]?.body).toMatchObject({ + noReply: true, + agent: "hephaestus", + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "xhigh", + tools: { bash: true, edit: false }, + }) + expect(promptAsyncCalls[0]?.body.parts).toHaveLength(1) + + notifier.shutdown() + releaseAllPromptAsyncReservationsForTesting() + }) + test("#given user message is older than the race window #when flushing pending wake #then dispatch proceeds", async () => { // given const { notifier, promptAsyncCalls } = createNotifier({