Merge pull request #4453 from code-yeongyu/fix-4419-atlas-subagent-fallback
fix(background-agent): atlas subagent fallback retry on retryable model errors (#4419)
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { _resetMemCacheForTesting as resetConnectedProvidersCacheForTesting } from "../../shared/connected-providers-cache"
|
||||
import { releaseAllPromptAsyncReservationsForTesting } from "../../shared/prompt-async-gate"
|
||||
import {
|
||||
getSessionAgent,
|
||||
_resetForTesting as resetClaudeCodeSessionState,
|
||||
subagentSessions,
|
||||
} from "../claude-code-session-state"
|
||||
import { BackgroundManager } from "./manager"
|
||||
import { clearBackgroundTaskRegistryForTesting } from "./task-registry"
|
||||
|
||||
type SessionGetArgs = { readonly path: { readonly id: string } }
|
||||
type SessionCreateArgs = {
|
||||
readonly body?: {
|
||||
readonly parentID?: string
|
||||
readonly model?: { readonly providerID?: string; readonly id?: string; readonly variant?: string }
|
||||
}
|
||||
}
|
||||
type PromptCall = { readonly path: { readonly id: string }; readonly body?: unknown }
|
||||
|
||||
const originalXdgCacheHome = process.env.XDG_CACHE_HOME
|
||||
const testDirectory = "/tmp/omo-atlas-fallback-test"
|
||||
let cacheCounter = 0
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.XDG_CACHE_HOME = `${testDirectory}/cache-${cacheCounter}`
|
||||
cacheCounter += 1
|
||||
resetConnectedProvidersCacheForTesting()
|
||||
resetClaudeCodeSessionState()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalXdgCacheHome === undefined) {
|
||||
delete process.env.XDG_CACHE_HOME
|
||||
} else {
|
||||
process.env.XDG_CACHE_HOME = originalXdgCacheHome
|
||||
}
|
||||
resetConnectedProvidersCacheForTesting()
|
||||
resetClaudeCodeSessionState()
|
||||
clearBackgroundTaskRegistryForTesting()
|
||||
releaseAllPromptAsyncReservationsForTesting()
|
||||
})
|
||||
|
||||
function createPluginInput(client: unknown, directory: string): PluginInput {
|
||||
return { client, directory } as PluginInput
|
||||
}
|
||||
|
||||
async function flushAsyncWork(cycles = 30): Promise<void> {
|
||||
for (let index = 0; index < cycles; index++) {
|
||||
await Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
function createAtlasHarness(): {
|
||||
readonly manager: BackgroundManager
|
||||
readonly createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }>
|
||||
readonly promptCalls: PromptCall[]
|
||||
readonly markSessionMissing: (sessionID: string) => void
|
||||
} {
|
||||
const directory = testDirectory
|
||||
const sessionAlive = new Map<string, boolean>([["atlas-parent", true]])
|
||||
const createdSessions: Array<{ readonly id: string; readonly body: SessionCreateArgs["body"] }> = []
|
||||
const promptCalls: PromptCall[] = []
|
||||
const sessionIDs = ["ses_primary", "ses_fallback"]
|
||||
|
||||
const client = {
|
||||
session: {
|
||||
get: async ({ path }: SessionGetArgs) => {
|
||||
if (path.id === "atlas-parent") {
|
||||
return { data: { id: path.id, directory, parentID: undefined } }
|
||||
}
|
||||
if (sessionAlive.get(path.id)) {
|
||||
return { data: { id: path.id, directory, parentID: "atlas-parent" } }
|
||||
}
|
||||
return { error: { status: 404, message: `session ${path.id} not found` } }
|
||||
},
|
||||
create: async (args: SessionCreateArgs) => {
|
||||
const id = sessionIDs[createdSessions.length] ?? `ses_extra_${createdSessions.length}`
|
||||
createdSessions.push({ id, body: args.body })
|
||||
sessionAlive.set(id, true)
|
||||
return { data: { id } }
|
||||
},
|
||||
promptAsync: async (args: PromptCall) => {
|
||||
promptCalls.push(args)
|
||||
return {}
|
||||
},
|
||||
abort: async ({ path }: SessionGetArgs) => {
|
||||
sessionAlive.set(path.id, false)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ pluginContext: createPluginInput(client, directory) })
|
||||
|
||||
return {
|
||||
manager,
|
||||
createdSessions,
|
||||
promptCalls,
|
||||
markSessionMissing: (sessionID: string) => sessionAlive.set(sessionID, false),
|
||||
}
|
||||
}
|
||||
|
||||
async function launchAtlasOracleSubagent(manager: BackgroundManager): Promise<string> {
|
||||
const task = await manager.launch({
|
||||
description: "Atlas oracle subagent",
|
||||
prompt: "Investigate fallback behavior",
|
||||
agent: "oracle",
|
||||
parentSessionId: "atlas-parent",
|
||||
parentMessageId: "atlas-message",
|
||||
parentAgent: "atlas",
|
||||
model: { providerID: "openai", modelID: "gpt-5.5", variant: "high" },
|
||||
fallbackChain: [
|
||||
{ providers: ["github-copilot"], model: "claude-sonnet-4.6", variant: "high" },
|
||||
],
|
||||
})
|
||||
await flushAsyncWork()
|
||||
return task.id
|
||||
}
|
||||
|
||||
function emitUsageLimitError(manager: BackgroundManager, sessionID: string): void {
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: {
|
||||
name: "AI_APICallError",
|
||||
data: {
|
||||
error: {
|
||||
type: "usage_limit_reached",
|
||||
message: "The usage limit has been reached",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("Atlas-spawned subagent runtime fallback", () => {
|
||||
test("retries oracle subagent on OpenAI usage_limit_reached and registers the fallback session", async () => {
|
||||
//#given
|
||||
const { manager, createdSessions, promptCalls } = createAtlasHarness()
|
||||
const taskID = await launchAtlasOracleSubagent(manager)
|
||||
|
||||
//#when
|
||||
emitUsageLimitError(manager, "ses_primary")
|
||||
await flushAsyncWork(60)
|
||||
|
||||
//#then
|
||||
const task = manager.getTask(taskID)
|
||||
expect(task?.status).toBe("running")
|
||||
expect(task?.sessionId).toBe("ses_fallback")
|
||||
expect(task?.model).toEqual({ providerID: "github-copilot", modelID: "claude-sonnet-4.6", variant: "high" })
|
||||
expect(task?.attemptCount).toBe(1)
|
||||
expect(createdSessions).toHaveLength(2)
|
||||
expect(createdSessions[1]?.body?.model).toEqual({ providerID: "github-copilot", id: "claude-sonnet-4.6", variant: "high" })
|
||||
expect(promptCalls).toHaveLength(2)
|
||||
expect(subagentSessions.has("ses_primary")).toBe(false)
|
||||
expect(subagentSessions.has("ses_fallback")).toBe(true)
|
||||
expect(getSessionAgent("ses_fallback")).toBe("oracle")
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("surfaces non-retryable oracle subagent errors without creating a fallback session", async () => {
|
||||
//#given
|
||||
const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
|
||||
const taskID = await launchAtlasOracleSubagent(manager)
|
||||
markSessionMissing("ses_primary")
|
||||
|
||||
//#when
|
||||
manager.handleEvent({
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "ses_primary",
|
||||
error: { name: "PermissionDeniedError", data: { message: "permission denied" } },
|
||||
},
|
||||
})
|
||||
await flushAsyncWork(60)
|
||||
|
||||
//#then
|
||||
const task = manager.getTask(taskID)
|
||||
expect(task?.status).toBe("error")
|
||||
expect(task?.error).toBe("permission denied")
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("marks oracle subagent errored when usage_limit_reached exhausts all fallbacks", async () => {
|
||||
//#given
|
||||
const { manager, createdSessions, markSessionMissing } = createAtlasHarness()
|
||||
const taskID = await launchAtlasOracleSubagent(manager)
|
||||
emitUsageLimitError(manager, "ses_primary")
|
||||
await flushAsyncWork(60)
|
||||
markSessionMissing("ses_fallback")
|
||||
|
||||
//#when
|
||||
emitUsageLimitError(manager, "ses_fallback")
|
||||
await flushAsyncWork(60)
|
||||
|
||||
//#then
|
||||
const task = manager.getTask(taskID)
|
||||
expect(task?.status).toBe("error")
|
||||
expect(task?.error).toBe("The usage limit has been reached")
|
||||
expect(task?.attemptCount).toBe(1)
|
||||
expect(createdSessions).toHaveLength(2)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
@@ -104,6 +104,15 @@ export function getSessionErrorMessage(properties: EventPropertiesLike): string
|
||||
if (isRecord(dataRaw)) {
|
||||
const message = dataRaw["message"]
|
||||
if (typeof message === "string") return message
|
||||
|
||||
const nestedError = dataRaw["error"]
|
||||
if (isRecord(nestedError)) {
|
||||
const nestedMessage = nestedError["message"]
|
||||
if (typeof nestedMessage === "string") return nestedMessage
|
||||
|
||||
const nestedType = nestedError["type"]
|
||||
if (typeof nestedType === "string") return nestedType
|
||||
}
|
||||
}
|
||||
|
||||
const message = errorRaw["message"]
|
||||
|
||||
Reference in New Issue
Block a user