Kaynağa Gözat

feat: agent display names with custom @ invocations (#325)

* feat: show configured display name for agent

* fix: normalize displayName in prompt injection

- Fix P1 bug where raw displayName was used in injectDisplayNames
- Users with '@' prefix or whitespace in displayName would cause
  malformed agent mentions (@@name or @ name) in orchestrator prompt
- Add tests for @-prefixed and whitespace-padded displayName normalization
Nguyen Canh Toan 3 ay önce
ebeveyn
işleme
a2f8d162ef

+ 2 - 0
.gitignore

@@ -56,6 +56,8 @@ GOAL.md
 GOALS.md
 PR-NOTES.md
 REVIEW.md
+docs/plans
+docs/superpowers
 
 # Python
 __pycache__/

+ 8 - 0
oh-my-opencode-slim.schema.json

@@ -265,6 +265,10 @@
                 "type": "string"
               },
               "additionalProperties": {}
+            },
+            "displayName": {
+              "type": "string",
+              "minLength": 1
             }
           }
         }
@@ -335,6 +339,10 @@
               "type": "string"
             },
             "additionalProperties": {}
+          },
+          "displayName": {
+            "type": "string",
+            "minLength": 1
           }
         }
       }

+ 203 - 0
src/agents/display-name.test.ts

@@ -0,0 +1,203 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createAgents, getAgentConfigs } from './index';
+
+describe('displayName', () => {
+  test('stores displayName on agent when configured', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.displayName).toBe('researcher');
+
+    const sdkConfigs = getAgentConfigs(config);
+    expect((sdkConfigs.explorer as { displayName?: string }).displayName).toBe(
+      'researcher',
+    );
+  });
+
+  test('injects configured displayName into orchestrator prompt mentions', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('normalizes @-prefixed displayName in prompt injection', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: '@researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toContain('@@researcher');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('normalizes whitespace-padded displayName in prompt injection', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: '  researcher  ' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    const prompt = orchestrator?.config.prompt ?? '';
+
+    expect(prompt).toContain('@researcher');
+    expect(prompt).not.toContain('@ researcher ');
+    expect(prompt).not.toMatch(/@explorer\b/);
+  });
+
+  test('throws when duplicate displayName is assigned', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'helper' },
+        librarian: { displayName: 'helper' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "Duplicate displayName 'helper' assigned to multiple agents",
+    );
+  });
+
+  test('throws when normalized duplicate displayName is assigned', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'advisor' },
+        librarian: { displayName: ' @advisor ' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "Duplicate displayName 'advisor' assigned to multiple agents",
+    );
+  });
+
+  test('throws when displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: 'oracle' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "displayName 'oracle' conflicts with internal agent name",
+    );
+  });
+
+  test('throws when normalized displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { displayName: ' @oracle ' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      "displayName 'oracle' conflicts with internal agent name",
+    );
+  });
+
+  test('throws when orchestrator displayName conflicts with internal agent name', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: { displayName: 'oracle' },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow(
+      /displayName.*conflicts with internal agent name/,
+    );
+  });
+
+  test('resolves legacy alias for explorer displayName override', () => {
+    const config: PluginConfig = {
+      agents: {
+        explore: { displayName: 'researcher' },
+      },
+    };
+
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+
+    expect(explorer?.displayName).toBe('researcher');
+  });
+
+  test('uses displayName as host-facing registry key with hidden internal alias', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config) as Record<
+      string,
+      { hidden?: boolean; mode?: string }
+    >;
+
+    expect(sdkConfigs.advisor).toBeDefined();
+    expect(sdkConfigs.advisor.mode).toBe('subagent');
+    expect(sdkConfigs.advisor.hidden).toBeUndefined();
+
+    expect(sdkConfigs.oracle).toBeDefined();
+    expect(sdkConfigs.oracle.mode).toBe('subagent');
+    expect(sdkConfigs.oracle.hidden).toBe(true);
+  });
+
+  test('uses orchestrator displayName as host-facing key with hidden internal alias', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: { displayName: 'engineer' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config) as Record<
+      string,
+      { hidden?: boolean; mode?: string }
+    >;
+
+    expect(sdkConfigs.engineer).toBeDefined();
+    expect(sdkConfigs.engineer.mode).toBe('primary');
+    expect(sdkConfigs.engineer.hidden).toBeUndefined();
+
+    expect(sdkConfigs.orchestrator).toBeDefined();
+    expect(sdkConfigs.orchestrator.mode).toBe('primary');
+    expect(sdkConfigs.orchestrator.hidden).toBe(true);
+  });
+
+  test('keeps internal-only council agents hidden even with displayName configured', () => {
+    const config: PluginConfig = {
+      disabled_agents: [],
+      agents: {
+        councillor: { displayName: 'reviewer' },
+        'council-master': { displayName: 'arbiter' },
+      },
+    };
+
+    const sdkConfigs = getAgentConfigs(config);
+
+    expect(sdkConfigs.reviewer).toBeUndefined();
+    expect(sdkConfigs.arbiter).toBeUndefined();
+    expect(sdkConfigs.councillor?.hidden).toBe(true);
+    expect(sdkConfigs['council-master']?.hidden).toBe(true);
+  });
+});

+ 118 - 25
src/agents/index.ts

@@ -32,6 +32,11 @@ type AgentFactory = (
   customAppendPrompt?: string,
 ) => AgentDefinition;
 
+function normalizeDisplayName(displayName: string): string {
+  const trimmed = displayName.trim();
+  return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
+}
+
 // Agent Configuration Helpers
 
 /**
@@ -63,6 +68,27 @@ function applyOverrides(
       ...override.options,
     };
   }
+  if (override.displayName) {
+    agent.displayName = override.displayName;
+  }
+}
+
+function injectDisplayNames(
+  orchestrator: AgentDefinition,
+  nameMap: Map<string, string>,
+): void {
+  if (nameMap.size === 0) return;
+  let prompt = orchestrator.config.prompt;
+  if (!prompt) return;
+
+  for (const [internalName, displayName] of nameMap) {
+    prompt = prompt.replace(
+      new RegExp(`@${internalName}\\b`, 'g'),
+      `@${normalizeDisplayName(displayName)}`,
+    );
+  }
+
+  orchestrator.config.prompt = prompt;
 }
 
 /**
@@ -207,6 +233,39 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     applyOverrides(orchestrator, orchestratorOverride);
   }
 
+  // Collect all display names from orchestrator and all subagents
+  const displayNameMap = new Map<string, string>();
+  if (orchestrator.displayName) {
+    displayNameMap.set('orchestrator', orchestrator.displayName);
+  }
+  for (const agent of allSubAgents) {
+    if (agent.displayName) {
+      displayNameMap.set(agent.name, agent.displayName);
+    }
+  }
+
+  // Validate display names
+  const usedDisplayNames = new Set<string>();
+  for (const [, displayName] of displayNameMap) {
+    const normalizedDisplayName = normalizeDisplayName(displayName);
+    if (usedDisplayNames.has(normalizedDisplayName)) {
+      throw new Error(
+        `Duplicate displayName '${normalizedDisplayName}' assigned to multiple agents`,
+      );
+    }
+    usedDisplayNames.add(normalizedDisplayName);
+  }
+  for (const displayName of usedDisplayNames) {
+    if ((ALL_AGENT_NAMES as readonly string[]).includes(displayName)) {
+      throw new Error(
+        `displayName '${displayName}' conflicts with internal agent name`,
+      );
+    }
+  }
+
+  // Inject display names into orchestrator prompt (complete map)
+  injectDisplayNames(orchestrator, displayNameMap);
+
   return [orchestrator, ...allSubAgents];
 }
 
@@ -221,32 +280,66 @@ export function getAgentConfigs(
   config?: PluginConfig,
 ): Record<string, SDKAgentConfig> {
   const agents = createAgents(config);
-  return Object.fromEntries(
-    agents.map((a) => {
-      const sdkConfig: SDKAgentConfig & { mcps?: string[] } = {
-        ...a.config,
-        description: a.description,
-        mcps: getAgentMcpList(a.name, config),
-      };
-
-      // Apply classification-based visibility and mode
-      if (a.name === 'council') {
-        // Council is callable both as a primary agent (user-facing)
-        // and as a subagent (orchestrator can delegate to it)
-        sdkConfig.mode = 'all';
-      } else if (a.name === 'councillor' || a.name === 'council-master') {
-        // Internal agents — subagent mode, hidden from @ autocomplete
-        sdkConfig.mode = 'subagent';
-        sdkConfig.hidden = true;
-      } else if (isSubagent(a.name)) {
-        sdkConfig.mode = 'subagent';
-      } else if (a.name === 'orchestrator') {
-        sdkConfig.mode = 'primary';
-      }
 
-      return [a.name, sdkConfig];
-    }),
-  );
+  const applyClassification = (
+    name: string,
+    sdkConfig: SDKAgentConfig & {
+      mcps?: string[];
+      displayName?: string;
+      hidden?: boolean;
+    },
+  ): void => {
+    if (name === 'council') {
+      // Council is callable both as a primary agent (user-facing)
+      // and as a subagent (orchestrator can delegate to it)
+      sdkConfig.mode = 'all';
+    } else if (name === 'councillor' || name === 'council-master') {
+      // Internal agents — subagent mode, hidden from @ autocomplete
+      sdkConfig.mode = 'subagent';
+      sdkConfig.hidden = true;
+    } else if (isSubagent(name)) {
+      sdkConfig.mode = 'subagent';
+    } else if (name === 'orchestrator') {
+      sdkConfig.mode = 'primary';
+    }
+  };
+
+  const isInternalOnly = (name: string): boolean =>
+    name === 'councillor' || name === 'council-master';
+
+  const entries: Array<[string, SDKAgentConfig]> = [];
+
+  for (const a of agents) {
+    const sdkConfig: SDKAgentConfig & {
+      mcps?: string[];
+      displayName?: string;
+      hidden?: boolean;
+    } = {
+      ...a.config,
+      description: a.description,
+      mcps: getAgentMcpList(a.name, config),
+    };
+
+    if (a.displayName) {
+      sdkConfig.displayName = a.displayName;
+    }
+
+    applyClassification(a.name, sdkConfig);
+
+    const normalizedDisplayName = a.displayName
+      ? normalizeDisplayName(a.displayName)
+      : undefined;
+
+    if (normalizedDisplayName && !isInternalOnly(a.name)) {
+      entries.push([normalizedDisplayName, sdkConfig]);
+      entries.push([a.name, { ...sdkConfig, hidden: true }]);
+      continue;
+    }
+
+    entries.push([a.name, sdkConfig]);
+  }
+
+  return Object.fromEntries(entries);
 }
 
 /**

+ 1 - 0
src/agents/orchestrator.ts

@@ -2,6 +2,7 @@ import type { AgentConfig } from '@opencode-ai/sdk/v2';
 
 export interface AgentDefinition {
   name: string;
+  displayName?: string;
   description?: string;
   config: AgentConfig;
   /** Priority-ordered model entries for runtime fallback resolution. */

+ 19 - 0
src/background/background-manager.test.ts

@@ -1,4 +1,5 @@
 import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
 import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
 import { BackgroundTaskManager } from './background-manager';
 
@@ -166,6 +167,24 @@ describe('BackgroundTaskManager', () => {
       expect(['pending', 'starting']).toContain(task2.status);
       expect(['pending', 'starting']).toContain(task3.status);
     });
+
+    test('resolves displayName alias to internal agent name on launch', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx, undefined, {
+        agents: {
+          oracle: { displayName: 'advisor' },
+        },
+      });
+
+      const task = manager.launch({
+        agent: 'advisor',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      expect(task.agent).toBe('oracle');
+    });
   });
 
   describe('handleSessionStatus', () => {

+ 5 - 2
src/background/background-manager.ts

@@ -26,6 +26,7 @@ import {
   applyAgentVariant,
   createInternalAgentTextPart,
   resolveAgentVariant,
+  resolveRuntimeAgentName,
 } from '../utils';
 import { log } from '../utils/logger';
 import {
@@ -185,11 +186,13 @@ export class BackgroundTaskManager {
    * @returns The created background task with pending status
    */
   launch(opts: LaunchOptions): BackgroundTask {
+    const resolvedAgent = resolveRuntimeAgentName(this.config, opts.agent);
+
     const task: BackgroundTask = {
       id: generateTaskId(),
       sessionId: undefined,
       description: opts.description,
-      agent: opts.agent,
+      agent: resolvedAgent,
       status: 'pending',
       startedAt: new Date(),
       config: {
@@ -205,7 +208,7 @@ export class BackgroundTaskManager {
     this.enqueueStart(task);
 
     log(`[background-manager] task launched: ${task.id}`, {
-      agent: opts.agent,
+      agent: resolvedAgent,
       description: opts.description,
     });
 

+ 1 - 0
src/config/schema.ts

@@ -99,6 +99,7 @@ export const AgentOverrideConfigSchema = z.object({
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
   options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
+  displayName: z.string().min(1).optional(),
 });
 
 // Multiplexer type options

+ 26 - 1
src/index.ts

@@ -32,6 +32,7 @@ import {
   lsp_rename,
   setUserLspConfig,
 } from './tools';
+import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
 import { initLogger, log } from './utils/logger';
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
@@ -524,7 +525,19 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       input: { sessionID: string; agent?: string },
       output?: { message?: { agent?: string } },
     ) => {
-      const agent = input.agent ?? output?.message?.agent;
+      const rawAgent = input.agent ?? output?.message?.agent;
+      const agent = rawAgent
+        ? resolveRuntimeAgentName(config, rawAgent)
+        : undefined;
+
+      if (
+        agent &&
+        output?.message &&
+        typeof output.message.agent === 'string'
+      ) {
+        output.message.agent = agent;
+      }
+
       if (agent) {
         sessionAgentMap.set(input.sessionID, agent);
       }
@@ -590,6 +603,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }>;
       };
 
+      for (const message of typedOutput.messages) {
+        if (message.info.role !== 'user') {
+          continue;
+        }
+        for (const part of message.parts) {
+          if (part.type !== 'text' || typeof part.text !== 'string') {
+            continue;
+          }
+          part.text = rewriteDisplayNameMentions(config, part.text);
+        }
+      }
+
       // Strip image parts from orchestrator messages when @observer is available.
       // When the orchestrator's model doesn't support image input, the API call
       // fails before the LLM can respond. We replace image bytes with a text

+ 100 - 0
src/tools/background.test.ts

@@ -0,0 +1,100 @@
+import { describe, expect, mock, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createBackgroundTools } from './background';
+
+function createMockManager() {
+  return {
+    isAgentAllowed: mock(() => true),
+    getAllowedSubagents: mock(() => ['oracle']),
+    launch: mock(
+      (opts: {
+        agent: string;
+        prompt: string;
+        description: string;
+        parentSessionId: string;
+      }) => ({
+        id: 'bg_test1234',
+        sessionId: undefined,
+        description: opts.description,
+        agent: opts.agent,
+        status: 'pending',
+        startedAt: new Date(),
+        config: { maxConcurrentStarts: 10 },
+        parentSessionId: opts.parentSessionId,
+        prompt: opts.prompt,
+      }),
+    ),
+    getResult: mock(() => null),
+    waitForCompletion: mock(async () => null),
+    cancel: mock(() => 0),
+  };
+}
+
+describe('createBackgroundTools displayName runtime aliasing', () => {
+  test('resolves displayName alias for background_task direct invocation', async () => {
+    const manager = createMockManager();
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const tools = createBackgroundTools(
+      {} as any,
+      manager as any,
+      undefined,
+      config,
+    );
+
+    const result = await tools.background_task.execute(
+      {
+        agent: 'advisor',
+        prompt: 'Analyze this architecture',
+        description: 'Architecture analysis',
+      },
+      { sessionID: 'session-1' } as any,
+    );
+
+    expect(manager.isAgentAllowed).toHaveBeenCalledWith('session-1', 'oracle');
+    expect(manager.launch).toHaveBeenCalledWith({
+      agent: 'oracle',
+      prompt: 'Analyze this architecture',
+      description: 'Architecture analysis',
+      parentSessionId: 'session-1',
+    });
+    expect(result).toContain('Agent: oracle');
+  });
+
+  test('keeps internal agent names working for background_task', async () => {
+    const manager = createMockManager();
+    const config: PluginConfig = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    };
+
+    const tools = createBackgroundTools(
+      {} as any,
+      manager as any,
+      undefined,
+      config,
+    );
+
+    await tools.background_task.execute(
+      {
+        agent: 'oracle',
+        prompt: 'Analyze this architecture',
+        description: 'Architecture analysis',
+      },
+      { sessionID: 'session-1' } as any,
+    );
+
+    expect(manager.isAgentAllowed).toHaveBeenCalledWith('session-1', 'oracle');
+    expect(manager.launch).toHaveBeenCalledWith({
+      agent: 'oracle',
+      prompt: 'Analyze this architecture',
+      description: 'Architecture analysis',
+      parentSessionId: 'session-1',
+    });
+  });
+});

+ 2 - 1
src/tools/background.ts

@@ -8,6 +8,7 @@ import type { BackgroundTaskManager } from '../background';
 import type { PluginConfig } from '../config';
 import { SUBAGENT_NAMES } from '../config';
 import type { MultiplexerConfig } from '../config/schema';
+import { resolveRuntimeAgentName } from '../utils';
 
 const z = tool.schema;
 
@@ -55,7 +56,7 @@ Key behaviors:
         throw new Error('Invalid toolContext: missing sessionID');
       }
 
-      const agent = String(args.agent);
+      const agent = resolveRuntimeAgentName(_pluginConfig, String(args.agent));
       const prompt = String(args.prompt);
       const description = String(args.description);
       const parentSessionId = (toolContext as { sessionID: string }).sessionID;

+ 104 - 0
src/utils/agent-variant.test.ts

@@ -4,6 +4,8 @@ import {
   applyAgentVariant,
   normalizeAgentName,
   resolveAgentVariant,
+  resolveRuntimeAgentName,
+  rewriteDisplayNameMentions,
 } from './agent-variant';
 
 describe('normalizeAgentName', () => {
@@ -100,6 +102,108 @@ describe('resolveAgentVariant', () => {
     } as PluginConfig;
     expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
+
+  test('resolves displayName alias to internal agent for variant lookup', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor', variant: 'high' },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, '@advisor')).toBe('high');
+  });
+});
+
+describe('resolveRuntimeAgentName', () => {
+  test('keeps internal agent names unchanged', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'oracle')).toBe('oracle');
+  });
+
+  test('resolves displayName to internal name', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'advisor')).toBe('oracle');
+  });
+
+  test('resolves displayName with @ prefix and whitespace', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, '  @advisor  ')).toBe('oracle');
+  });
+
+  test('resolves displayName configured via legacy alias key', () => {
+    const config = {
+      agents: {
+        explore: { displayName: 'researcher' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, 'researcher')).toBe('explorer');
+  });
+
+  test('returns normalized name when no displayName match exists', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, '  @unknown  ')).toBe('unknown');
+  });
+});
+
+describe('rewriteDisplayNameMentions', () => {
+  test('rewrites displayName mentions to internal names for direct invocation', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(rewriteDisplayNameMentions(config, 'ask @advisor about this')).toBe(
+      'ask @oracle about this',
+    );
+  });
+
+  test('keeps internal mentions working while rewriting aliases', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(
+      rewriteDisplayNameMentions(config, 'compare @advisor with @oracle'),
+    ).toBe('compare @oracle with @oracle');
+  });
+
+  test('does not rewrite embedded text such as email addresses', () => {
+    const config = {
+      agents: {
+        oracle: { displayName: 'advisor' },
+      },
+    } as PluginConfig;
+
+    expect(
+      rewriteDisplayNameMentions(
+        config,
+        'email foo@advisor.com and ask @advisor directly',
+      ),
+    ).toBe('email foo@advisor.com and ask @oracle directly');
+  });
 });
 
 describe('applyAgentVariant', () => {

+ 80 - 3
src/utils/agent-variant.ts

@@ -1,4 +1,8 @@
-import type { PluginConfig } from '../config';
+import {
+  ALL_AGENT_NAMES,
+  getAgentOverride,
+  type PluginConfig,
+} from '../config';
 import { log } from './logger';
 
 /**
@@ -36,8 +40,8 @@ export function resolveAgentVariant(
   config: PluginConfig | undefined,
   agentName: string,
 ): string | undefined {
-  const normalized = normalizeAgentName(agentName);
-  const rawVariant = config?.agents?.[normalized]?.variant;
+  const normalized = resolveRuntimeAgentName(config, agentName);
+  const rawVariant = getAgentOverride(config, normalized)?.variant;
 
   if (typeof rawVariant !== 'string') {
     return undefined;
@@ -52,6 +56,79 @@ export function resolveAgentVariant(
   return trimmed;
 }
 
+/**
+ * Resolve a runtime-provided agent name to an internal agent name.
+ *
+ * Supports:
+ * - internal names (e.g. "oracle")
+ * - @-prefixed names (e.g. "@oracle")
+ * - displayName aliases (e.g. "advisor" -> "oracle")
+ */
+export function resolveRuntimeAgentName(
+  config: PluginConfig | undefined,
+  agentName: string,
+): string {
+  const normalized = normalizeAgentName(agentName);
+  if (!normalized) {
+    return normalized;
+  }
+
+  if ((ALL_AGENT_NAMES as readonly string[]).includes(normalized)) {
+    return normalized;
+  }
+
+  for (const internalName of ALL_AGENT_NAMES) {
+    const displayName = getAgentOverride(config, internalName)?.displayName;
+    if (!displayName) {
+      continue;
+    }
+
+    if (normalizeAgentName(displayName) === normalized) {
+      return internalName;
+    }
+  }
+
+  return normalized;
+}
+
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Rewrites user-facing display-name mentions (e.g. @advisor) into internal
+ * agent mentions (e.g. @oracle) for runtime routing.
+ */
+export function rewriteDisplayNameMentions(
+  config: PluginConfig | undefined,
+  text: string,
+): string {
+  if (!text.includes('@')) {
+    return text;
+  }
+
+  let rewritten = text;
+
+  for (const internalName of ALL_AGENT_NAMES) {
+    const displayName = getAgentOverride(config, internalName)?.displayName;
+    if (!displayName) {
+      continue;
+    }
+
+    const normalizedDisplayName = normalizeAgentName(displayName);
+    if (!normalizedDisplayName || normalizedDisplayName === internalName) {
+      continue;
+    }
+
+    rewritten = rewritten.replace(
+      new RegExp(`(^|[^\\w.])@${escapeRegExp(normalizedDisplayName)}\\b`, 'g'),
+      `$1@${internalName}`,
+    );
+  }
+
+  return rewritten;
+}
+
 /**
  * Applies a variant to a request body if the body doesn't already have one.
  *