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:
YeonGyu-Kim
2026-04-18 02:35:46 +09:00
parent e2f5c0d361
commit 5e4102566c
23 changed files with 271 additions and 123 deletions
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}
}
+44 -33
View File
@@ -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)
}) })
}) })
+77 -40
View File
@@ -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()
} }
+2
View File
@@ -0,0 +1,2 @@
export { createModelFallbackControllerAccessor } from "./controller-accessor"
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
+1
View File
@@ -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,
+10 -11
View File
@@ -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",
+5 -8
View File
@@ -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({
+1 -2
View File
@@ -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
View File
@@ -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()
}) })
+4 -1
View File
@@ -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,
}) })
+4 -1
View File
@@ -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
+3 -1
View File
@@ -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,
+4 -5
View File
@@ -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> {
+37 -2
View File
@@ -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,
)
}, },
}); });
} }
+4 -3
View File
@@ -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
} }
+2 -3
View File
@@ -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)
} }
} }
+2
View File
@@ -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
} }