fix: enable runtime fallback for delegated child sessions (#2357)

This commit is contained in:
YeonGyu-Kim
2026-03-12 01:43:17 +09:00
parent 4ded45d14c
commit ba86ef0eea
14 changed files with 630 additions and 211 deletions
+184 -1
View File
@@ -1,4 +1,5 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
declare const require: (name: string) => any
const { describe, expect, test, beforeEach, afterEach, spyOn } = require("bun:test")
import { createRuntimeFallbackHook } from "./index"
import type { RuntimeFallbackConfig, OhMyOpenCodeConfig } from "../../config"
import * as sharedModule from "../../shared"
@@ -72,6 +73,23 @@ describe("runtime-fallback", () => {
}
}
function createMockPluginConfigWithCategoryModel(
categoryName: string,
model: string,
fallbackModels: string[],
variant?: string,
): OhMyOpenCodeConfig {
return {
categories: {
[categoryName]: {
model,
fallback_models: fallbackModels,
...(variant ? { variant } : {}),
},
},
}
}
describe("session.error handling", () => {
test("should detect retryable error with status code 429", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), { config: createMockConfig() })
@@ -310,6 +328,114 @@ describe("runtime-fallback", () => {
expect(nonRetryLog).toBeUndefined()
})
test("should continue fallback chain when ProviderModelNotFoundError occurs", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback([
"anthropic/claude-opus-4.6",
"openai/gpt-5.4",
]),
})
const sessionID = "test-session-provider-model-not-found"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "google/gemini-2.5-pro" } },
},
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "AI_LoadAPIKeyError",
message:
"Google Generative AI API key is missing. Pass it using the 'apiKey' parameter or the GOOGLE_GENERATIVE_AI_API_KEY environment variable.",
},
},
},
})
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: {
name: "ProviderModelNotFoundError",
data: {
providerID: "anthropic",
modelID: "claude-opus-4.6",
message: "Model not found: anthropic/claude-opus-4.6.",
},
},
},
},
})
const fallbackLogs = logCalls.filter((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLogs.length).toBeGreaterThanOrEqual(2)
expect(fallbackLogs[1]?.data).toMatchObject({ from: "anthropic/claude-opus-4.6", to: "openai/gpt-5.4" })
})
test("should bootstrap session.error fallback from session category model and preserve variant", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
}),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"quick",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4(high)"],
),
},
)
const sessionID = "test-session-category-bootstrap-session-error"
SessionCategoryRegistry.register(sessionID, "quick")
await hook.event({
event: {
type: "session.error",
properties: {
sessionID,
error: { statusCode: 429, message: "Rate limit exceeded" },
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string }
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
const bootstrapLog = logCalls.find((call) =>
call.msg.includes("Derived model from session category config for session.error"),
)
expect(bootstrapLog?.data).toMatchObject({
sessionID,
category: "quick",
model: "anthropic/claude-haiku-4-5",
})
})
test("should trigger fallback on Copilot auto-retry signal in message.updated", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
@@ -905,6 +1031,63 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "google/gemini-2.5-pro", to: "openai/gpt-5.4" })
})
test("should bootstrap message.updated fallback from session category model and preserve variant", async () => {
const promptCalls: Array<Record<string, unknown>> = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [{ info: { role: "user" }, parts: [{ type: "text", text: "continue" }] }],
}),
promptAsync: async (args) => {
promptCalls.push(args as Record<string, unknown>)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryModel(
"quick",
"anthropic/claude-haiku-4-5",
["openai/gpt-5.4(high)"],
),
},
)
const sessionID = "test-session-category-bootstrap-message-updated"
SessionCategoryRegistry.register(sessionID, "quick")
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
error: { statusCode: 429, message: "Rate limit exceeded" },
},
},
},
})
expect(promptCalls).toHaveLength(1)
const promptBody = promptCalls[0]?.body as {
model?: { providerID?: string; modelID?: string }
variant?: string
} | undefined
expect(promptBody?.model).toEqual({ providerID: "openai", modelID: "gpt-5.4" })
expect(promptBody?.variant).toBe("high")
const bootstrapLog = logCalls.find((call) =>
call.msg.includes("Derived model from session category config for message.updated"),
)
expect(bootstrapLog?.data).toMatchObject({
sessionID,
category: "quick",
model: "anthropic/claude-haiku-4-5",
})
})
test("should not advance fallback state from message.updated while retry is already in flight", async () => {
const pending = new Promise<never>(() => {})