fix(oauth+errors): OAuth silent refresh, quota STOP patterns, compaction loop cap

Bug fixes:
1. OAuth token refresh (#3149): buildHttpRequestInit() now attempts silent refresh
   via refresh_token before triggering full browser re-auth. Added refresh() method
   to McpOAuthProvider. Includes test isolation fix for discovery mock.

2. Quota error STOP (#3126): Added STOP_MESSAGE_PATTERNS in model-error-classifier
   that take precedence over RETRYABLE_MESSAGE_PATTERNS. Message-only quota errors
   now non-retryable. Runtime-fallback: quota_exceeded with 'retrying in' signal
   still triggers fallback (provider-managed auto-retry). Restored removed patterns.

3. Compaction loop (#3127): MAX_RECOVERY_ATTEMPTS=3 cap + additional suppression
   guard from opencode session in degradation monitor.

Also: refactored extractAutoRetrySignal to auto-retry-signal.ts, new regression
tests for quota classifier and compaction degradation monitor.
This commit is contained in:
YeonGyu-Kim
2026-04-06 17:38:37 +09:00
parent 6c4e0b69a5
commit 61083d499d
15 changed files with 611 additions and 531 deletions
+71 -2
View File
@@ -1,14 +1,16 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, mock, spyOn } from "bun:test"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import type { OAuthTokenData } from "../mcp-oauth/storage"
// Mock the MCP SDK transports to avoid network calls
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve())
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
const mockTokens = mock(() => null as { accessToken: string } | null)
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" }) as Promise<{ accessToken: string } | null>)
const mockTokens = mock(() => null as OAuthTokenData | null)
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token" } satisfies OAuthTokenData))
const mockRefresh = mock((_: string) => Promise.resolve({ accessToken: "refreshed-token" } satisfies OAuthTokenData))
async function importFreshManagerModule(): Promise<typeof import("./manager")> {
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
@@ -41,12 +43,14 @@ describe("SkillMcpManager", () => {
createOAuthProvider: () => ({
tokens: () => mockTokens(),
login: () => mockLogin(),
refresh: (refreshToken: string) => mockRefresh(refreshToken),
}),
})
mockHttpConnect.mockClear()
mockHttpClose.mockClear()
mockTokens.mockClear()
mockLogin.mockClear()
mockRefresh.mockClear()
})
afterEach(async () => {
@@ -724,6 +728,71 @@ describe("SkillMcpManager", () => {
expect(headers?.Authorization).toBe("Bearer oauth-token")
})
it("attempts silent refresh for expired stored tokens before login", async () => {
// given
const info: SkillMcpClientInfo = {
serverName: "oauth-refresh",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue({
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Math.floor(Date.now() / 1000) - 60,
})
mockRefresh.mockResolvedValue({ accessToken: "refreshed-token" })
// when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer refreshed-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).not.toHaveBeenCalled()
})
it("falls back to login when silent refresh fails", async () => {
// given
const info: SkillMcpClientInfo = {
serverName: "oauth-refresh-fallback",
skillName: "oauth-skill",
sessionID: "session-oauth-refresh-fallback",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue({
accessToken: "expired-token",
refreshToken: "refresh-token",
expiresAt: Math.floor(Date.now() / 1000) - 60,
})
mockRefresh.mockRejectedValue(new Error("Refresh failed"))
mockLogin.mockResolvedValue({ accessToken: "login-token" })
// when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer login-token")
expect(mockRefresh).toHaveBeenCalledWith("refresh-token")
expect(mockLogin).toHaveBeenCalled()
})
it("does not create auth provider when oauth config is absent", async () => {
// given
const info: SkillMcpClientInfo = {
@@ -44,7 +44,7 @@ export async function buildHttpRequestInit(
const provider = getOrCreateAuthProvider(authProviders, config.url, config.oauth, createOAuthProvider)
let tokenData = provider.tokens()
if (!tokenData || isTokenExpired(tokenData)) {
if (!tokenData) {
try {
tokenData = await provider.login()
} catch {
@@ -52,6 +52,20 @@ export async function buildHttpRequestInit(
}
}
if (tokenData && isTokenExpired(tokenData)) {
try {
tokenData = tokenData.refreshToken
? await provider.refresh(tokenData.refreshToken)
: await provider.login()
} catch {
try {
tokenData = await provider.login()
} catch {
tokenData = null
}
}
}
if (tokenData) {
headers.Authorization = `Bearer ${tokenData.accessToken}`
}
+1 -1
View File
@@ -50,7 +50,7 @@ export interface ProcessCleanupHandler {
export type OAuthProviderLike = Pick<
McpOAuthProvider,
"tokens" | "login"
"tokens" | "login" | "refresh"
>
export type OAuthProviderFactory = (options: {