Merge pull request #3492 from code-yeongyu/refactor/legacy-plugin-decoupling

refactor: modernize plugin entry to V1 format and decouple legacy/tightly-coupled code
This commit is contained in:
YeonGyu-Kim
2026-04-18 03:10:14 +09:00
committed by GitHub
60 changed files with 1590 additions and 1429 deletions
+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 { 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 { createRulesInjectorHook } from "./rules-injector";
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,
}
}
@@ -0,0 +1,134 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { log } from "../../shared/logger"
import { getNextReachableFallback } from "./next-fallback"
type ModelFallbackStateLike = {
providerID: string
modelID: string
fallbackChain: FallbackEntry[]
attemptCount: number
pending: boolean
}
export type ModelFallbackStateController = {
lastToastKey: Map<string, string>
setSessionFallbackChain: (sessionID: string, fallbackChain: FallbackEntry[] | undefined) => void
clearSessionFallbackChain: (sessionID: string) => void
setPendingModelFallback: (
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
) => boolean
getNextFallback: (sessionID: string) => ReturnType<typeof getNextReachableFallback>
clearPendingModelFallback: (sessionID: string) => void
hasPendingModelFallback: (sessionID: string) => boolean
getFallbackState: (sessionID: string) => ModelFallbackStateLike | undefined
reset: () => void
}
export function createModelFallbackStateController(input: {
pendingModelFallbacks: Map<string, ModelFallbackStateLike>
lastToastKey: Map<string, string>
sessionFallbackChains: Map<string, FallbackEntry[]>
}): ModelFallbackStateController {
const { pendingModelFallbacks, lastToastKey, sessionFallbackChains } = input
function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
sessionFallbackChains.set(sessionID, fallbackChain?.length ? fallbackChain : [])
}
function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
}
function setPendingModelFallback(
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const fallbackChain = sessionFallbackChains.get(sessionID) ?? requirements?.fallbackChain
if (!fallbackChain?.length) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (!existing) {
pendingModelFallbacks.set(sessionID, {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
})
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
}
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
function getNextFallback(sessionID: string): ReturnType<typeof getNextReachableFallback> {
const state = pendingModelFallbacks.get(sessionID)
if (!state?.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) return fallback
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
}
function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
}
function hasPendingModelFallback(sessionID: string): boolean {
return pendingModelFallbacks.get(sessionID)?.pending === true
}
function getFallbackState(sessionID: string): ModelFallbackStateLike | undefined {
return pendingModelFallbacks.get(sessionID)
}
function reset(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
}
return {
lastToastKey,
setSessionFallbackChain,
clearSessionFallbackChain,
setPendingModelFallback,
getNextFallback,
clearPendingModelFallback,
hasPendingModelFallback,
getFallbackState,
reset,
}
}
+44 -33
View File
@@ -70,22 +70,23 @@ const {
setPendingModelFallback,
} = await importFreshModelFallbackHookModule()
type ModelFallbackHook = ReturnType<typeof createModelFallbackHook>
describe("model fallback hook", () => {
let modelFallback: ModelFallbackHook
beforeEach(() => {
modelFallback = createModelFallbackHook()
readConnectedProvidersCacheMock.mockReturnValue(null)
readProviderModelsCacheMock.mockReturnValue(null)
readConnectedProvidersCacheMock.mockClear()
readProviderModelsCacheMock.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 () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
@@ -93,6 +94,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
modelFallback,
"ses_model_fallback_main",
"Sisyphus - Ultraworker",
"anthropic",
@@ -123,7 +125,7 @@ describe("model fallback hook", () => {
test("preserves fallback progression across repeated session.error retries", async () => {
//#given
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: 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"
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7-thinking"),
).toBe(true)
const firstOutput = {
@@ -154,7 +156,7 @@ describe("model fallback hook", () => {
//#when - second error re-arms fallback and should advance to next entry
expect(
setPendingModelFallback(sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
setPendingModelFallback(modelFallback, sessionID, "Sisyphus - Ultraworker", "anthropic", "claude-opus-4-7"),
).toBe(true)
const secondOutput = {
@@ -176,16 +178,18 @@ describe("model fallback hook", () => {
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
//#when
const firstSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
"claude-opus-4-7-thinking",
)
const secondSet = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -195,28 +199,29 @@ describe("model fallback hook", () => {
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
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"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-7" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"anthropic",
@@ -239,28 +244,29 @@ describe("model fallback hook", () => {
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
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"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-7", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"quotio",
@@ -285,28 +291,29 @@ describe("model fallback hook", () => {
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("uses connected preferred provider when fallback entry providers are disconnected", async () => {
//#given
const sessionID = "ses_model_fallback_preferred_provider"
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
readConnectedProvidersCacheMock.mockReturnValue(["provider-x"])
const hook = createModelFallbackHook() as unknown as {
const hook = modelFallback as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["provider-y"], model: "fallback-model" },
])
expect(
setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Ultraworker",
"provider-x",
@@ -329,17 +336,18 @@ describe("model fallback hook", () => {
providerID: "provider-x",
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]", () => {
//#given
const sessionID = "ses_model_fallback_explicit_none"
clearPendingModelFallback(sessionID)
setSessionFallbackChain(sessionID, undefined)
clearPendingModelFallback(modelFallback, sessionID)
setSessionFallbackChain(modelFallback, sessionID, undefined)
//#when
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Sisyphus - Junior",
"anthropic",
@@ -348,7 +356,7 @@ describe("model fallback hook", () => {
//#then
expect(set).toBe(false)
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("shows toast when fallback is applied", async () => {
@@ -366,6 +374,7 @@ describe("model fallback hook", () => {
}
const set = setPendingModelFallback(
hook,
"ses_model_fallback_toast",
"Sisyphus - Ultraworker",
"anthropic",
@@ -392,9 +401,9 @@ describe("model fallback hook", () => {
test("transforms model names for github-copilot provider via fallback chain", async () => {
//#given
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"?: (
input: { sessionID: 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
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["github-copilot"], model: "claude-sonnet-4-6" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Atlas - Plan Executor",
"github-copilot",
@@ -430,15 +440,15 @@ describe("model fallback hook", () => {
modelID: "claude-sonnet-4.6",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
test("preserves canonical google preview model names via fallback chain", async () => {
//#given
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"?: (
input: { sessionID: 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
setSessionFallbackChain(sessionID, [
setSessionFallbackChain(modelFallback, sessionID, [
{ providers: ["google"], model: "gemini-3.1-pro-preview" },
])
const set = setPendingModelFallback(
modelFallback,
sessionID,
"Oracle",
"google",
@@ -474,7 +485,7 @@ describe("model fallback hook", () => {
modelID: "gemini-3.1-pro-preview",
})
clearPendingModelFallback(sessionID)
clearPendingModelFallback(modelFallback, sessionID)
})
})
+91 -103
View File
@@ -1,13 +1,11 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache"
import { selectFallbackProvider } from "../../shared/model-error-classifier"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { log } from "../../shared/logger"
import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message"
import { applyFallbackToChatMessage } from "./chat-message-fallback-handler"
import { getNextReachableFallback } from "./next-fallback"
import {
createModelFallbackStateController,
type ModelFallbackStateController,
} from "./fallback-state-controller"
import type { ModelFallbackControllerAccessor } from "./controller-accessor"
type FallbackToast = (input: {
title: string
@@ -31,30 +29,45 @@ export type ModelFallbackState = {
pending: boolean
}
/**
* Map of sessionID -> pending model fallback state
* When a model error occurs, we store the fallback info here.
* The next chat.message call will use this to switch to the fallback model.
*/
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
type ModelFallbackControllerWithState = Pick<
ModelFallbackStateController,
| "lastToastKey"
| "setSessionFallbackChain"
| "clearSessionFallbackChain"
| "setPendingModelFallback"
| "getNextFallback"
| "clearPendingModelFallback"
| "hasPendingModelFallback"
| "getFallbackState"
| "reset"
>
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain) {
sessionFallbackChains.set(sessionID, [])
return
}
if (fallbackChain.length === 0) {
sessionFallbackChains.set(sessionID, [])
return
}
sessionFallbackChains.set(sessionID, fallbackChain)
export type ModelFallbackHook = ModelFallbackControllerWithState & {
"chat.message": (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
) => Promise<void>
}
export function clearSessionFallbackChain(sessionID: string): void {
sessionFallbackChains.delete(sessionID)
type ModelFallbackHookArgs = {
toast?: FallbackToast
onApplied?: FallbackCallback
controllerAccessor?: ModelFallbackControllerAccessor
}
export function setSessionFallbackChain(
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)
}
/**
@@ -62,56 +75,18 @@ export function clearSessionFallbackChain(sessionID: string): void {
* Called when a model error is detected in session.error handler.
*/
export function setPendingModelFallback(
controller: Pick<ModelFallbackStateController, "setPendingModelFallback">,
sessionID: string,
agentName: string,
currentProviderID: string,
currentModelID: string,
): boolean {
const agentKey = getAgentConfigKey(agentName)
const requirements = AGENT_MODEL_REQUIREMENTS[agentKey]
const hasSessionFallback = sessionFallbackChains.has(sessionID)
const sessionFallback = sessionFallbackChains.get(sessionID)
const fallbackChain = hasSessionFallback
? sessionFallback
: requirements?.fallbackChain
if (!fallbackChain || fallbackChain.length === 0) {
log("[model-fallback] No fallback chain for agent: " + agentName + " (key: " + agentKey + ")")
return false
}
const existing = pendingModelFallbacks.get(sessionID)
if (existing) {
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
// Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID
existing.modelID = currentModelID
existing.pending = true
if (existing.attemptCount >= existing.fallbackChain.length) {
log("[model-fallback] Fallback chain exhausted for session: " + sessionID)
return false
}
log("[model-fallback] Re-armed pending fallback for session: " + sessionID)
return true
}
const state: ModelFallbackState = {
providerID: currentProviderID,
modelID: currentModelID,
fallbackChain,
attemptCount: 0,
pending: true,
}
pendingModelFallbacks.set(sessionID, state)
log("[model-fallback] Set pending fallback for session: " + sessionID + ", agent: " + agentName)
return true
return controller.setPendingModelFallback(
sessionID,
agentName,
currentProviderID,
currentModelID,
)
}
/**
@@ -119,55 +94,71 @@ export function setPendingModelFallback(
* Increments attemptCount each time called.
*/
export function getNextFallback(
controller: Pick<ModelFallbackStateController, "getNextFallback">,
sessionID: string,
): { providerID: string; modelID: string; variant?: string } | null {
const state = pendingModelFallbacks.get(sessionID)
if (!state) return null
if (!state.pending) return null
const fallback = getNextReachableFallback(sessionID, state)
if (fallback) {
return fallback
}
log("[model-fallback] No more fallbacks for session: " + sessionID)
pendingModelFallbacks.delete(sessionID)
return null
return controller.getNextFallback(sessionID)
}
/**
* Clears the pending fallback for a session.
* Called after fallback is successfully applied.
*/
export function clearPendingModelFallback(sessionID: string): void {
pendingModelFallbacks.delete(sessionID)
lastToastKey.delete(sessionID)
export function clearPendingModelFallback(
controller: Pick<ModelFallbackStateController, "clearPendingModelFallback">,
sessionID: string,
): void {
controller.clearPendingModelFallback(sessionID)
}
/**
* Checks if there's a pending fallback for a session.
*/
export function hasPendingModelFallback(sessionID: string): boolean {
const state = pendingModelFallbacks.get(sessionID)
return state?.pending === true
export function hasPendingModelFallback(
controller: Pick<ModelFallbackStateController, "hasPendingModelFallback">,
sessionID: string,
): boolean {
return controller.hasPendingModelFallback(sessionID)
}
/**
* Gets the current fallback state for a session (for debugging).
*/
export function getFallbackState(sessionID: string): ModelFallbackState | undefined {
return pendingModelFallbacks.get(sessionID)
export function getFallbackState(
controller: Pick<ModelFallbackStateController, "getFallbackState">,
sessionID: string,
): ModelFallbackState | undefined {
return controller.getFallbackState(sessionID)
}
/**
* 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 {
const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
const controller = createModelFallbackStateController({
pendingModelFallbacks,
lastToastKey,
sessionFallbackChains,
})
args?.controllerAccessor?.register(controller)
const toast = args?.toast
const onApplied = args?.onApplied
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 (
input: ChatMessageInput,
output: ChatMessageHandlerOutput,
@@ -175,7 +166,7 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
const { sessionID } = input
if (!sessionID) return
const fallback = getNextFallback(sessionID)
const fallback = getNextFallback(controller, sessionID)
if (!fallback) return
await applyFallbackToChatMessage({
@@ -184,18 +175,15 @@ export function createModelFallbackHook(args?: { toast?: FallbackToast; onApplie
fallback,
toast,
onApplied,
lastToastKey,
lastToastKey: controller.lastToastKey,
})
},
}
}
/**
* Resets all module-global state for testing.
* Clears pending fallbacks, toast keys, and session chains.
* Resets hook-owned state for testing.
*/
export function _resetForTesting(): void {
pendingModelFallbacks.clear()
lastToastKey.clear()
sessionFallbackChains.clear()
export function _resetForTesting(controller?: Pick<ModelFallbackStateController, "reset">): void {
controller?.reset()
}
+2
View File
@@ -0,0 +1,2 @@
export { createModelFallbackControllerAccessor } from "./controller-accessor"
export type { ModelFallbackControllerAccessor } from "./controller-accessor"
+131
View File
@@ -0,0 +1,131 @@
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { log } from "../shared/logger"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
} from "./preemptive-compaction-types"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
export async function runPreemptiveCompactionIfNeeded(args: {
ctx: PreemptiveCompactionContext
pluginConfig: OhMyOpenCodeConfig
modelCacheState?: ContextLimitModelCacheState
sessionID: string
tokenCache: Map<string, CachedCompactionState>
compactionInProgress: Set<string>
compactedSessions: Set<string>
lastCompactionTime: Map<string, number>
}): Promise<void> {
const {
ctx,
pluginConfig,
modelCacheState,
sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
} = args
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true },
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
}
+41
View File
@@ -0,0 +1,41 @@
export interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
export interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
export interface PreemptiveCompactionClient {
session: {
messages: (input: {
path: { id: string }
query?: { directory: string }
}) => Promise<unknown>
summarize: (input: {
path: { id: string }
body: { providerID: string; modelID: string; auto?: boolean }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
showToast: (input: {
body: {
title: string
message: string
variant: "warning"
duration: number
}
}) => Promise<unknown>
}
}
export interface PreemptiveCompactionContext {
client: PreemptiveCompactionClient
directory: string
}
+17 -132
View File
@@ -1,69 +1,16 @@
import { log } from "../shared/logger"
import type { OhMyOpenCodeConfig } from "../config"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import type { ContextLimitModelCacheState } from "../shared/context-limit-resolver"
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60_000
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000
declare function setTimeout(handler: () => void, timeout?: number): unknown
declare function clearTimeout(timeoutID: unknown): void
interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
interface CachedCompactionState {
providerID: string
modelID: string
tokens: TokenInfo
}
async function withTimeout<TValue>(
promise: Promise<TValue>,
timeoutMs: number,
errorMessage: string,
): Promise<TValue> {
let timeoutID: unknown
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutID = setTimeout(() => {
reject(new Error(errorMessage))
}, timeoutMs)
})
return await Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutID)
})
}
type PluginInput = {
client: {
session: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
messages: (...args: any[]) => any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
summarize: (...args: any[]) => any
}
tui: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
showToast: (...args: any[]) => any
}
}
directory: string
}
import { runPreemptiveCompactionIfNeeded } from "./preemptive-compaction-trigger"
import type {
CachedCompactionState,
PreemptiveCompactionContext,
TokenInfo,
} from "./preemptive-compaction-types"
export function createPreemptiveCompactionHook(
ctx: PluginInput,
ctx: PreemptiveCompactionContext,
pluginConfig: OhMyOpenCodeConfig,
modelCacheState?: ContextLimitModelCacheState,
) {
@@ -84,78 +31,16 @@ export function createPreemptiveCompactionHook(
input: { tool: string; sessionID: string; callID: string },
_output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return
const lastTime = lastCompactionTime.get(sessionID)
if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
await runPreemptiveCompactionIfNeeded({
ctx,
pluginConfig,
modelCacheState,
)
if (actualLimit === null) {
log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", {
providerID: cached.providerID,
modelID: cached.modelID,
})
return
}
const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0)
const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return
compactionInProgress.add(sessionID)
lastCompactionTime.set(sessionID, Date.now())
try {
const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel(
pluginConfig,
sessionID,
cached.providerID,
cached.modelID,
)
await withTimeout(
ctx.client.session.summarize({
path: { id: sessionID },
body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never,
query: { directory: ctx.directory },
}),
PREEMPTIVE_COMPACTION_TIMEOUT_MS,
`Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`,
)
compactedSessions.add(sessionID)
} catch (error) {
log("[preemptive-compaction] Compaction failed", {
sessionID,
providerID: cached.providerID,
modelID: cached.modelID,
error: String(error),
})
ctx.client.tui.showToast({
body: {
title: "Preemptive compaction failed",
message: `Context window is above ${Math.round(PREEMPTIVE_COMPACTION_THRESHOLD * 100)}% and auto-compaction could not run. The session may grow large. Error: ${String(error)}`,
variant: "warning",
duration: 10000,
},
}).catch((toastError: unknown) => {
log("[preemptive-compaction] Failed to show toast", {
sessionID,
toastError: String(toastError),
})
})
} finally {
compactionInProgress.delete(sessionID)
}
sessionID: input.sessionID,
tokenCache,
compactionInProgress,
compactedSessions,
lastCompactionTime,
})
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
@@ -21,9 +21,9 @@ function createDeferred(): {
}
}
async function waitUntil(condition: () => boolean): Promise<void> {
async function waitUntil(shouldTrigger: () => boolean): Promise<void> {
for (let index = 0; index < 100; index++) {
if (condition()) {
if (shouldTrigger()) {
return
}
@@ -1,4 +1,4 @@
import { parseModelString } from "../../tools/delegate-task/model-string-parser"
import { parseModelString } from "../../shared/model-string-parser"
export function buildRetryModelPayload(
model: string,
@@ -0,0 +1,51 @@
type EventProperties = Record<string, unknown> | undefined
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function getEventInfo(properties: EventProperties): Record<string, unknown> | undefined {
const info = properties?.info
return isRecord(info) ? info : undefined
}
export function getSessionID(properties: EventProperties): string | undefined {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = getEventInfo(properties)
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
export function getEventToolName(properties: EventProperties): string | undefined {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
export function getQuestionText(properties: EventProperties): string {
const args = properties?.args
if (!isRecord(args)) return ""
const questions = args.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0]
if (!isRecord(firstQuestion)) return ""
const questionText = firstQuestion.question
return typeof questionText === "string" ? questionText : ""
}
+5 -37
View File
@@ -8,6 +8,11 @@ import {
type Platform,
} from "./session-notification-sender"
import * as sessionNotificationSender from "./session-notification-sender"
import {
getEventToolName,
getQuestionText,
getSessionID,
} from "./session-notification-event-properties"
import { hasIncompleteTodos } from "./session-todo-status"
import { createIdleNotificationScheduler } from "./session-notification-scheduler"
@@ -85,23 +90,6 @@ export function createSessionNotification(
const PERMISSION_EVENTS = new Set(["permission.ask", "permission.asked", "permission.updated", "permission.requested"])
const PERMISSION_HINT_PATTERN = /\b(permission|approve|approval|allow|deny|consent)\b/i
const getSessionID = (properties: Record<string, unknown> | undefined): string | undefined => {
const sessionID = properties?.sessionID
if (typeof sessionID === "string" && sessionID.length > 0) return sessionID
const sessionId = properties?.sessionId
if (typeof sessionId === "string" && sessionId.length > 0) return sessionId
const info = properties?.info as Record<string, unknown> | undefined
const infoSessionID = info?.sessionID
if (typeof infoSessionID === "string" && infoSessionID.length > 0) return infoSessionID
const infoSessionId = info?.sessionId
if (typeof infoSessionId === "string" && infoSessionId.length > 0) return infoSessionId
return undefined
}
const shouldNotifyForSession = (sessionID: string): boolean => {
if (subagentSessions.has(sessionID)) return false
@@ -113,26 +101,6 @@ export function createSessionNotification(
return true
}
const getEventToolName = (properties: Record<string, unknown> | undefined): string | undefined => {
const tool = properties?.tool
if (typeof tool === "string" && tool.length > 0) return tool
const name = properties?.name
if (typeof name === "string" && name.length > 0) return name
return undefined
}
const getQuestionText = (properties: Record<string, unknown> | undefined): string => {
const args = properties?.args as Record<string, unknown> | undefined
const questions = args?.questions
if (!Array.isArray(questions) || questions.length === 0) return ""
const firstQuestion = questions[0] as Record<string, unknown> | undefined
const questionText = firstQuestion?.question
return typeof questionText === "string" ? questionText : ""
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (currentPlatform === "unsupported") return