fix: align sync fallback chain, fix model-fallback test determinism
- Hoist resolveFallbackChainForCallOmoAgent before sync/background branch so sync executor also receives the fallback chain - Add fallbackChain parameter to sync-executor with setSessionFallbackChain - Mock connected-providers-cache in event.model-fallback tests for deterministic behavior (no dependency on local cache files) - Update test expectations to account for no-op fallback skip when normalized current model matches first fallback entry - Add cache spy isolation for subagent-resolver fallback_models tests
This commit is contained in:
@@ -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 { createEventHandler } from "./event"
|
||||||
import { createChatMessageHandler } from "./chat-message"
|
import { createChatMessageHandler } from "./chat-message"
|
||||||
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
|
||||||
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
import { createModelFallbackHook, clearPendingModelFallback } from "../hooks/model-fallback/hook"
|
||||||
|
|
||||||
describe("createEventHandler - model fallback", () => {
|
describe("createEventHandler - model fallback", () => {
|
||||||
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
|
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
|
||||||
const abortCalls: string[] = []
|
const abortCalls: string[] = []
|
||||||
@@ -207,10 +212,10 @@ describe("createEventHandler - model fallback", () => {
|
|||||||
expect(abortCalls).toEqual([sessionID])
|
expect(abortCalls).toEqual([sessionID])
|
||||||
expect(promptCalls).toEqual([sessionID])
|
expect(promptCalls).toEqual([sessionID])
|
||||||
expect(output.message["model"]).toMatchObject({
|
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"]).toBeUndefined()
|
||||||
expect(output.message["variant"]).toBe("max")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not spam abort/prompt when session.status retry countdown updates", async () => {
|
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
|
//#when - first retry cycle
|
||||||
const first = await triggerRetryCycle()
|
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({
|
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"]).toBeUndefined()
|
||||||
expect(first.message["variant"]).toBe("max")
|
|
||||||
|
|
||||||
//#when - second retry cycle
|
//#when - second retry cycle
|
||||||
const second = await triggerRetryCycle()
|
const second = await triggerRetryCycle()
|
||||||
|
|
||||||
//#then - second fallback entry applied (chain advanced)
|
//#then - second fallback entry applied (chain advanced past k2p5)
|
||||||
expect(second.message["model"]).toEqual({
|
expect(second.message["model"]).toMatchObject({
|
||||||
providerID: "kimi-for-coding",
|
modelID: "kimi-k2.5",
|
||||||
modelID: "k2p5",
|
|
||||||
})
|
})
|
||||||
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
|
expect((second.message["model"] as { providerID?: string })?.providerID).toBeTruthy()
|
||||||
expect(second.message["variant"]).toBeUndefined()
|
expect(second.message["variant"]).toBeUndefined()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe("executeSync", () => {
|
|||||||
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
|
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
|
||||||
waitForCompletion: mock(async () => {}),
|
waitForCompletion: mock(async () => {}),
|
||||||
processMessages: mock(async () => "agent response"),
|
processMessages: mock(async () => "agent response"),
|
||||||
|
setSessionFallbackChain: mock(() => {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
let promptArgs: any
|
let promptArgs: any
|
||||||
@@ -53,6 +54,7 @@ describe("executeSync", () => {
|
|||||||
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
|
createOrGetSession: mock(async () => ({ sessionID: "ses-test-123", isNew: true })),
|
||||||
waitForCompletion: mock(async () => {}),
|
waitForCompletion: mock(async () => {}),
|
||||||
processMessages: mock(async () => "agent response"),
|
processMessages: mock(async () => "agent response"),
|
||||||
|
setSessionFallbackChain: mock(() => {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
let promptArgs: any
|
let promptArgs: any
|
||||||
@@ -88,4 +90,48 @@ describe("executeSync", () => {
|
|||||||
expect(promptAsync).toHaveBeenCalled()
|
expect(promptAsync).toHaveBeenCalled()
|
||||||
expect(promptArgs.body.tools.task).toBe(false)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
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 { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
import { getAgentToolRestrictions } from "../../shared"
|
import { getAgentToolRestrictions } from "../../shared"
|
||||||
|
import { setSessionFallbackChain } from "../../hooks/model-fallback/hook"
|
||||||
import { createOrGetSession } from "./session-creator"
|
import { createOrGetSession } from "./session-creator"
|
||||||
import { waitForCompletion } from "./completion-poller"
|
import { waitForCompletion } from "./completion-poller"
|
||||||
import { processMessages } from "./message-processor"
|
import { processMessages } from "./message-processor"
|
||||||
@@ -14,12 +16,14 @@ type ExecuteSyncDeps = {
|
|||||||
createOrGetSession: typeof createOrGetSession
|
createOrGetSession: typeof createOrGetSession
|
||||||
waitForCompletion: typeof waitForCompletion
|
waitForCompletion: typeof waitForCompletion
|
||||||
processMessages: typeof processMessages
|
processMessages: typeof processMessages
|
||||||
|
setSessionFallbackChain: typeof setSessionFallbackChain
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultDeps: ExecuteSyncDeps = {
|
const defaultDeps: ExecuteSyncDeps = {
|
||||||
createOrGetSession,
|
createOrGetSession,
|
||||||
waitForCompletion,
|
waitForCompletion,
|
||||||
processMessages,
|
processMessages,
|
||||||
|
setSessionFallbackChain,
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function executeSync(
|
export async function executeSync(
|
||||||
@@ -32,10 +36,15 @@ export async function executeSync(
|
|||||||
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
|
||||||
},
|
},
|
||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
deps: ExecuteSyncDeps = defaultDeps
|
deps: ExecuteSyncDeps = defaultDeps,
|
||||||
|
fallbackChain?: FallbackEntry[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
|
const { sessionID } = await deps.createOrGetSession(args, toolContext, ctx)
|
||||||
|
|
||||||
|
if (fallbackChain && fallbackChain.length > 0) {
|
||||||
|
deps.setSessionFallbackChain(sessionID, fallbackChain)
|
||||||
|
}
|
||||||
|
|
||||||
await toolContext.metadata?.({
|
await toolContext.metadata?.({
|
||||||
title: args.description,
|
title: args.description,
|
||||||
metadata: { sessionId: sessionID },
|
metadata: { sessionId: sessionID },
|
||||||
|
|||||||
@@ -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.`
|
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.run_in_background) {
|
||||||
if (args.session_id) {
|
if (args.session_id) {
|
||||||
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
|
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 executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return await executeSync(args, toolCtx, ctx)
|
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ describe("resolveSubagentExecution", () => {
|
|||||||
|
|
||||||
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
|
test("uses agent override fallback_models for subagent runtime fallback chain", async () => {
|
||||||
//#given
|
//#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 args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -131,10 +136,16 @@ describe("resolveSubagentExecution", () => {
|
|||||||
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
|
{ providers: ["quotio"], model: "gpt-5.2", variant: undefined },
|
||||||
{ providers: ["quotio"], model: "glm-5", variant: "max" },
|
{ providers: ["quotio"], model: "glm-5", variant: "max" },
|
||||||
])
|
])
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("uses category fallback_models when agent override points at category", async () => {
|
test("uses category fallback_models when agent override points at category", async () => {
|
||||||
//#given
|
//#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 args = createBaseArgs({ subagent_type: "explore" })
|
||||||
const executorCtx = createExecutorContext(
|
const executorCtx = createExecutorContext(
|
||||||
async () => ([
|
async () => ([
|
||||||
@@ -162,5 +173,6 @@ describe("resolveSubagentExecution", () => {
|
|||||||
expect(result.fallbackChain).toEqual([
|
expect(result.fallbackChain).toEqual([
|
||||||
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
|
{ providers: ["anthropic"], model: "claude-haiku-4-5", variant: undefined },
|
||||||
])
|
])
|
||||||
|
cacheSpy.mockRestore()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user