Explorar el Código

fix(foreground-fallback): check props.error for rate-limit in session.status

OpenCode may carry rate-limit text in props.error instead of
props.status.message. The handler only checked status.message, so
sessions with error-field rate limits stayed on the exhausted model.

Also removed the else branch that cleared sessionRetries on non-rate-limit
statuses — abort events from our own fallback carry non-rate-limit
messages and would reset the counter, creating an infinite loop.
Michael Henke hace 1 mes
padre
commit
d4941eb267

+ 27 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -533,6 +533,33 @@ describe('ForegroundFallbackManager session.status', () => {
     });
     });
     expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
     expect(mocks.promptAsync).toHaveBeenCalledTimes(2);
   });
   });
+
+  test('triggers fallback when rate-limit text is in props.error instead of status.message', async () => {
+    const { client, mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(client, makeChains(), true, 3);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-error-field',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    // status.message is benign but props.error carries the rate-limit signal
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-error-field',
+        status: { type: 'retry', attempt: 1, message: 'retrying...' },
+        error: { message: 'Usage exceeded for this billing period' },
+      },
+    });
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+  });
 });
 });
 
 
 // ---------------------------------------------------------------------------
 // ---------------------------------------------------------------------------

+ 16 - 13
src/hooks/foreground-fallback/index.ts

@@ -206,21 +206,24 @@ export class ForegroundFallbackManager {
           | {
           | {
               sessionID?: string;
               sessionID?: string;
               status?: { type?: string; message?: string; attempt?: number };
               status?: { type?: string; message?: string; attempt?: number };
+              error?: unknown;
             }
             }
           | undefined;
           | undefined;
-        if (!props?.sessionID || !props.status?.message) break;
-        const msg = props.status.message.toLowerCase();
-        if (
-          msg.includes('rate limit') ||
-          msg.includes('usage limit') ||
-          msg.includes('usage exceeded') ||
-          msg.includes('quota exceeded') ||
-          msg.includes('exceededbudget') ||
-          msg.includes('over budget') ||
-          msg.includes('insufficient') ||
-          msg.includes('high concurrency') ||
-          msg.includes('reduce concurrency')
-        ) {
+        if (!props?.sessionID) break;
+        const msg = props.status?.message?.toLowerCase() ?? '';
+        const isRateLimit =
+          (msg &&
+            (msg.includes('rate limit') ||
+              msg.includes('usage limit') ||
+              msg.includes('usage exceeded') ||
+              msg.includes('quota exceeded') ||
+              msg.includes('exceededbudget') ||
+              msg.includes('over budget') ||
+              msg.includes('insufficient') ||
+              msg.includes('high concurrency') ||
+              msg.includes('reduce concurrency'))) ||
+          isRateLimitError(props.error);
+        if (isRateLimit) {
           // Abort retry loop before falling back — promptAsync alone
           // Abort retry loop before falling back — promptAsync alone
           // is ignored when the session is in retry mode.
           // is ignored when the session is in retry mode.
           if (this.shouldIntervene(props.sessionID)) {
           if (this.shouldIntervene(props.sessionID)) {