Sfoglia il codice sorgente

fix(foreground-fallback): honor inline 401/410 on status path, record persistent errors on job board

- session.status retry path now forwards status.message to toast suppression,
  so inline 401/410 (auth, model gone) no longer fire a 'Model fallback' toast
- event-router records persistent 401/410 session errors on the job board
  instead of skipping them as recoverable, preventing false 'completed' via
  idle-reconciliation when the fallback chain is exhausted
- drop stale 'keyed by directory / cached client' docstring on getClient
  (now returns the host-owned in-process client, no caching)
Michael Henke 1 mese fa
parent
commit
1fbbfd838a

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

@@ -1259,6 +1259,39 @@ describe('ForegroundFallbackManager session.status', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
   });
 
+  test('does not toast when 410 signal arrives via status.message with no error property', async () => {
+    const { mocks } = createMockClient();
+    const showToast = mock(async () => ({}));
+    const mgr = new ForegroundFallbackManager(makeChains(), true, {
+      directory: '/test',
+      client: { tui: { showToast } },
+    } as any);
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-status-message-410',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+        },
+      },
+    });
+
+    // The AI SDK surfaces HTTP 410 as a bare retry status message with no
+    // separate error property. The runtime renders it inline — no toast.
+    await mgr.handleEvent({
+      type: 'session.status',
+      properties: {
+        sessionID: 'sess-status-message-410',
+        status: { type: 'retry', attempt: 1, message: 'AI_APICallError: Gone' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    expect(showToast).not.toHaveBeenCalled();
+  });
+
   test('non-rate-limit retry does not trigger fallback but rate-limit does', async () => {
     const { mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(

+ 8 - 1
src/hooks/foreground-fallback/index.ts

@@ -477,7 +477,14 @@ export class ForegroundFallbackManager {
           // Otherwise (attempt === 1, or model didn't change, or outside
           // dedup window): process as genuine retry for current model.
           if (this.shouldTriggerFallback(sessionID)) {
-            await this.tryFallbackWithAbort(sessionID, props.error);
+            // Failover may have been detected from status.message (e.g.
+            // 'AI_APICallError: Gone') with no separate error property;
+            // forward that message so 401/410 inline errors suppress the
+            // toast on this path too, matching session.error behavior.
+            await this.tryFallbackWithAbort(
+              sessionID,
+              props.error ?? { message: props.status?.message ?? '' },
+            );
           }
           break;
         }

+ 17 - 2
src/hooks/task-session-manager/event-router.ts

@@ -9,7 +9,10 @@ import type { BackgroundJobExecution } from '../../utils/background-job-board';
 import type { BackgroundJobStore } from '../../utils/background-job-store';
 import type { BackgroundJobSupervisor } from '../../utils/background-job-supervisor';
 import { log } from '../../utils/logger';
-import { isFailoverError } from '../foreground-fallback/index';
+import {
+  isFailoverError,
+  isInlineFailoverError,
+} from '../foreground-fallback/index';
 import type {
   InjectedTerminalJobs,
   RetainedBoardSnapshotState,
@@ -222,7 +225,19 @@ export async function handleEvent(
       // job state here would make the orchestrator lose track of
       // completed background tasks and unable to dispatch follow-ups.
       const props = input.event.properties as { error?: unknown } | undefined;
-      if (!props?.error || !isFailoverError(props.error)) {
+      // Only clear injected terminal jobs for fatal errors.
+      // Rate-limit errors are recovered by ForegroundFallbackManager
+      // (abort + reprompt with fallback model); clearing the injected
+      // job state here would make the orchestrator lose track of
+      // completed background tasks and unable to dispatch follow-ups.
+      // Persistent 401/410 (auth, model gone) are NOT recovered once the
+      // chain is exhausted, so they must still surface as errors on the
+      // board instead of a false completion via idle-reconciliation.
+      if (
+        !props?.error ||
+        !isFailoverError(props.error) ||
+        isInlineFailoverError(props.error)
+      ) {
         deps.terminalJobsInjectedByParent.delete(sessionId);
         deps.pendingInjectedTerminalJobsByParent.delete(sessionId);
         // Record non-retryable errors on the job board so the

+ 29 - 0
src/hooks/task-session-manager/index.test.ts

@@ -3517,6 +3517,35 @@ describe('task-session-manager hook', () => {
     expect(job?.resultSummary).toBe('LLM proxy connection refused');
   });
 
+  test('persistent 401 session.error on managed session records board error', async () => {
+    // 401/410 are persistent (not recovered once the fallback chain is
+    // exhausted) and must surface as an error, not a false completion.
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'parent-1',
+      parentSessionID: 'root-1',
+      agent: 'orchestrator',
+      description: 'background session',
+    });
+    board.updateStatus({ taskID: 'parent-1', state: 'running' });
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'parent-1',
+          error: { statusCode: 401, message: 'Unauthorized' },
+        },
+      },
+    });
+
+    const job = board.get('parent-1');
+    expect(job?.state).toBe('error');
+    expect(job?.resultSummary).toBe('Unauthorized');
+  });
+
   test('session.idle does not overwrite error state with completed', async () => {
     const board = new BackgroundJobBoard();
     const { hook } = createHook({ backgroundJobBoard: board });

+ 1 - 2
src/utils/opencode-client.ts

@@ -4,8 +4,7 @@ import type { PluginInput } from '@opencode-ai/plugin';
  * Returns the in-process OpenCode client for the given plugin directory.
  * The plugin host provides `input.client` — a direct in-process client into
  * the same OpenCode server the plugin runs inside. No loopback HTTP is
- * involved. Keyed by directory; the server holds session state so a cached
- * client stays valid for the process lifetime.
+ * involved, and no client is cached: the host owns the client lifecycle.
  */
 export function getClient(input: PluginInput): PluginInput['client'] {
   return input.client;