diff --git a/src/hooks/runtime-fallback/index.test.ts b/src/hooks/runtime-fallback/index.test.ts index 44fc790c0..45a432e08 100644 --- a/src/hooks/runtime-fallback/index.test.ts +++ b/src/hooks/runtime-fallback/index.test.ts @@ -387,6 +387,92 @@ describe("runtime-fallback", () => { expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" }) }) + test("should trigger fallback on auto-retry signal in assistant text parts", async () => { + const hook = createRuntimeFallbackHook(createMockPluginInput(), { + config: createMockConfig({ notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]), + }) + + const sessionID = "test-session-parts-auto-retry" + SessionCategoryRegistry.register(sessionID, "test") + + await hook.event({ + event: { + type: "session.created", + properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } }, + }, + }) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + sessionID, + role: "assistant", + model: "quotio/claude-opus-4-6", + }, + parts: [ + { + type: "text", + text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]", + }, + ], + }, + }, + }) + + const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal")) + expect(signalLog).toBeDefined() + + const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback")) + expect(fallbackLog).toBeDefined() + expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" }) + }) + + test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => { + const hook = createRuntimeFallbackHook(createMockPluginInput(), { + config: createMockConfig({ notify_on_fallback: false }), + pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]), + }) + + const sessionID = "test-session-info-parts-auto-retry" + SessionCategoryRegistry.register(sessionID, "test") + + await hook.event({ + event: { + type: "session.created", + properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } }, + }, + }) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + sessionID, + role: "assistant", + model: "quotio/claude-opus-4-6", + parts: [ + { + type: "text", + text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]", + }, + ], + }, + }, + }, + }) + + const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal")) + expect(signalLog).toBeDefined() + + const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback")) + expect(fallbackLog).toBeDefined() + expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" }) + }) + test("should trigger fallback on session.status auto-retry signal", async () => { const promptCalls: unknown[] = [] const hook = createRuntimeFallbackHook( diff --git a/src/hooks/runtime-fallback/message-update-handler.ts b/src/hooks/runtime-fallback/message-update-handler.ts index 7e6130955..9252b7918 100644 --- a/src/hooks/runtime-fallback/message-update-handler.ts +++ b/src/hooks/runtime-fallback/message-update-handler.ts @@ -57,10 +57,20 @@ export function createMessageUpdateHandler(deps: HookDeps, helpers: AutoRetryHel return async (props: Record | undefined) => { const info = props?.info as Record | undefined const sessionID = info?.sessionID as string | undefined - const retrySignalResult = extractAutoRetrySignal(info) - const retrySignal = retrySignalResult?.signal const timeoutEnabled = config.timeout_seconds > 0 - const parts = props?.parts as Array<{ type?: string; text?: string }> | undefined + const eventParts = props?.parts as Array<{ type?: string; text?: string }> | undefined + const infoParts = info?.parts as Array<{ type?: string; text?: string }> | undefined + const parts = eventParts && eventParts.length > 0 ? eventParts : infoParts + const retrySignalResult = extractAutoRetrySignal(info) + const partsText = (parts ?? []) + .filter((p) => typeof p?.text === "string") + .map((p) => (p.text ?? "").trim()) + .filter((text) => text.length > 0) + .join("\n") + const retrySignalFromParts = partsText + ? extractAutoRetrySignal({ message: partsText, status: partsText, summary: partsText })?.signal + : undefined + const retrySignal = retrySignalResult?.signal ?? retrySignalFromParts const errorContentResult = containsErrorContent(parts) const error = info?.error ?? (retrySignal && timeoutEnabled ? { name: "ProviderRateLimitError", message: retrySignal } : undefined) ??