Browse Source

fix(foreground-fallback): replay v2 user messages and detect quota-threshold errors

Two root causes kept foreground fallback from switching models:

1. execFallback located the last user message with isUserMessageWithParts,
   which requires the v1 { info, parts } shape. Since OpenCode 1.18 the
   plugin SDK's session.messages() returns v2 SessionMessage objects
   ({ type: 'user', text }), so the finder never matched and replay aborted
   with "no user message found". Add isReplayableUserMessage/
   partsFromReplayMessage accepting both shapes, and log messageCount plus
   request error when no replayable message exists (refs #954).

2. Codex quota-threshold errors ("All codex accounts reached configured
   quota threshold") matched no retryable pattern, so the fallback chain
   was never consulted. Add /quota.?threshold/i to RETRYABLE_ERROR_PATTERNS.

Verification: typecheck and biome check:ci pass; foreground-fallback suite
73/73 (3 new tests: quota-threshold classification, v2 replay, mixed
v1/v2). Full suite matches the pre-existing baseline on the base commit.
safeer 1 week ago
parent
commit
da8339d8fd

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

@@ -121,6 +121,20 @@ describe('isFailoverError', () => {
     );
   });
 
+  test('returns true for codex quota-threshold errors', () => {
+    expect(
+      isFailoverError({
+        message:
+          'AI_APICallError: [codex/gpt-5.6-sol-medium] All codex accounts reached configured quota threshold (reset after 20h 41m 59s)',
+      }),
+    ).toBe(true);
+    expect(
+      isFailoverError(
+        'AI_APICallError: [codex/gpt-5.6-sol-medium] All codex accounts reached configured quota threshold (reset after 20h 41m 59s)',
+      ),
+    ).toBe(true);
+  });
+
   test('returns true for "usage exceeded"', () => {
     expect(isRetryableError({ message: 'usage exceeded' })).toBe(true);
   });
@@ -504,6 +518,93 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(call[0].parts[0]?.text).toBe('real prompt');
   });
 
+  test('replays the last user message from v2-shaped session.messages data', async () => {
+    // OpenCode 1.18+ session.messages() returns v2 SessionMessage objects
+    // ({ type, text }) instead of the v1 { info, parts } shape. The fallback
+    // must locate and re-submit the v2 user text even when an assistant
+    // message appears after it.
+    ({ mocks } = createMockClient({
+      messagesData: [
+        { id: 'm1', type: 'user', text: 'v2 prompt' },
+        {
+          id: 'm2',
+          type: 'assistant',
+          parts: [{ type: 'text', text: 'reply' }],
+        },
+      ],
+    }));
+    mgr = new ForegroundFallbackManager(makeChains(), true, {
+      directory: '/test',
+    } as any);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { parts: Array<{ text?: string }> },
+    ];
+    expect(call[0].parts[0]?.text).toBe('v2 prompt');
+  });
+
+  test('prefers the latest user message across mixed v1/v2 shapes', async () => {
+    ({ mocks } = createMockClient({
+      messagesData: [
+        {
+          info: { role: 'user' },
+          parts: [{ type: 'text', text: 'legacy prompt' }],
+        },
+        { id: 'm2', type: 'user', text: 'v2 prompt' },
+      ],
+    }));
+    mgr = new ForegroundFallbackManager(makeChains(), true, {
+      directory: '/test',
+    } as any);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { parts: Array<{ text?: string }> },
+    ];
+    expect(call[0].parts[0]?.text).toBe('v2 prompt');
+  });
+
   test('does nothing when error is not a rate limit', async () => {
     await mgr.handleEvent({
       type: 'session.error',

+ 21 - 9
src/hooks/foreground-fallback/index.ts

@@ -26,7 +26,7 @@ import {
   parseModelReference,
 } from '../../utils/session';
 import type { SessionLifecycle } from '../session-lifecycle';
-import { isUserMessageWithParts } from '../types';
+import { isReplayableUserMessage, partsFromReplayMessage } from '../types';
 
 // ---------------------------------------------------------------------------
 // Retryable error detection
@@ -37,6 +37,7 @@ const RETRYABLE_ERROR_PATTERNS = [
   /rate.?limit/i,
   /too many requests/i,
   /quota.?exceeded/i,
+  /quota.?threshold/i,
   /usage.?exceeded/i,
   /ExceededBudget/i,
   /over.?budget/i,
@@ -624,12 +625,17 @@ export class ForegroundFallbackManager {
         sessionID,
       });
       // result.data may contain partial/streaming messages whose `info` is
-      // undefined at runtime (OpenCode violates its own declared type), so
-      // guard each entry instead of dereferencing `info` directly.
+      // undefined at runtime (OpenCode violates its own declared type), and
+      // v2 messages carry `type`/`text` instead of `info`/`parts`, so guard
+      // each entry instead of dereferencing a fixed shape.
       const messages = (result.data ?? []) as unknown[];
-      const lastUser = [...messages].reverse().find(isUserMessageWithParts);
+      const lastUser = [...messages].reverse().find(isReplayableUserMessage);
       if (!lastUser) {
-        log('[foreground-fallback] no user message found', { sessionID });
+        log('[foreground-fallback] no user message found', {
+          sessionID,
+          messageCount: messages.length,
+          requestError: result.error ?? undefined,
+        });
         return;
       }
 
@@ -641,12 +647,18 @@ export class ForegroundFallbackManager {
         return;
       }
 
+      const replayParts = partsFromReplayMessage(lastUser) as Array<{
+        type: 'text';
+        text: string;
+      }>;
+
       const promptBody = {
-        // ponytail: lastUser.parts are MessagePart[] from API, but v2
-        // promptAsync expects TextPartInput[] — runtime-compatible, TS
-        // doesn't know the `type` field is already 'text'.
+        // ponytail: replayed parts are MessagePart[] from the transform API
+        // or a synthesized v2 text part, but promptAsync expects
+        // TextPartInput[] — runtime-compatible, TS doesn't know the `type`
+        // field is already 'text'.
         parts: [
-          ...(lastUser.parts as Array<{ type: 'text'; text: string }>),
+          ...replayParts,
           createInternalAgentTextPart('Foreground fallback replay.'),
         ],
         model: ref,

+ 40 - 0
src/hooks/types.ts

@@ -58,3 +58,43 @@ export function findLatestUserMessage(
   }
   return undefined;
 }
+
+/**
+ * A user message that can be replayed into a fallback prompt.
+ *
+ * Accepts both the v1 transform shape (`{ info: { role: 'user' }, parts }`)
+ * and the v2 `session.messages()` shape (`{ type: 'user', text }`) returned
+ * by the plugin SDK's HTTP API since OpenCode 1.18.
+ */
+export type ReplayableUserMessage =
+  | MessageWithParts
+  | {
+      type: 'user';
+      text?: string;
+    };
+
+export function isReplayableUserMessage(
+  message: unknown,
+): message is ReplayableUserMessage {
+  if (isUserMessageWithParts(message)) {
+    return true;
+  }
+  if (!message || typeof message !== 'object') {
+    return false;
+  }
+  const candidate = message as { type?: unknown };
+  return candidate.type === 'user';
+}
+
+export function partsFromReplayMessage(
+  message: ReplayableUserMessage,
+): MessagePart[] {
+  const parts = (message as Partial<MessageWithParts>).parts;
+  if (Array.isArray(parts)) {
+    return parts;
+  }
+  const text = (message as { text?: string }).text;
+  return typeof text === 'string' && text.length > 0
+    ? [{ type: 'text', text }]
+    : [];
+}