Jelajahi Sumber

fix(foreground-fallback): guard last-user lookup against messages missing info

tryFallback() fetched session messages and dereferenced m.info.role
directly. OpenCode can return partial/streaming messages whose info is
undefined at runtime (violating its declared type), which would throw
the same TypeError this PR guards elsewhere. Reuse isUserMessageWithParts
so malformed entries are skipped, and add a regression test.
Zaradacht Taifour 1 bulan lalu
induk
melakukan
697b6685c7

+ 45 - 1
src/hooks/foreground-fallback/index.test.ts

@@ -13,7 +13,7 @@ function createMockClient(overrides?: {
   promptAsyncImpl?: (args: unknown) => Promise<unknown>;
   abortImpl?: () => Promise<unknown>;
   includePromptAsync?: boolean;
-  messagesData?: Array<{ info: { role: string }; parts: unknown[] }>;
+  messagesData?: unknown[];
 }) {
   const promptAsync = mock(async (args: unknown) => {
     if (overrides?.promptAsyncImpl) return overrides.promptAsyncImpl(args);
@@ -174,6 +174,50 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(call[0].body.model.modelID).toBe('gpt-4o');
   });
 
+  test('skips malformed messages without info when locating the last user message', async () => {
+    // OpenCode may return partial/streaming messages whose `info` is undefined;
+    // the fallback must ignore those rather than crash, and still re-submit the
+    // real last user message.
+    ({ client, mocks } = createMockClient({
+      messagesData: [
+        {},
+        { info: { role: 'assistant' }, parts: [] },
+        { parts: [{ type: 'text', text: 'no info' }] },
+        {
+          info: { role: 'user' },
+          parts: [{ type: 'text', text: 'real prompt' }],
+        },
+      ],
+    }));
+    mgr = new ForegroundFallbackManager(client, makeChains(), true);
+
+    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 [
+      { body: { parts: Array<{ text?: string }> } },
+    ];
+    expect(call[0].body.parts[0]?.text).toBe('real prompt');
+  });
+
   test('does nothing when error is not a rate limit', async () => {
     await mgr.handleEvent({
       type: 'session.error',

+ 6 - 7
src/hooks/foreground-fallback/index.ts

@@ -21,6 +21,7 @@ import {
   abortSessionWithTimeout,
   parseModelReference,
 } from '../../utils/session';
+import { isUserMessageWithParts } from '../types';
 
 type OpencodeClient = PluginInput['client'];
 
@@ -284,13 +285,11 @@ export class ForegroundFallbackManager {
       const result = await this.client.session.messages({
         path: { id: sessionID },
       });
-      const messages = (result.data ?? []) as Array<{
-        info: { role: string };
-        parts: unknown[];
-      }>;
-      const lastUser = [...messages]
-        .reverse()
-        .find((m) => m.info.role === 'user');
+      // 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.
+      const messages = (result.data ?? []) as unknown[];
+      const lastUser = [...messages].reverse().find(isUserMessageWithParts);
       if (!lastUser) {
         log('[foreground-fallback] no user message found', { sessionID });
         return;