Browse Source

fix(foreground-fallback): don't bleed into other agent chains via model-matching

resolveChain allowed known agents without a chain to fall through to
model-matching, inheriting another agent's chain when they shared a
model. This caused the Build agent to inherit the Orchestrator chain
and switch sessions to Orchestrator on fallback.

Any known agent without a configured chain now returns [] — no
fallback, no agent switch. Model-matching is only used when the
agent name is completely unknown (undefined).
Michael Henke 1 month ago
parent
commit
df00ae3f05
2 changed files with 18 additions and 32 deletions
  1. 11 18
      src/hooks/foreground-fallback/index.test.ts
  2. 7 14
      src/hooks/foreground-fallback/index.ts

+ 11 - 18
src/hooks/foreground-fallback/index.test.ts

@@ -1025,11 +1025,10 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
     expect(call[0].body.model.modelID).toBe('gpt-4o');
   });
 
-  test('falls through to model matching for non-omos agents (e.g. compaction)', async () => {
-    // compaction is an OpenCode built-in agent that is NOT an omos agent,
-    // so it has no chain configured. It should fall through to model
-    // matching and inherit a chain from a configured agent that shares
-    // its model, instead of being silently excluded from fallback.
+  test('does NOT bleed into other agent chains for non-omos agents without a chain', async () => {
+    // A user-defined agent (e.g. Build) shares its model with the orchestrator
+    // chain but has no chain of its own. It must NOT inherit the orchestrator
+    // chain — that would switch the session from Build to Orchestrator.
     const { client, mocks } = createMockClient();
     const mgr = new ForegroundFallbackManager(
       client,
@@ -1041,8 +1040,8 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
       type: 'message.updated',
       properties: {
         info: {
-          sessionID: 'compaction-sess',
-          agent: 'compaction', // NOT a known omos built-in agent
+          sessionID: 'build-sess',
+          agent: 'build',
           providerID: 'openai',
           modelID: 'gpt-5.6',
           error: { message: 'rate limit exceeded' },
@@ -1050,14 +1049,8 @@ describe('ForegroundFallbackManager resolveChain cross-agent isolation', () => {
       },
     });
 
-    // compaction's model (openai/gpt-5.6) matches orchestrator's chain
-    // → should fall back to the next untried model in that chain
-    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
-    const call = mocks.promptAsync.mock.calls[0] as [
-      { body: { model: { providerID: string; modelID: string } } },
-    ];
-    expect(call[0].body.model.providerID).toBe('new-api');
-    expect(call[0].body.model.modelID).toBe('glm-5.2');
+    // build has no configured chain and must not inherit orchestrator's
+    expect(mocks.promptAsync).not.toHaveBeenCalled();
   });
 });
 
@@ -1212,14 +1205,14 @@ describe('ForegroundFallbackManager runtimeOverride', () => {
       false, // runtimeOverride
     );
 
-    // Simulate unknown agent (e.g. "compaction") using a model that IS in
-    // the orchestrator chain — resolveChain infers the chain from the model.
+    // Simulate an agent name OpenCode doesn't report (agent field absent).
+    // resolveChain falls through to model-matching, finds the chain that
+    // contains openai/gpt-4o (orchestrator's chain).
     await mgr.handleEvent({
       type: 'message.updated',
       properties: {
         info: {
           sessionID: 'sess-5',
-          agent: 'compaction',
           providerID: 'openai',
           modelID: 'gpt-4o',
           error: { message: 'rate limit exceeded' },

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

@@ -15,7 +15,6 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
-import { ALL_AGENT_NAMES } from '../../config/constants';
 import { log } from '../../utils/logger';
 import {
   abortSessionWithTimeout,
@@ -533,9 +532,8 @@ export class ForegroundFallbackManager {
    *
    * Priority:
    * 1. Agent name known AND has a configured chain → return it directly
-   * 2. Agent name known but NO chain configured → return [] (no fallback;
-   *    do NOT bleed into other agents' chains which would re-prompt the
-   *    session with a model belonging to a completely different agent)
+   * 2. Agent name known but NO chain → return [] (no fallback; never
+   *    bleed into other agents' chains)
    * 3. Agent name unknown, current model known → search all chains for
    *    the model to infer which chain to use
    * 4. Nothing matches → flatten all chains as a last resort (only
@@ -546,18 +544,13 @@ export class ForegroundFallbackManager {
     currentModel: string | undefined,
   ): string[] {
     if (agentName) {
-      // Agent is known: use its chain exactly if configured.
       const chain = this.chains[agentName];
       if (chain) return chain;
-      // Known omos built-in agent (oracle, librarian, …) without a
-      // configured chain: keep isolation - do NOT bleed into other
-      // agents' chains (preserves the cross-agent isolation contract
-      // from PR #199).
-      if ((ALL_AGENT_NAMES as readonly string[]).includes(agentName)) return [];
-      // Unknown agent (e.g. OpenCode built-in "compaction" or "title"
-      // that don't appear in the user preset): fall through to
-      // model-matching so they can inherit a chain from a configured
-      // agent that shares their model.
+      // Any known agent without a configured chain: no fallback.
+      // Don't bleed into other agents' chains via model-matching —
+      // that switches the session to the wrong agent (e.g. Build
+      // inherits Orchestrator's chain and becomes Orchestrator).
+      return [];
     }
 
     // Agent unknown: try to infer from the current model.