refactor(model-fallback): fully encapsulate session state in factory closure
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder"
|
|||||||
import type { HookName, OhMyOpenCodeConfig } from "./config"
|
import type { HookName, OhMyOpenCodeConfig } from "./config"
|
||||||
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
import type { LoadedSkill } from "./features/opencode-skill-loader/types"
|
||||||
import type { BackgroundManager } from "./features/background-agent"
|
import type { BackgroundManager } from "./features/background-agent"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
import type { PluginContext } from "./plugin/types"
|
import type { PluginContext } from "./plugin/types"
|
||||||
import type { ModelCacheState } from "./plugin-state"
|
import type { ModelCacheState } from "./plugin-state"
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export function createHooks(args: {
|
|||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
backgroundManager: BackgroundManager
|
backgroundManager: BackgroundManager
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
mergedSkills: LoadedSkill[]
|
mergedSkills: LoadedSkill[]
|
||||||
@@ -46,6 +48,7 @@ export function createHooks(args: {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
mergedSkills,
|
mergedSkills,
|
||||||
@@ -56,6 +59,7 @@ export function createHooks(args: {
|
|||||||
ctx,
|
ctx,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PluginContext, TmuxConfig } from "./plugin/types"
|
|||||||
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
|
import type { SubagentSessionCreatedEvent } from "./features/background-agent"
|
||||||
import { BackgroundManager } from "./features/background-agent"
|
import { BackgroundManager } from "./features/background-agent"
|
||||||
import { SkillMcpManager } from "./features/skill-mcp-manager"
|
import { SkillMcpManager } from "./features/skill-mcp-manager"
|
||||||
|
import { createModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
import { initTaskToastManager } from "./features/task-toast-manager"
|
import { initTaskToastManager } from "./features/task-toast-manager"
|
||||||
import { TmuxSessionManager } from "./features/tmux-subagent"
|
import { TmuxSessionManager } from "./features/tmux-subagent"
|
||||||
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
|
import * as openclawRuntimeDispatch from "./openclaw/runtime-dispatch"
|
||||||
@@ -12,6 +13,7 @@ import { registerManagerForCleanup } from "./features/background-agent/process-c
|
|||||||
import { createConfigHandler } from "./plugin-handlers"
|
import { createConfigHandler } from "./plugin-handlers"
|
||||||
import { log } from "./shared"
|
import { log } from "./shared"
|
||||||
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
|
import { markServerRunningInProcess } from "./shared/tmux/tmux-utils/server-health"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./hooks/model-fallback"
|
||||||
|
|
||||||
type CreateManagersDeps = {
|
type CreateManagersDeps = {
|
||||||
BackgroundManagerClass: typeof BackgroundManager
|
BackgroundManagerClass: typeof BackgroundManager
|
||||||
@@ -38,6 +40,7 @@ export type Managers = {
|
|||||||
backgroundManager: BackgroundManager
|
backgroundManager: BackgroundManager
|
||||||
skillMcpManager: SkillMcpManager
|
skillMcpManager: SkillMcpManager
|
||||||
configHandler: ReturnType<typeof createConfigHandler>
|
configHandler: ReturnType<typeof createConfigHandler>
|
||||||
|
modelFallbackControllerAccessor: ModelFallbackControllerAccessor
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createManagers(args: {
|
export function createManagers(args: {
|
||||||
@@ -119,11 +122,13 @@ export function createManagers(args: {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
})
|
})
|
||||||
|
const modelFallbackControllerAccessor = createModelFallbackControllerAccessor()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tmuxSessionManager,
|
tmuxSessionManager,
|
||||||
backgroundManager,
|
backgroundManager,
|
||||||
skillMcpManager,
|
skillMcpManager,
|
||||||
configHandler,
|
configHandler,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ type CreateToolsResult = {
|
|||||||
export async function createTools(args: {
|
export async function createTools(args: {
|
||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
|
||||||
}): Promise<CreateToolsResult> {
|
}): Promise<CreateToolsResult> {
|
||||||
const { ctx, pluginConfig, managers } = args
|
const { ctx, pluginConfig, managers } = args
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -14,7 +14,13 @@ export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detec
|
|||||||
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
||||||
|
|
||||||
export { createThinkModeHook } from "./think-mode";
|
export { createThinkModeHook } from "./think-mode";
|
||||||
export { createModelFallbackHook, setPendingModelFallback, clearPendingModelFallback, type ModelFallbackState } from "./model-fallback/hook";
|
export {
|
||||||
|
createModelFallbackHook,
|
||||||
|
setPendingModelFallback,
|
||||||
|
clearPendingModelFallback,
|
||||||
|
type ModelFallbackHook,
|
||||||
|
type ModelFallbackState,
|
||||||
|
} from "./model-fallback/hook";
|
||||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||||
export { createRulesInjectorHook } from "./rules-injector";
|
export { createRulesInjectorHook } from "./rules-injector";
|
||||||
export { createBackgroundNotificationHook } from "./background-notification"
|
export { createBackgroundNotificationHook } from "./background-notification"
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import type { ModelFallbackStateController } from "./fallback-state-controller"
|
||||||
|
|
||||||
|
export type ModelFallbackControllerAccessor = {
|
||||||
|
register: (controller: ModelFallbackStateController) => void
|
||||||
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||||
|
clearSessionFallbackChain: (sessionID: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModelFallbackControllerAccessor(): ModelFallbackControllerAccessor {
|
||||||
|
let controller: ModelFallbackStateController | null = null
|
||||||
|
|
||||||
|
function register(nextController: ModelFallbackStateController): void {
|
||||||
|
controller = nextController
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
||||||
|
controller?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSessionFallbackChain(sessionID: string): void {
|
||||||
|
controller?.clearSessionFallbackChain(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
register,
|
||||||
|
setSessionFallbackChain,
|
||||||
|
clearSessionFallbackChain,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,22 +70,23 @@ const {
|
|||||||
setPendingModelFallback,
|
setPendingModelFallback,
|
||||||
} = await importFreshModelFallbackHookModule()
|
} = await importFreshModelFallbackHookModule()
|
||||||
|
|
||||||
|
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
|
||||||
|
|
||||||
describe("model fallback hook", () => {
|
describe("model fallback hook", () => {
|
||||||
|
let modelFallback: ModelFallbackHook
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
modelFallback = createModelFallbackHook()
|
||||||
readConnectedProvidersCacheMock.mockReturnValue(null)
|
readConnectedProvidersCacheMock.mockReturnValue(null)
|
||||||
readProviderModelsCacheMock.mockReturnValue(null)
|
readProviderModelsCacheMock.mockReturnValue(null)
|
||||||
readConnectedProvidersCacheMock.mockClear()
|
readConnectedProvidersCacheMock.mockClear()
|
||||||
readProviderModelsCacheMock.mockClear()
|
readProviderModelsCacheMock.mockClear()
|
||||||
selectFallbackProviderMock.mockClear()
|
selectFallbackProviderMock.mockClear()
|
||||||
|
|
||||||
clearPendingModelFallback("ses_model_fallback_main")
|
|
||||||
clearPendingModelFallback("ses_model_fallback_ghcp")
|
|
||||||
clearPendingModelFallback("ses_model_fallback_google")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("applies pending fallback on chat.message by overriding model", async () => {
|
test("applies pending fallback on chat.message by overriding model", async () => {
|
||||||
//#given
|
//#given
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -93,6 +94,7 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
"ses_model_fallback_main",
|
"ses_model_fallback_main",
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -123,7 +125,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
test("preserves fallback progression across repeated session.error retries", async () => {
|
test("preserves fallback progression across repeated session.error retries", async () => {
|
||||||
//#given
|
//#given
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -132,7 +134,7 @@ describe("model fallback hook", () => {
|
|||||||
const sessionID = "ses_model_fallback_main"
|
const sessionID = "ses_model_fallback_main"
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
|
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
const firstOutput = {
|
const firstOutput = {
|
||||||
@@ -154,7 +156,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
//#when - second error re-arms fallback and should advance to next entry
|
//#when - second error re-arms fallback and should advance to next entry
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
|
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
|
|
||||||
const secondOutput = {
|
const secondOutput = {
|
||||||
@@ -176,16 +178,18 @@ describe("model fallback hook", () => {
|
|||||||
test("does not re-arm fallback when one is already pending", () => {
|
test("does not re-arm fallback when one is already pending", () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_pending_guard"
|
const sessionID = "ses_model_fallback_pending_guard"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const firstSet = setPendingModelFallback(
|
const firstSet = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"claude-opus-4-7-thinking",
|
"claude-opus-4-7-thinking",
|
||||||
)
|
)
|
||||||
const secondSet = setPendingModelFallback(
|
const secondSet = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -195,28 +199,29 @@ describe("model fallback hook", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(firstSet).toBe(true)
|
expect(firstSet).toBe(true)
|
||||||
expect(secondSet).toBe(false)
|
expect(secondSet).toBe(false)
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips no-op fallback entries that resolve to same provider/model", async () => {
|
test("skips no-op fallback entries that resolve to same provider/model", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_noop_skip"
|
const sessionID = "ses_model_fallback_noop_skip"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
{ providers: ["anthropic"], model: "claude-opus-4-7" },
|
||||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -239,28 +244,29 @@ describe("model fallback hook", () => {
|
|||||||
providerID: "opencode",
|
providerID: "opencode",
|
||||||
modelID: "kimi-k2.5-free",
|
modelID: "kimi-k2.5-free",
|
||||||
})
|
})
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("skips no-op fallback entries even when variant differs", async () => {
|
test("skips no-op fallback entries even when variant differs", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_noop_variant_skip"
|
const sessionID = "ses_model_fallback_noop_variant_skip"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
|
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
|
||||||
{ providers: ["quotio"], model: "gpt-5.2" },
|
{ providers: ["quotio"], model: "gpt-5.2" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"quotio",
|
"quotio",
|
||||||
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "gpt-5.2",
|
modelID: "gpt-5.2",
|
||||||
})
|
})
|
||||||
expect(output.message["variant"]).toBeUndefined()
|
expect(output.message["variant"]).toBeUndefined()
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_preferred_provider"
|
const sessionID = "ses_model_fallback_preferred_provider"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
) => Promise<void>
|
) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["provider-y"], model: "fallback-model" },
|
{ providers: ["provider-y"], model: "fallback-model" },
|
||||||
])
|
])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
setPendingModelFallback(
|
setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"provider-x",
|
"provider-x",
|
||||||
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
|
|||||||
providerID: "provider-x",
|
providerID: "provider-x",
|
||||||
modelID: "fallback-model",
|
modelID: "fallback-model",
|
||||||
})
|
})
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
|
test("does not fall back to hardcoded agent chain when session explicitly stores no fallback chain [regression #2941]", () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_explicit_none"
|
const sessionID = "ses_model_fallback_explicit_none"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
setSessionFallbackChain(sessionID, undefined)
|
setSessionFallbackChain(modelFallback, sessionID, undefined)
|
||||||
|
|
||||||
//#when
|
//#when
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Sisyphus - Junior",
|
"Sisyphus - Junior",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
|
|||||||
|
|
||||||
//#then
|
//#then
|
||||||
expect(set).toBe(false)
|
expect(set).toBe(false)
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("shows toast when fallback is applied", async () => {
|
test("shows toast when fallback is applied", async () => {
|
||||||
@@ -366,6 +374,7 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
hook,
|
||||||
"ses_model_fallback_toast",
|
"ses_model_fallback_toast",
|
||||||
"Sisyphus - Ultraworker",
|
"Sisyphus - Ultraworker",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
|
|||||||
test("transforms model names for github-copilot provider via fallback chain", async () => {
|
test("transforms model names for github-copilot provider via fallback chain", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_ghcp"
|
const sessionID = "ses_model_fallback_ghcp"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -402,11 +411,12 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set a custom fallback chain that routes through github-copilot
|
// Set a custom fallback chain that routes through github-copilot
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
|
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
|
||||||
])
|
])
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Atlas - Plan Executor",
|
"Atlas - Plan Executor",
|
||||||
"github-copilot",
|
"github-copilot",
|
||||||
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "claude-sonnet-4.6",
|
modelID: "claude-sonnet-4.6",
|
||||||
})
|
})
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("preserves canonical google preview model names via fallback chain", async () => {
|
test("preserves canonical google preview model names via fallback chain", async () => {
|
||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_model_fallback_google"
|
const sessionID = "ses_model_fallback_google"
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const hook = createModelFallbackHook() as unknown as {
|
const hook = modelFallback as unknown as {
|
||||||
"chat.message"?: (
|
"chat.message"?: (
|
||||||
input: { sessionID: string },
|
input: { sessionID: string },
|
||||||
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
|
||||||
@@ -446,11 +456,12 @@ describe("model fallback hook", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set a custom fallback chain that routes through google
|
// Set a custom fallback chain that routes through google
|
||||||
setSessionFallbackChain(sessionID, [
|
setSessionFallbackChain(modelFallback, sessionID, [
|
||||||
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
|
||||||
])
|
])
|
||||||
|
|
||||||
const set = setPendingModelFallback(
|
const set = setPendingModelFallback(
|
||||||
|
modelFallback,
|
||||||
sessionID,
|
sessionID,
|
||||||
"Oracle",
|
"Oracle",
|
||||||
"google",
|
"google",
|
||||||
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
|
|||||||
modelID: "gemini-3.1-pro-preview",
|
modelID: "gemini-3.1-pro-preview",
|
||||||
})
|
})
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createModelFallbackStateController,
|
createModelFallbackStateController,
|
||||||
type ModelFallbackStateController,
|
type ModelFallbackStateController,
|
||||||
} from "./fallback-state-controller"
|
} from "./fallback-state-controller"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
|
|
||||||
type FallbackToast = (input: {
|
type FallbackToast = (input: {
|
||||||
title: string
|
title: string
|
||||||
@@ -28,26 +29,45 @@ export type ModelFallbackState = {
|
|||||||
pending: boolean
|
pending: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelFallbackControllerRef: { current?: ModelFallbackStateController } = {}
|
type ModelFallbackControllerWithState = Pick<
|
||||||
|
ModelFallbackStateController,
|
||||||
|
| "lastToastKey"
|
||||||
|
| "setSessionFallbackChain"
|
||||||
|
| "clearSessionFallbackChain"
|
||||||
|
| "setPendingModelFallback"
|
||||||
|
| "getNextFallback"
|
||||||
|
| "clearPendingModelFallback"
|
||||||
|
| "hasPendingModelFallback"
|
||||||
|
| "getFallbackState"
|
||||||
|
| "reset"
|
||||||
|
>
|
||||||
|
|
||||||
function getOrCreateModelFallbackController(): ModelFallbackStateController {
|
export type ModelFallbackHook = ModelFallbackControllerWithState & {
|
||||||
if (!modelFallbackControllerRef.current) {
|
"chat.message": (
|
||||||
createModelFallbackHook()
|
input: ChatMessageInput,
|
||||||
}
|
output: ChatMessageHandlerOutput,
|
||||||
|
) => Promise<void>
|
||||||
const controller = modelFallbackControllerRef.current
|
|
||||||
if (!controller) {
|
|
||||||
throw new Error("Model fallback controller should be initialized")
|
|
||||||
}
|
|
||||||
return controller
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
|
type ModelFallbackHookArgs = {
|
||||||
getOrCreateModelFallbackController().setSessionFallbackChain(sessionID, fallbackChain)
|
toast?: FallbackToast
|
||||||
|
onApplied?: FallbackCallback
|
||||||
|
controllerAccessor?: ModelFallbackControllerAccessor
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSessionFallbackChain(sessionID: string): void {
|
export function setSessionFallbackChain(
|
||||||
getOrCreateModelFallbackController().clearSessionFallbackChain(sessionID)
|
controller: Pick<ModelFallbackStateController, "setSessionFallbackChain">,
|
||||||
|
sessionID: string,
|
||||||
|
fallbackChain: FallbackEntry[] | undefined,
|
||||||
|
): void {
|
||||||
|
controller.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionFallbackChain(
|
||||||
|
controller: Pick<ModelFallbackStateController, "clearSessionFallbackChain">,
|
||||||
|
sessionID: string,
|
||||||
|
): void {
|
||||||
|
controller.clearSessionFallbackChain(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,12 +75,13 @@ export function clearSessionFallbackChain(sessionID: string): void {
|
|||||||
* Called when a model error is detected in session.error handler.
|
* Called when a model error is detected in session.error handler.
|
||||||
*/
|
*/
|
||||||
export function setPendingModelFallback(
|
export function setPendingModelFallback(
|
||||||
|
controller: Pick<ModelFallbackStateController, "setPendingModelFallback">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
currentProviderID: string,
|
currentProviderID: string,
|
||||||
currentModelID: string,
|
currentModelID: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
return getOrCreateModelFallbackController().setPendingModelFallback(
|
return controller.setPendingModelFallback(
|
||||||
sessionID,
|
sessionID,
|
||||||
agentName,
|
agentName,
|
||||||
currentProviderID,
|
currentProviderID,
|
||||||
@@ -73,54 +94,71 @@ export function setPendingModelFallback(
|
|||||||
* Increments attemptCount each time called.
|
* Increments attemptCount each time called.
|
||||||
*/
|
*/
|
||||||
export function getNextFallback(
|
export function getNextFallback(
|
||||||
|
controller: Pick<ModelFallbackStateController, "getNextFallback">,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
): { providerID: string; modelID: string; variant?: string } | null {
|
): { providerID: string; modelID: string; variant?: string } | null {
|
||||||
return getOrCreateModelFallbackController().getNextFallback(sessionID)
|
return controller.getNextFallback(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clears the pending fallback for a session.
|
* Clears the pending fallback for a session.
|
||||||
* Called after fallback is successfully applied.
|
* Called after fallback is successfully applied.
|
||||||
*/
|
*/
|
||||||
export function clearPendingModelFallback(sessionID: string): void {
|
export function clearPendingModelFallback(
|
||||||
getOrCreateModelFallbackController().clearPendingModelFallback(sessionID)
|
controller: Pick<ModelFallbackStateController, "clearPendingModelFallback">,
|
||||||
|
sessionID: string,
|
||||||
|
): void {
|
||||||
|
controller.clearPendingModelFallback(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if there's a pending fallback for a session.
|
* Checks if there's a pending fallback for a session.
|
||||||
*/
|
*/
|
||||||
export function hasPendingModelFallback(sessionID: string): boolean {
|
export function hasPendingModelFallback(
|
||||||
return getOrCreateModelFallbackController().hasPendingModelFallback(sessionID)
|
controller: Pick<ModelFallbackStateController, "hasPendingModelFallback">,
|
||||||
|
sessionID: string,
|
||||||
|
): boolean {
|
||||||
|
return controller.hasPendingModelFallback(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the current fallback state for a session (for debugging).
|
* Gets the current fallback state for a session (for debugging).
|
||||||
*/
|
*/
|
||||||
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
|
export function getFallbackState(
|
||||||
return getOrCreateModelFallbackController().getFallbackState(sessionID)
|
controller: Pick<ModelFallbackStateController, "getFallbackState">,
|
||||||
|
sessionID: string,
|
||||||
|
): ModelFallbackState | undefined {
|
||||||
|
return controller.getFallbackState(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a chat.message hook that applies model fallbacks when pending.
|
* Creates a chat.message hook that applies model fallbacks when pending.
|
||||||
*/
|
*/
|
||||||
export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplied?: FallbackCallback }) {
|
export function createModelFallbackHook(args?: ModelFallbackHookArgs): ModelFallbackHook {
|
||||||
if (!modelFallbackControllerRef.current) {
|
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
|
||||||
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
|
const lastToastKey = new Map<string, string>()
|
||||||
const lastToastKey = new Map<string, string>()
|
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
||||||
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
|
const controller = createModelFallbackStateController({
|
||||||
|
pendingModelFallbacks,
|
||||||
|
lastToastKey,
|
||||||
|
sessionFallbackChains,
|
||||||
|
})
|
||||||
|
|
||||||
modelFallbackControllerRef.current = createModelFallbackStateController({
|
args?.controllerAccessor?.register(controller)
|
||||||
pendingModelFallbacks,
|
|
||||||
lastToastKey,
|
|
||||||
sessionFallbackChains,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const controller = getOrCreateModelFallbackController()
|
|
||||||
const toast = args?.toast
|
const toast = args?.toast
|
||||||
const onApplied = args?.onApplied
|
const onApplied = args?.onApplied
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
lastToastKey: controller.lastToastKey,
|
||||||
|
setSessionFallbackChain: controller.setSessionFallbackChain,
|
||||||
|
clearSessionFallbackChain: controller.clearSessionFallbackChain,
|
||||||
|
setPendingModelFallback: controller.setPendingModelFallback,
|
||||||
|
getNextFallback: controller.getNextFallback,
|
||||||
|
clearPendingModelFallback: controller.clearPendingModelFallback,
|
||||||
|
hasPendingModelFallback: controller.hasPendingModelFallback,
|
||||||
|
getFallbackState: controller.getFallbackState,
|
||||||
|
reset: controller.reset,
|
||||||
"chat.message": async (
|
"chat.message": async (
|
||||||
input: ChatMessageInput,
|
input: ChatMessageInput,
|
||||||
output: ChatMessageHandlerOutput,
|
output: ChatMessageHandlerOutput,
|
||||||
@@ -128,7 +166,7 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
|||||||
const { sessionID } = input
|
const { sessionID } = input
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|
||||||
const fallback = getNextFallback(sessionID)
|
const fallback = getNextFallback(controller, sessionID)
|
||||||
if (!fallback) return
|
if (!fallback) return
|
||||||
|
|
||||||
await applyFallbackToChatMessage({
|
await applyFallbackToChatMessage({
|
||||||
@@ -144,9 +182,8 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets all module-global state for testing.
|
* Resets hook-owned state for testing.
|
||||||
* Clears pending fallbacks, toast keys, and session chains.
|
|
||||||
*/
|
*/
|
||||||
export function _resetForTesting(): void {
|
export function _resetForTesting(controller?: Pick<ModelFallbackStateController, "reset">): void {
|
||||||
getOrCreateModelFallbackController().reset()
|
controller?.reset()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { createModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
|
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
|
||||||
@@ -91,6 +91,7 @@ const serverPlugin: Plugin = async (input, _options): Promise<Hooks> => {
|
|||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
backgroundManager: managers.backgroundManager,
|
backgroundManager: managers.backgroundManager,
|
||||||
|
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
mergedSkills: toolsResult.mergedSkills,
|
mergedSkills: toolsResult.mergedSkills,
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ function createChatMessageHandlerHooks(modelFallback: ReturnType<typeof createMo
|
|||||||
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
|
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
|
||||||
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
|
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
readConnectedProvidersCacheSpy?.mockRestore()
|
readConnectedProvidersCacheSpy?.mockRestore()
|
||||||
readProviderModelsCacheSpy?.mockRestore()
|
readProviderModelsCacheSpy?.mockRestore()
|
||||||
readConnectedProvidersCacheSpy = undefined
|
readConnectedProvidersCacheSpy = undefined
|
||||||
readProviderModelsCacheSpy = undefined
|
readProviderModelsCacheSpy = undefined
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("createEventHandler - category runtime fallback suppression", () => {
|
describe("createEventHandler - category runtime fallback suppression", () => {
|
||||||
test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => {
|
test("does not arm retry fallback when category session explicitly stores no fallback chain [regression #2941]", async () => {
|
||||||
@@ -83,11 +83,10 @@ describe("createEventHandler - category runtime fallback suppression", () => {
|
|||||||
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||||
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
||||||
|
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
setSessionAgent(sessionID, "sisyphus-junior")
|
|
||||||
setSessionFallbackChain(sessionID, undefined)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
setSessionAgent(sessionID, "sisyphus-junior")
|
||||||
|
setSessionFallbackChain(modelFallback, sessionID, undefined)
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({
|
ctx: asEventHandlerContext({
|
||||||
directory: "/tmp",
|
directory: "/tmp",
|
||||||
|
|||||||
@@ -142,9 +142,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_fallback"
|
const sessionID = "ses_status_retry_fallback"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
@@ -232,8 +231,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_dedup"
|
const sessionID = "ses_status_retry_dedup"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
@@ -293,8 +292,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_runtime_enabled"
|
const sessionID = "ses_status_retry_runtime_enabled"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const runtimeFallback = {
|
const runtimeFallback = {
|
||||||
event: async () => {},
|
event: async () => {},
|
||||||
"chat.message": async () => {},
|
"chat.message": async () => {},
|
||||||
@@ -346,9 +345,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_status_retry_user_fallback"
|
const sessionID = "ses_status_retry_user_fallback"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
const pluginConfig = {
|
const pluginConfig = {
|
||||||
agents: {
|
agents: {
|
||||||
sisyphus: {
|
sisyphus: {
|
||||||
@@ -446,9 +444,8 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
const toastCalls: string[] = []
|
const toastCalls: string[] = []
|
||||||
const sessionID = "ses_main_fallback_chain"
|
const sessionID = "ses_main_fallback_chain"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
setupConnectedProviderCacheMocks()
|
setupConnectedProviderCacheMocks()
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
|
|||||||
@@ -761,11 +761,10 @@ describe("createEventHandler - retry dedupe lifecycle", () => {
|
|||||||
//#given
|
//#given
|
||||||
const sessionID = "ses_retry_recovery_rearm"
|
const sessionID = "ses_retry_recovery_rearm"
|
||||||
setMainSession(sessionID)
|
setMainSession(sessionID)
|
||||||
clearPendingModelFallback(sessionID)
|
|
||||||
|
|
||||||
const abortCalls: string[] = []
|
const abortCalls: string[] = []
|
||||||
const promptCalls: string[] = []
|
const promptCalls: string[] = []
|
||||||
const modelFallback = createModelFallbackHook()
|
const modelFallback = createModelFallbackHook()
|
||||||
|
clearPendingModelFallback(modelFallback, sessionID)
|
||||||
|
|
||||||
const eventHandler = createEventHandler({
|
const eventHandler = createEventHandler({
|
||||||
ctx: asEventHandlerContext({
|
ctx: asEventHandlerContext({
|
||||||
|
|||||||
+22
-9
@@ -15,6 +15,7 @@ import {
|
|||||||
clearSessionFallbackChain,
|
clearSessionFallbackChain,
|
||||||
setSessionFallbackChain,
|
setSessionFallbackChain,
|
||||||
setPendingModelFallback,
|
setPendingModelFallback,
|
||||||
|
type ModelFallbackHook,
|
||||||
} from "../hooks/model-fallback/hook";
|
} from "../hooks/model-fallback/hook";
|
||||||
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
import { getRawFallbackModels } from "../hooks/runtime-fallback/fallback-models";
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +112,7 @@ function extractProviderModelFromErrorMessage(message: string): { providerID?: s
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
function applyUserConfiguredFallbackChain(
|
function applyUserConfiguredFallbackChain(
|
||||||
|
modelFallback: Pick<ModelFallbackHook, "setSessionFallbackChain"> | null | undefined,
|
||||||
sessionID: string,
|
sessionID: string,
|
||||||
agentName: string,
|
agentName: string,
|
||||||
currentProviderID: string,
|
currentProviderID: string,
|
||||||
@@ -123,7 +125,9 @@ function applyUserConfiguredFallbackChain(
|
|||||||
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
const fallbackChain = buildFallbackChainFromModels(rawFallbackModels, currentProviderID);
|
||||||
|
|
||||||
if (fallbackChain && fallbackChain.length > 0) {
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
setSessionFallbackChain(sessionID, fallbackChain);
|
if (modelFallback) {
|
||||||
|
setSessionFallbackChain(modelFallback, sessionID, fallbackChain);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ export function createEventHandler(args: {
|
|||||||
|
|
||||||
const isModelFallbackEnabled =
|
const isModelFallbackEnabled =
|
||||||
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
hooks.modelFallback !== null && hooks.modelFallback !== undefined;
|
||||||
|
const modelFallback = hooks.modelFallback;
|
||||||
|
|
||||||
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
// Avoid triggering multiple abort+continue cycles for the same failing assistant message.
|
||||||
const lastHandledModelErrorMessageID = new Map<string, string>();
|
const lastHandledModelErrorMessageID = new Map<string, string>();
|
||||||
@@ -408,8 +413,10 @@ export function createEventHandler(args: {
|
|||||||
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
lastHandledModelErrorMessageID.delete(sessionInfo.id);
|
||||||
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
lastHandledRetryStatusKey.delete(sessionInfo.id);
|
||||||
lastKnownModelBySession.delete(sessionInfo.id);
|
lastKnownModelBySession.delete(sessionInfo.id);
|
||||||
clearPendingModelFallback(sessionInfo.id);
|
if (modelFallback) {
|
||||||
clearSessionFallbackChain(sessionInfo.id);
|
clearPendingModelFallback(modelFallback, sessionInfo.id);
|
||||||
|
clearSessionFallbackChain(modelFallback, sessionInfo.id);
|
||||||
|
}
|
||||||
resetMessageCursor(sessionInfo.id);
|
resetMessageCursor(sessionInfo.id);
|
||||||
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForParentSession(sessionInfo.id);
|
||||||
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
|
clearBackgroundOutputConsumptionsForTaskSession(sessionInfo.id);
|
||||||
@@ -517,9 +524,11 @@ export function createEventHandler(args: {
|
|||||||
);
|
);
|
||||||
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
|
const rawModel = (info?.modelID as string | undefined) ?? "claude-opus-4-7";
|
||||||
const currentModel = normalizeFallbackModelID(rawModel);
|
const currentModel = normalizeFallbackModelID(rawModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
@@ -580,9 +589,11 @@ export function createEventHandler(args: {
|
|||||||
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
|
const currentProvider = resolveFallbackProviderID(sessionID, parsed.providerID);
|
||||||
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
|
let currentModel = parsed.modelID ?? lastKnown?.modelID ?? "claude-opus-4-7";
|
||||||
currentModel = normalizeFallbackModelID(currentModel);
|
currentModel = normalizeFallbackModelID(currentModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
@@ -666,9 +677,11 @@ export function createEventHandler(args: {
|
|||||||
);
|
);
|
||||||
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
|
let currentModel = (props?.modelID as string) || parsed.modelID || "claude-opus-4-7";
|
||||||
currentModel = normalizeFallbackModelID(currentModel);
|
currentModel = normalizeFallbackModelID(currentModel);
|
||||||
applyUserConfiguredFallbackChain(sessionID, agentName, currentProvider, args.pluginConfig);
|
applyUserConfiguredFallbackChain(modelFallback, sessionID, agentName, currentProvider, args.pluginConfig);
|
||||||
|
|
||||||
const setFallback = setPendingModelFallback(sessionID, agentName, currentProvider, currentModel);
|
const setFallback = modelFallback
|
||||||
|
? setPendingModelFallback(modelFallback, sessionID, agentName, currentProvider, currentModel)
|
||||||
|
: false;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
setFallback &&
|
setFallback &&
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { createModelFallbackHook } from "../hooks/model-fallback/hook"
|
|||||||
import { createRuntimeFallbackHook } from "../hooks/runtime-fallback"
|
import { createRuntimeFallbackHook } from "../hooks/runtime-fallback"
|
||||||
import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types"
|
import type { RuntimeFallbackPluginInput } from "../hooks/runtime-fallback/types"
|
||||||
import { _resetForTesting } from "../features/claude-code-session-state"
|
import { _resetForTesting } from "../features/claude-code-session-state"
|
||||||
import { _resetForTesting as _resetModelFallbackForTesting } from "../hooks/model-fallback/hook"
|
|
||||||
import { SessionCategoryRegistry } from "../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../shared/session-category-registry"
|
||||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||||
|
|
||||||
@@ -369,7 +368,6 @@ function setupConnectedProviderCacheMocks(): void {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
_resetForTesting()
|
_resetForTesting()
|
||||||
_resetModelFallbackForTesting()
|
|
||||||
SessionCategoryRegistry.clear()
|
SessionCategoryRegistry.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
import type { HookName, OhMyOpenCodeConfig } from "../../config"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
import type { ModelCacheState } from "../../plugin-state"
|
import type { ModelCacheState } from "../../plugin-state"
|
||||||
|
|
||||||
@@ -10,15 +11,17 @@ export function createCoreHooks(args: {
|
|||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
}) {
|
}) {
|
||||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
|
||||||
|
|
||||||
const session = createSessionHooks({
|
const session = createSessionHooks({
|
||||||
ctx,
|
ctx,
|
||||||
pluginConfig,
|
pluginConfig,
|
||||||
modelCacheState,
|
modelCacheState,
|
||||||
|
modelFallbackControllerAccessor,
|
||||||
isHookEnabled,
|
isHookEnabled,
|
||||||
safeHookEnabled,
|
safeHookEnabled,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
import type { OhMyOpenCodeConfig, HookName } from "../../config"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { ModelCacheState } from "../../plugin-state"
|
import type { ModelCacheState } from "../../plugin-state"
|
||||||
import type { PluginContext } from "../types"
|
import type { PluginContext } from "../types"
|
||||||
|
|
||||||
@@ -69,10 +70,11 @@ export function createSessionHooks(args: {
|
|||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
modelCacheState: ModelCacheState
|
modelCacheState: ModelCacheState
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
isHookEnabled: (hookName: HookName) => boolean
|
isHookEnabled: (hookName: HookName) => boolean
|
||||||
safeHookEnabled: boolean
|
safeHookEnabled: boolean
|
||||||
}): SessionHooks {
|
}): SessionHooks {
|
||||||
const { ctx, pluginConfig, modelCacheState, isHookEnabled, safeHookEnabled } = args
|
const { ctx, pluginConfig, modelCacheState, modelFallbackControllerAccessor, isHookEnabled, safeHookEnabled } = args
|
||||||
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
const safeHook = <T>(hookName: HookName, factory: () => T): T | null =>
|
||||||
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
safeCreateHook(hookName, factory, { enabled: safeHookEnabled })
|
||||||
|
|
||||||
@@ -171,6 +173,7 @@ export function createSessionHooks(args: {
|
|||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
},
|
},
|
||||||
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
|
onApplied: enableFallbackTitle ? updateFallbackTitle : undefined,
|
||||||
|
controllerAccessor: modelFallbackControllerAccessor,
|
||||||
}))
|
}))
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): vo
|
|||||||
export function createToolRegistry(args: {
|
export function createToolRegistry(args: {
|
||||||
ctx: PluginContext
|
ctx: PluginContext
|
||||||
pluginConfig: OhMyOpenCodeConfig
|
pluginConfig: OhMyOpenCodeConfig
|
||||||
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager">
|
managers: Pick<Managers, "backgroundManager" | "tmuxSessionManager" | "skillMcpManager" | "modelFallbackControllerAccessor">
|
||||||
skillContext: SkillContext
|
skillContext: SkillContext
|
||||||
availableCategories: AvailableCategory[]
|
availableCategories: AvailableCategory[]
|
||||||
interactiveBashEnabled?: boolean
|
interactiveBashEnabled?: boolean
|
||||||
@@ -170,6 +170,7 @@ export function createToolRegistry(args: {
|
|||||||
pluginConfig.disabled_agents ?? [],
|
pluginConfig.disabled_agents ?? [],
|
||||||
pluginConfig.agents,
|
pluginConfig.agents,
|
||||||
pluginConfig.categories,
|
pluginConfig.categories,
|
||||||
|
managers.modelFallbackControllerAccessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some(
|
||||||
@@ -191,6 +192,7 @@ export function createToolRegistry(args: {
|
|||||||
availableSkills: skillContext.availableSkills,
|
availableSkills: skillContext.availableSkills,
|
||||||
sisyphusAgentConfig: pluginConfig.sisyphus_agent,
|
sisyphusAgentConfig: pluginConfig.sisyphus_agent,
|
||||||
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
|
syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs,
|
||||||
|
modelFallbackControllerAccessor: managers.modelFallbackControllerAccessor,
|
||||||
onSyncSessionCreated: async (event) => {
|
onSyncSessionCreated: async (event) => {
|
||||||
log("[index] onSyncSessionCreated callback", {
|
log("[index] onSyncSessionCreated callback", {
|
||||||
sessionID: event.sessionID,
|
sessionID: event.sessionID,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { CallOmoAgentArgs } from "./types"
|
import type { CallOmoAgentArgs } from "./types"
|
||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
|
||||||
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { getAgentToolRestrictions, log } from "../../shared"
|
import { getAgentToolRestrictions, log } from "../../shared"
|
||||||
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
|
||||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||||
@@ -19,8 +18,8 @@ type ExecuteSyncDeps = {
|
|||||||
createOrGetSession: typeof createOrGetSession
|
createOrGetSession: typeof createOrGetSession
|
||||||
waitForCompletion: typeof waitForCompletion
|
waitForCompletion: typeof waitForCompletion
|
||||||
processMessages: typeof processMessages
|
processMessages: typeof processMessages
|
||||||
setSessionFallbackChain: typeof setSessionFallbackChain
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
|
||||||
clearSessionFallbackChain: typeof clearSessionFallbackChain
|
clearSessionFallbackChain: (sessionID: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpawnReservation = {
|
type SpawnReservation = {
|
||||||
@@ -32,8 +31,8 @@ const defaultDeps: ExecuteSyncDeps = {
|
|||||||
createOrGetSession,
|
createOrGetSession,
|
||||||
waitForCompletion,
|
waitForCompletion,
|
||||||
processMessages,
|
processMessages,
|
||||||
setSessionFallbackChain,
|
setSessionFallbackChain: () => {},
|
||||||
clearSessionFallbackChain,
|
clearSessionFallbackChain: () => {},
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
function buildPromptGenerationParams(model: DelegatedModelConfig | undefined): Record<string, unknown> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin
|
|||||||
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||||
import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
import type { CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
|
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
|
||||||
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
|
||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
@@ -15,6 +16,23 @@ import { parseModelString } from "../../shared"
|
|||||||
import { executeBackground } from "./background-executor"
|
import { executeBackground } from "./background-executor"
|
||||||
import { executeSync } from "./sync-executor"
|
import { executeSync } from "./sync-executor"
|
||||||
import { resolveCallableAgents } from "./agent-resolver"
|
import { resolveCallableAgents } from "./agent-resolver"
|
||||||
|
import { createOrGetSession } from "./session-creator"
|
||||||
|
import { processMessages } from "./message-processor"
|
||||||
|
import { waitForCompletion } from "./completion-poller"
|
||||||
|
|
||||||
|
function createSyncExecutorDeps(modelFallbackControllerAccessor?: ModelFallbackControllerAccessor) {
|
||||||
|
return {
|
||||||
|
createOrGetSession,
|
||||||
|
waitForCompletion,
|
||||||
|
processMessages,
|
||||||
|
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => {
|
||||||
|
modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
},
|
||||||
|
clearSessionFallbackChain: (sessionID: string) => {
|
||||||
|
modelFallbackControllerAccessor?.clearSessionFallbackChain(sessionID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModelAndFallbackChain(args: {
|
function resolveModelAndFallbackChain(args: {
|
||||||
subagentType: string
|
subagentType: string
|
||||||
@@ -82,6 +100,7 @@ export function createCallOmoAgent(
|
|||||||
disabledAgents: string[] = [],
|
disabledAgents: string[] = [],
|
||||||
agentOverrides?: AgentOverrides,
|
agentOverrides?: AgentOverrides,
|
||||||
userCategories?: CategoriesConfig,
|
userCategories?: CategoriesConfig,
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor,
|
||||||
): ToolDefinition {
|
): ToolDefinition {
|
||||||
const agentDescriptions = ALLOWED_AGENTS.map(
|
const agentDescriptions = ALLOWED_AGENTS.map(
|
||||||
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
(name) => `- ${name}: Specialized agent for ${name} tasks`,
|
||||||
@@ -158,14 +177,30 @@ export function createCallOmoAgent(
|
|||||||
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
|
||||||
try {
|
try {
|
||||||
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
|
||||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation, resolvedModel)
|
return await executeSync(
|
||||||
|
args,
|
||||||
|
toolCtx,
|
||||||
|
ctx,
|
||||||
|
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||||
|
fallbackChain,
|
||||||
|
spawnReservation,
|
||||||
|
resolvedModel,
|
||||||
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
spawnReservation?.rollback()
|
spawnReservation?.rollback()
|
||||||
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
return `Error: ${error instanceof Error ? error.message : String(error)}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
|
return await executeSync(
|
||||||
|
args,
|
||||||
|
toolCtx,
|
||||||
|
ctx,
|
||||||
|
createSyncExecutorDeps(modelFallbackControllerAccessor),
|
||||||
|
fallbackChain,
|
||||||
|
undefined,
|
||||||
|
resolvedModel,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { formatDetailedError } from "./error-formatting"
|
|||||||
import { getSessionTools } from "../../shared/session-tools-store"
|
import { getSessionTools } from "../../shared/session-tools-store"
|
||||||
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
||||||
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
|
||||||
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
import { stripAgentListSortPrefix } from "../../shared/agent-display-names"
|
||||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||||
@@ -19,6 +18,7 @@ function continueSessionSetup(args: {
|
|||||||
timing: ReturnType<typeof getTimingConfig>
|
timing: ReturnType<typeof getTimingConfig>
|
||||||
fallbackChain?: FallbackEntry[]
|
fallbackChain?: FallbackEntry[]
|
||||||
category?: string
|
category?: string
|
||||||
|
modelFallbackControllerAccessor?: ExecutorContext["modelFallbackControllerAccessor"]
|
||||||
}): void {
|
}): void {
|
||||||
if (!args.fallbackChain && !args.category) {
|
if (!args.fallbackChain && !args.category) {
|
||||||
return
|
return
|
||||||
@@ -41,7 +41,7 @@ function continueSessionSetup(args: {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
setSessionFallbackChain(sessionId, args.fallbackChain)
|
args.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, args.fallbackChain)
|
||||||
if (args.category) {
|
if (args.category) {
|
||||||
SessionCategoryRegistry.register(sessionId, args.category)
|
SessionCategoryRegistry.register(sessionId, args.category)
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,7 @@ export async function executeBackgroundTask(
|
|||||||
timing,
|
timing,
|
||||||
fallbackChain,
|
fallbackChain,
|
||||||
category: args.category,
|
category: args.category,
|
||||||
|
modelFallbackControllerAccessor: executorCtx.modelFallbackControllerAccessor,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ export async function executeBackgroundTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
setSessionFallbackChain(sessionId, fallbackChain)
|
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionId, fallbackChain)
|
||||||
}
|
}
|
||||||
if (args.category && sessionId) {
|
if (args.category && sessionId) {
|
||||||
SessionCategoryRegistry.register(sessionId, args.category)
|
SessionCategoryRegistry.register(sessionId, args.category)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type { OpencodeClient } from "./types"
|
import type { OpencodeClient } from "./types"
|
||||||
|
|
||||||
export interface ExecutorContext {
|
export interface ExecutorContext {
|
||||||
@@ -12,6 +13,7 @@ export interface ExecutorContext {
|
|||||||
browserProvider?: BrowserAutomationProvider
|
browserProvider?: BrowserAutomationProvider
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise<void>
|
||||||
syncPollTimeoutMs?: number
|
syncPollTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { SessionCategoryRegistry } from "../../shared/session-category-registry"
|
|||||||
import { formatDuration } from "./time-formatter"
|
import { formatDuration } from "./time-formatter"
|
||||||
import { formatDetailedError } from "./error-formatting"
|
import { formatDetailedError } from "./error-formatting"
|
||||||
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
import { syncTaskDeps, type SyncTaskDeps } from "./sync-task-deps"
|
||||||
import { setSessionFallbackChain, clearSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
|
||||||
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
import { retrySyncPromptWithFallbacks } from "./sync-task-fallback"
|
||||||
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
import { buildTaskMetadataBlock } from "../../features/tool-metadata-store/task-metadata-contract"
|
||||||
import { resolveMetadataModel } from "./resolve-metadata-model"
|
import { resolveMetadataModel } from "./resolve-metadata-model"
|
||||||
@@ -81,7 +80,7 @@ export async function executeSyncTask(
|
|||||||
subagentSessions.add(sessionID)
|
subagentSessions.add(sessionID)
|
||||||
syncSubagentSessions.add(sessionID)
|
syncSubagentSessions.add(sessionID)
|
||||||
setSessionAgent(sessionID, agentToUse)
|
setSessionAgent(sessionID, agentToUse)
|
||||||
setSessionFallbackChain(sessionID, fallbackChain)
|
executorCtx.modelFallbackControllerAccessor?.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
|
||||||
if (args.category) {
|
if (args.category) {
|
||||||
SessionCategoryRegistry.register(sessionID, args.category)
|
SessionCategoryRegistry.register(sessionID, args.category)
|
||||||
@@ -237,7 +236,7 @@ ${buildTaskMetadataBlock({
|
|||||||
if (syncSessionID) {
|
if (syncSessionID) {
|
||||||
subagentSessions.delete(syncSessionID)
|
subagentSessions.delete(syncSessionID)
|
||||||
syncSubagentSessions.delete(syncSessionID)
|
syncSubagentSessions.delete(syncSessionID)
|
||||||
clearSessionFallbackChain(syncSessionID)
|
executorCtx.modelFallbackControllerAccessor?.clearSessionFallbackChain(syncSessionID)
|
||||||
SessionCategoryRegistry.remove(syncSessionID)
|
SessionCategoryRegistry.remove(syncSessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { BackgroundManager } from "../../features/background-agent"
|
import type { BackgroundManager } from "../../features/background-agent"
|
||||||
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
import type { CategoriesConfig, GitMasterConfig, BrowserAutomationProvider, AgentOverrides, SisyphusAgentConfig } from "../../config/schema"
|
||||||
|
import type { ModelFallbackControllerAccessor } from "../../hooks/model-fallback"
|
||||||
import type {
|
import type {
|
||||||
AvailableCategory,
|
AvailableCategory,
|
||||||
AvailableSkill,
|
AvailableSkill,
|
||||||
@@ -68,6 +69,7 @@ export interface DelegateTaskToolOptions {
|
|||||||
availableSkills?: AvailableSkill[]
|
availableSkills?: AvailableSkill[]
|
||||||
agentOverrides?: AgentOverrides
|
agentOverrides?: AgentOverrides
|
||||||
sisyphusAgentConfig?: SisyphusAgentConfig
|
sisyphusAgentConfig?: SisyphusAgentConfig
|
||||||
|
modelFallbackControllerAccessor?: ModelFallbackControllerAccessor
|
||||||
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
|
onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise<void>
|
||||||
syncPollTimeoutMs?: number
|
syncPollTimeoutMs?: number
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user