diff --git a/src/plugin/event.model-fallback.test.ts b/src/plugin/event.model-fallback.test.ts index 555aff01b..f8a92b6f1 100644 --- a/src/plugin/event.model-fallback.test.ts +++ b/src/plugin/event.model-fallback.test.ts @@ -1,10 +1,15 @@ -import { afterEach, describe, expect, test } from "bun:test" +declare const require: (name: string) => any +const { afterEach, describe, expect, mock, test } = require("bun:test") + +mock.module("../shared/connected-providers-cache", () => ({ + readConnectedProvidersCache: () => null, + readProviderModelsCache: () => null, +})) import { createEventHandler } from "./event" import { createChatMessageHandler } from "./chat-message" import { _resetForTesting, setMainSession } from "../features/claude-code-session-state" import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook" - describe("createEventHandler - model fallback", () => { const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => { const abortCalls: string[] = [] @@ -207,10 +212,10 @@ describe("createEventHandler - model fallback", () => { expect(abortCalls).toEqual([sessionID]) expect(promptCalls).toEqual([sessionID]) expect(output.message["model"]).toMatchObject({ - modelID: "claude-opus-4-6", + providerID: "kimi-for-coding", + modelID: "k2p5", }) - expect(["anthropic", "quotio", "opencode"]).toContain((output.message["model"] as { providerID?: string })?.providerID) - expect(output.message["variant"]).toBe("max") + expect(output.message["variant"]).toBeUndefined() }) test("does not spam abort/prompt when session.status retry countdown updates", async () => { @@ -533,20 +538,19 @@ describe("createEventHandler - model fallback", () => { //#when - first retry cycle const first = await triggerRetryCycle() - //#then - first fallback entry applied (prefers current provider when available) + //#then - first fallback entry applied (no-op skip: claude-opus-4-6 matches current model after normalization) expect(first.message["model"]).toMatchObject({ - modelID: "claude-opus-4-6", + providerID: "kimi-for-coding", + modelID: "k2p5", }) - expect(["anthropic", "quotio", "opencode"]).toContain((first.message["model"] as { providerID?: string })?.providerID) - expect(first.message["variant"]).toBe("max") + expect(first.message["variant"]).toBeUndefined() //#when - second retry cycle const second = await triggerRetryCycle() - //#then - second fallback entry applied (chain advanced) - expect(second.message["model"]).toEqual({ - providerID: "kimi-for-coding", - modelID: "k2p5", + //#then - second fallback entry applied (chain advanced past k2p5) + expect(second.message["model"]).toMatchObject({ + modelID: "kimi-k2.5", }) expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy() expect(second.message["variant"]).toBeUndefined() diff --git a/src/tools/call-omo-agent/sync-executor.test.ts b/src/tools/call-omo-agent/sync-executor.test.ts index de243aeed..37df05a36 100644 --- a/src/tools/call-omo-agent/sync-executor.test.ts +++ b/src/tools/call-omo-agent/sync-executor.test.ts @@ -9,6 +9,7 @@ describe("executeSync", () => { createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), waitForCompletion: mock(async () => {}), processMessages: mock(async () => "agent response"), + setSessionFallbackChain: mock(() => {}), } let promptArgs: any @@ -53,6 +54,7 @@ describe("executeSync", () => { createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })), waitForCompletion: mock(async () => {}), processMessages: mock(async () => "agent response"), + setSessionFallbackChain: mock(() => {}), } let promptArgs: any @@ -88,4 +90,48 @@ describe("executeSync", () => { expect(promptAsync).toHaveBeenCalled() expect(promptArgs.body.tools.task).toBe(false) }) + + test("applies fallbackChain to sync sessions", async () => { + //#given + const { executeSync } = require("./sync-executor") + + const setSessionFallbackChain = mock(() => {}) + const deps = { + createOrGetSession: mock(async () => ({ sessionID: "ses-test-456", isNew: true })), + waitForCompletion: mock(async () => {}), + processMessages: mock(async () => "agent response"), + setSessionFallbackChain, + } + + const args = { + subagent_type: "explore", + description: "test task", + prompt: "find something", + } + + const toolContext = { + sessionID: "parent-session", + messageID: "msg-3", + agent: "sisyphus", + abort: new AbortController().signal, + metadata: mock(async () => {}), + } + + const ctx = { + client: { + session: { promptAsync: mock(async () => ({ data: {} })) }, + }, + } + + const fallbackChain = [ + { providers: ["quotio"], model: "kimi-k2.5", variant: undefined }, + { providers: ["openai"], model: "gpt-5.2", variant: "high" }, + ] + + //#when + await executeSync(args, toolContext, ctx as any, deps, fallbackChain) + + //#then + expect(setSessionFallbackChain).toHaveBeenCalledWith("ses-test-456", fallbackChain) + }) }) diff --git a/src/tools/call-omo-agent/sync-executor.ts b/src/tools/call-omo-agent/sync-executor.ts index af356cc9a..971a9e410 100644 --- a/src/tools/call-omo-agent/sync-executor.ts +++ b/src/tools/call-omo-agent/sync-executor.ts @@ -1,7 +1,9 @@ import type { CallOmoAgentArgs } from "./types" import type { PluginInput } from "@opencode-ai/plugin" import { log } from "../../shared" +import type { FallbackEntry } from "../../shared/model-requirements" import { getAgentToolRestrictions } from "../../shared" +import { setSessionFallbackChain } from "../../hooks/model-fallback/hook" import { createOrGetSession } from "./session-creator" import { waitForCompletion } from "./completion-poller" import { processMessages } from "./message-processor" @@ -14,12 +16,14 @@ type ExecuteSyncDeps = { createOrGetSession: typeof createOrGetSession waitForCompletion: typeof waitForCompletion processMessages: typeof processMessages + setSessionFallbackChain: typeof setSessionFallbackChain } const defaultDeps: ExecuteSyncDeps = { createOrGetSession, waitForCompletion, processMessages, + setSessionFallbackChain, } export async function executeSync( @@ -32,10 +36,15 @@ export async function executeSync( metadata?: (input: { title?: string; metadata?: Record }) => void }, ctx: PluginInput, - deps: ExecuteSyncDeps = defaultDeps + deps: ExecuteSyncDeps = defaultDeps, + fallbackChain?: FallbackEntry[], ): Promise { const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx) + if (fallbackChain && fallbackChain.length > 0) { + deps.setSessionFallbackChain(sessionID, fallbackChain) + } + await toolContext.metadata?.({ title: args.description, metadata: { sessionId: sessionID }, diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index e9a40dcb9..1338282f1 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -82,19 +82,20 @@ export function createCallOmoAgent( return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.` } + const fallbackChain = resolveFallbackChainForCallOmoAgent({ + subagentType: args.subagent_type, + agentOverrides, + userCategories, + }) + if (args.run_in_background) { if (args.session_id) { return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.` } - const fallbackChain = resolveFallbackChainForCallOmoAgent({ - subagentType: args.subagent_type, - agentOverrides, - userCategories, - }) return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain) } - return await executeSync(args, toolCtx, ctx) + return await executeSync(args, toolCtx, ctx, undefined, fallbackChain) }, }) } diff --git a/src/tools/delegate-task/subagent-resolver.test.ts b/src/tools/delegate-task/subagent-resolver.test.ts index 3eabea58e..28c7a4731 100644 --- a/src/tools/delegate-task/subagent-resolver.test.ts +++ b/src/tools/delegate-task/subagent-resolver.test.ts @@ -108,6 +108,11 @@ describe("resolveSubagentExecution", () => { test("uses agent override fallback_models for subagent runtime fallback chain", async () => { //#given + const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + models: { quotio: ["claude-haiku-4-5"] }, + connected: ["quotio"], + updatedAt: "2026-03-03T00:00:00.000Z", + }) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -131,10 +136,16 @@ describe("resolveSubagentExecution", () => { { providers: ["quotio"], model: "gpt-5.2", variant: undefined }, { providers: ["quotio"], model: "glm-5", variant: "max" }, ]) + cacheSpy.mockRestore() }) test("uses category fallback_models when agent override points at category", async () => { //#given + const cacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue({ + models: { anthropic: ["claude-haiku-4-5"] }, + connected: ["anthropic"], + updatedAt: "2026-03-03T00:00:00.000Z", + }) const args = createBaseArgs({ subagent_type: "explore" }) const executorCtx = createExecutorContext( async () => ([ @@ -162,5 +173,6 @@ describe("resolveSubagentExecution", () => { expect(result.fallbackChain).toEqual([ { providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined }, ]) + cacheSpy.mockRestore() }) })