2026-03-09 13:11:03 +09:00
|
|
|
declare const require: (name: string) => any
|
2026-04-04 02:35:23 +09:00
|
|
|
const { afterEach, describe, expect, spyOn, test } = require("bun:test")
|
2026-04-03 23:06:35 +09:00
|
|
|
|
2026-02-19 04:41:00 +02:00
|
|
|
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"
|
2026-04-04 02:35:23 +09:00
|
|
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
2026-05-12 15:38:31 +09:00
|
|
|
import { unsafeTestValue } from "../../test-support/unsafe-test-value"
|
2026-04-04 02:35:23 +09:00
|
|
|
|
|
|
|
|
let readConnectedProvidersCacheSpy: { mockRestore: () => void } | undefined
|
|
|
|
|
let readProviderModelsCacheSpy: { mockRestore: () => void } | undefined
|
|
|
|
|
|
|
|
|
|
function setupConnectedProviderCacheMocks(): void {
|
|
|
|
|
readConnectedProvidersCacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
|
|
|
|
readProviderModelsCacheSpy = spyOn(connectedProvidersCache, "readProviderModelsCache").mockReturnValue(null)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 04:41:00 +02:00
|
|
|
describe("createEventHandler - model fallback", () => {
|
2026-03-04 18:35:09 +01:00
|
|
|
const createHandler = (args?: { hooks?: any; pluginConfig?: any }) => {
|
2026-04-04 02:35:23 +09:00
|
|
|
setupConnectedProviderCacheMocks()
|
2026-02-19 04:41:00 +02:00
|
|
|
const abortCalls: string[] = []
|
|
|
|
|
const promptCalls: string[] = []
|
|
|
|
|
|
|
|
|
|
const handler = createEventHandler({
|
2026-05-12 15:38:31 +09:00
|
|
|
ctx: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
abortCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
prompt: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
promptCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
pluginConfig: unsafeTestValue((args?.pluginConfig ?? {})),
|
2026-02-19 04:41:00 +02:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-05-12 15:38:31 +09:00
|
|
|
managers: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
hooks: args?.hooks ?? (unsafeTestValue({})),
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
|
|
|
|
|
2026-02-20 00:02:17 +02:00
|
|
|
return { handler, abortCalls, promptCalls }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
2026-04-04 02:35:23 +09:00
|
|
|
readConnectedProvidersCacheSpy?.mockRestore()
|
|
|
|
|
readProviderModelsCacheSpy?.mockRestore()
|
|
|
|
|
readConnectedProvidersCacheSpy = undefined
|
|
|
|
|
readProviderModelsCacheSpy = undefined
|
2026-02-20 00:02:17 +02:00
|
|
|
_resetForTesting()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("triggers retry prompt for assistant message.updated APIError payloads (headless resume)", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_message_updated_fallback"
|
2026-02-22 17:25:04 +09:00
|
|
|
const modelFallback = createModelFallbackHook()
|
|
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
2026-02-20 00:02:17 +02:00
|
|
|
|
2026-02-19 04:41:00 +02:00
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_err_1",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
time: { created: 1, completed: 2 },
|
|
|
|
|
error: {
|
|
|
|
|
name: "APIError",
|
|
|
|
|
data: {
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-19 04:41:00 +02:00
|
|
|
isRetryable: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
parentID: "msg_user_1",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-02-21 03:03:57 +09:00
|
|
|
providerID: "anthropic",
|
2026-04-07 10:08:04 +09:00
|
|
|
mode: "Sisyphus - Ultraworker",
|
|
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-02-19 04:41:00 +02:00
|
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("triggers retry prompt for nested model error payloads", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_main_fallback_nested"
|
|
|
|
|
setMainSession(sessionID)
|
2026-02-22 17:25:04 +09:00
|
|
|
const modelFallback = createModelFallbackHook()
|
|
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
2026-02-19 04:41:00 +02:00
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: {
|
|
|
|
|
name: "UnknownError",
|
|
|
|
|
data: {
|
|
|
|
|
error: {
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-19 04:41:00 +02:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("triggers retry prompt on session.status retry events and applies fallback", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_status_retry_fallback"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-02-19 04:41:00 +02:00
|
|
|
|
2026-02-20 00:02:17 +02:00
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
2026-02-19 04:41:00 +02:00
|
|
|
|
|
|
|
|
const chatMessageHandler = createChatMessageHandler({
|
2026-05-12 15:38:31 +09:00
|
|
|
ctx: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async () => ({}),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
pluginConfig: unsafeTestValue({}),
|
2026-02-19 04:41:00 +02:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
shouldOverride: () => false,
|
|
|
|
|
markApplied: () => {},
|
|
|
|
|
},
|
2026-05-12 15:38:31 +09:00
|
|
|
hooks: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
modelFallback,
|
|
|
|
|
stopContinuationGuard: null,
|
|
|
|
|
keywordDetector: null,
|
|
|
|
|
claudeCodeHooks: null,
|
|
|
|
|
autoSlashCommand: null,
|
|
|
|
|
startWork: null,
|
|
|
|
|
ralphLoop: null,
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_user_status_1",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
|
|
|
|
time: { created: 1 },
|
|
|
|
|
content: [],
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-02-21 03:03:57 +09:00
|
|
|
providerID: "anthropic",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-02-19 04:41:00 +02:00
|
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-19 04:41:00 +02:00
|
|
|
next: 1234,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
|
|
|
|
await chatMessageHandler(
|
|
|
|
|
{
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: "sisyphus",
|
2026-04-17 14:51:52 +09:00
|
|
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
|
2026-02-19 04:41:00 +02:00
|
|
|
},
|
|
|
|
|
output,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID])
|
2026-03-04 18:35:09 +01:00
|
|
|
expect(output.message["model"]).toMatchObject({
|
2026-03-13 10:48:05 +09:00
|
|
|
providerID: "opencode-go",
|
2026-04-23 11:13:45 +02:00
|
|
|
modelID: "kimi-k2.6",
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
2026-03-09 13:11:03 +09:00
|
|
|
expect(output.message["variant"]).toBeUndefined()
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
|
|
|
|
|
2026-03-04 18:35:09 +01:00
|
|
|
test("does not spam abort/prompt when session.status retry countdown updates", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_status_retry_dedup"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-03-04 18:35:09 +01:00
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback } })
|
|
|
|
|
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_user_status_dedup",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-03-04 18:35:09 +01:00
|
|
|
providerID: "anthropic",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-03-04 18:35:09 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
|
2026-03-04 18:35:09 +01:00
|
|
|
next: 300,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~4 days attempt #1]",
|
2026-03-04 18:35:09 +01:00
|
|
|
next: 299,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("does not trigger model-fallback from session.status when runtime_fallback is enabled", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_status_retry_runtime_enabled"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-03-04 18:35:09 +01:00
|
|
|
const runtimeFallback = {
|
|
|
|
|
event: async () => {},
|
|
|
|
|
"chat.message": async () => {},
|
|
|
|
|
}
|
|
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({
|
|
|
|
|
hooks: { modelFallback, runtimeFallback },
|
|
|
|
|
pluginConfig: { runtime_fallback: { enabled: true } },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_user_status_runtime_enabled",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7",
|
2026-03-04 18:35:09 +01:00
|
|
|
providerID: "quotio",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-03-04 18:35:09 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"All credentials for model claude-opus-4-7 are cooling down [retrying in 7m 56s attempt #1]",
|
2026-03-04 18:35:09 +01:00
|
|
|
next: 476,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([])
|
|
|
|
|
expect(promptCalls).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test("prefers user-configured fallback_models over hardcoded chain on session.status retry", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_status_retry_user_fallback"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-03-04 18:35:09 +01:00
|
|
|
const pluginConfig = {
|
|
|
|
|
agents: {
|
|
|
|
|
sisyphus: {
|
|
|
|
|
fallback_models: ["quotio/gpt-5.2", "quotio/kimi-k2.5"],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const { handler, abortCalls, promptCalls } = createHandler({ hooks: { modelFallback }, pluginConfig })
|
|
|
|
|
|
|
|
|
|
const chatMessageHandler = createChatMessageHandler({
|
2026-05-12 15:38:31 +09:00
|
|
|
ctx: unsafeTestValue({
|
2026-03-04 18:35:09 +01:00
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async () => ({}),
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
pluginConfig: unsafeTestValue({}),
|
2026-03-04 18:35:09 +01:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
shouldOverride: () => false,
|
|
|
|
|
markApplied: () => {},
|
|
|
|
|
},
|
2026-05-12 15:38:31 +09:00
|
|
|
hooks: unsafeTestValue({
|
2026-03-04 18:35:09 +01:00
|
|
|
modelFallback,
|
|
|
|
|
stopContinuationGuard: null,
|
|
|
|
|
keywordDetector: null,
|
|
|
|
|
claudeCodeHooks: null,
|
|
|
|
|
autoSlashCommand: null,
|
|
|
|
|
startWork: null,
|
|
|
|
|
ralphLoop: null,
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-03-04 18:35:09 +01:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_user_status_user_fallback",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "user",
|
|
|
|
|
time: { created: 1 },
|
|
|
|
|
content: [],
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7",
|
2026-03-04 18:35:09 +01:00
|
|
|
providerID: "quotio",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-03-04 18:35:09 +01:00
|
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.status",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
status: {
|
|
|
|
|
type: "retry",
|
|
|
|
|
attempt: 1,
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"All credentials for model claude-opus-4-7-thinking are cooling down [retrying in ~5 days attempt #1]",
|
2026-03-04 18:35:09 +01:00
|
|
|
next: 300,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
|
|
|
|
await chatMessageHandler(
|
|
|
|
|
{
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: "sisyphus",
|
2026-04-17 14:51:52 +09:00
|
|
|
model: { providerID: "quotio", modelID: "claude-opus-4-7" },
|
2026-03-04 18:35:09 +01:00
|
|
|
},
|
|
|
|
|
output,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//#then
|
|
|
|
|
expect(abortCalls).toEqual([sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID])
|
|
|
|
|
expect(output.message["model"]).toEqual({
|
|
|
|
|
providerID: "quotio",
|
|
|
|
|
modelID: "gpt-5.2",
|
|
|
|
|
})
|
|
|
|
|
expect(output.message["variant"]).toBeUndefined()
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-19 04:41:00 +02:00
|
|
|
test("advances main-session fallback chain across repeated session.error retries end-to-end", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const abortCalls: string[] = []
|
|
|
|
|
const promptCalls: string[] = []
|
|
|
|
|
const toastCalls: string[] = []
|
|
|
|
|
const sessionID = "ses_main_fallback_chain"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const modelFallback = createModelFallbackHook()
|
2026-04-18 02:35:46 +09:00
|
|
|
clearPendingModelFallback(modelFallback, sessionID)
|
2026-02-19 04:41:00 +02:00
|
|
|
|
2026-04-04 02:35:23 +09:00
|
|
|
setupConnectedProviderCacheMocks()
|
2026-02-19 04:41:00 +02:00
|
|
|
const eventHandler = createEventHandler({
|
2026-05-12 15:38:31 +09:00
|
|
|
ctx: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
directory: "/tmp",
|
|
|
|
|
client: {
|
|
|
|
|
session: {
|
|
|
|
|
abort: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
abortCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
prompt: async ({ path }: { path: { id: string } }) => {
|
|
|
|
|
promptCalls.push(path.id)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
pluginConfig: unsafeTestValue({}),
|
2026-02-19 04:41:00 +02:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
markSessionCreated: () => {},
|
|
|
|
|
clear: () => {},
|
|
|
|
|
},
|
2026-05-12 15:38:31 +09:00
|
|
|
managers: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
tmuxSessionManager: {
|
|
|
|
|
onSessionCreated: async () => {},
|
|
|
|
|
onSessionDeleted: async () => {},
|
|
|
|
|
},
|
|
|
|
|
skillMcpManager: {
|
|
|
|
|
disconnectSession: async () => {},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
hooks: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
modelFallback,
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const chatMessageHandler = createChatMessageHandler({
|
2026-05-12 15:38:31 +09:00
|
|
|
ctx: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
client: {
|
|
|
|
|
tui: {
|
|
|
|
|
showToast: async ({ body }: { body: { title?: string } }) => {
|
|
|
|
|
if (body?.title) toastCalls.push(body.title)
|
|
|
|
|
return {}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-05-12 15:38:31 +09:00
|
|
|
pluginConfig: unsafeTestValue({}),
|
2026-02-19 04:41:00 +02:00
|
|
|
firstMessageVariantGate: {
|
|
|
|
|
shouldOverride: () => false,
|
|
|
|
|
markApplied: () => {},
|
|
|
|
|
},
|
2026-05-12 15:38:31 +09:00
|
|
|
hooks: unsafeTestValue({
|
2026-02-19 04:41:00 +02:00
|
|
|
modelFallback,
|
|
|
|
|
stopContinuationGuard: null,
|
|
|
|
|
keywordDetector: null,
|
|
|
|
|
claudeCodeHooks: null,
|
|
|
|
|
autoSlashCommand: null,
|
|
|
|
|
startWork: null,
|
|
|
|
|
ralphLoop: null,
|
2026-05-12 14:22:30 +09:00
|
|
|
}),
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const triggerRetryCycle = async () => {
|
|
|
|
|
await eventHandler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
2026-02-21 03:03:57 +09:00
|
|
|
providerID: "anthropic",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-02-19 04:41:00 +02:00
|
|
|
error: {
|
|
|
|
|
name: "UnknownError",
|
|
|
|
|
data: {
|
|
|
|
|
error: {
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-19 04:41:00 +02:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const output = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
|
|
|
|
|
await chatMessageHandler(
|
|
|
|
|
{
|
|
|
|
|
sessionID,
|
|
|
|
|
agent: "sisyphus",
|
2026-04-17 14:51:52 +09:00
|
|
|
model: { providerID: "anthropic", modelID: "claude-opus-4-7-thinking" },
|
2026-02-19 04:41:00 +02:00
|
|
|
},
|
|
|
|
|
output,
|
|
|
|
|
)
|
|
|
|
|
return output
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//#when - first retry cycle
|
|
|
|
|
const first = await triggerRetryCycle()
|
|
|
|
|
|
2026-04-17 14:51:52 +09:00
|
|
|
//#then - first fallback entry applied (no-op skip: claude-opus-4-7 matches current model after normalization)
|
2026-03-04 18:35:09 +01:00
|
|
|
expect(first.message["model"]).toMatchObject({
|
2026-03-13 10:48:05 +09:00
|
|
|
providerID: "opencode-go",
|
2026-04-23 11:13:45 +02:00
|
|
|
modelID: "kimi-k2.6",
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
2026-03-09 13:11:03 +09:00
|
|
|
expect(first.message["variant"]).toBeUndefined()
|
2026-02-19 04:41:00 +02:00
|
|
|
|
|
|
|
|
//#when - second retry cycle
|
|
|
|
|
const second = await triggerRetryCycle()
|
|
|
|
|
|
2026-04-23 11:13:45 +02:00
|
|
|
//#then - second fallback entry applied (chain advanced past opencode-go/kimi-k2.6)
|
2026-03-09 13:11:03 +09:00
|
|
|
expect(second.message["model"]).toMatchObject({
|
2026-03-13 10:48:05 +09:00
|
|
|
providerID: "kimi-for-coding",
|
|
|
|
|
modelID: "k2p5",
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|
2026-02-21 03:33:42 +09:00
|
|
|
expect(second.message["variant"]).toBeUndefined()
|
2026-02-19 04:41:00 +02:00
|
|
|
expect(abortCalls).toEqual([sessionID, sessionID])
|
|
|
|
|
expect(promptCalls).toEqual([sessionID, sessionID])
|
|
|
|
|
expect(toastCalls.length).toBeGreaterThanOrEqual(0)
|
|
|
|
|
})
|
2026-02-22 17:25:04 +09:00
|
|
|
|
|
|
|
|
test("does not trigger model-fallback retry when modelFallback hook is not provided (disabled by default)", async () => {
|
|
|
|
|
//#given
|
|
|
|
|
const sessionID = "ses_disabled_by_default"
|
|
|
|
|
setMainSession(sessionID)
|
|
|
|
|
const { handler, abortCalls, promptCalls } = createHandler()
|
|
|
|
|
|
|
|
|
|
//#when - message.updated with assistant error
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "message.updated",
|
|
|
|
|
properties: {
|
|
|
|
|
info: {
|
|
|
|
|
id: "msg_err_disabled_1",
|
|
|
|
|
sessionID,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
time: { created: 1, completed: 2 },
|
|
|
|
|
error: {
|
|
|
|
|
name: "APIError",
|
|
|
|
|
data: {
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-22 17:25:04 +09:00
|
|
|
isRetryable: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
parentID: "msg_user_disabled_1",
|
2026-04-17 14:51:52 +09:00
|
|
|
modelID: "claude-opus-4-7-thinking",
|
2026-02-22 17:25:04 +09:00
|
|
|
providerID: "anthropic",
|
2026-04-07 10:08:04 +09:00
|
|
|
agent: "Sisyphus - Ultraworker",
|
2026-02-22 17:25:04 +09:00
|
|
|
path: { cwd: "/tmp", root: "/tmp" },
|
|
|
|
|
cost: 0,
|
|
|
|
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#when - session.error with retryable error
|
|
|
|
|
await handler({
|
|
|
|
|
event: {
|
|
|
|
|
type: "session.error",
|
|
|
|
|
properties: {
|
|
|
|
|
sessionID,
|
|
|
|
|
error: {
|
|
|
|
|
name: "UnknownError",
|
|
|
|
|
data: {
|
|
|
|
|
error: {
|
|
|
|
|
message:
|
2026-04-17 14:51:52 +09:00
|
|
|
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-7-thinking\"}}",
|
2026-02-22 17:25:04 +09:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
//#then - no abort or prompt calls should have been made
|
|
|
|
|
expect(abortCalls).toEqual([])
|
|
|
|
|
expect(promptCalls).toEqual([])
|
|
|
|
|
})
|
2026-02-19 04:41:00 +02:00
|
|
|
})
|