diff --git a/backend/__tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js b/backend/__tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js index b4876a77b8..30d86636b9 100644 --- a/backend/__tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js +++ b/backend/__tests__/unit/services/agentIdentityService.resolveAgentDisplayLabel.test.js @@ -81,6 +81,45 @@ describe('resolveAgentDisplayLabel', () => { expect(resolveAgentDisplayLabel(user)).toBe('aria'); }); + // TASK-551: Scout is a product agent, not a shared runtime. Its curated + // label equals its agentName on a per-user instance, and that label must + // show, not the raw instance id. + it('keeps a product agent\'s curated label that equals its agentName (Scout)', () => { + const user = { + username: 'scout-ubd1004e0d7', + botMetadata: { displayName: 'Scout', agentName: 'scout', instanceId: 'ubd1004e0d7' }, + }; + expect(resolveAgentDisplayLabel(user)).toBe('Scout'); + }); + + // The shared-runtime set is wider than openclaw: a codex seat that takes + // its officialDisplayName ('Codex') must still fall back to its instance. + it('still falls back to instanceId when a shared codex runtime name leaks as displayName', () => { + const user = { + username: 'codex-cody', + botMetadata: { displayName: 'Codex', agentName: 'codex', instanceId: 'cody' }, + }; + expect(resolveAgentDisplayLabel(user)).toBe('cody'); + }); + + // claude-code is a shared runtime too. Its runtime name and its + // officialDisplayName 'Claude Code' both fall back to the instanceId. + it('falls back to instanceId when a shared claude-code runtime name leaks as displayName', () => { + const user = { + username: 'claude-code-ops', + botMetadata: { displayName: 'claude-code', agentName: 'claude-code', instanceId: 'ops' }, + }; + expect(resolveAgentDisplayLabel(user)).toBe('ops'); + }); + + it('falls back to instanceId when the claude-code officialDisplayName leaks as displayName', () => { + const user = { + username: 'claude-code-ops', + botMetadata: { displayName: 'Claude Code', agentName: 'claude-code', instanceId: 'ops' }, + }; + expect(resolveAgentDisplayLabel(user)).toBe('ops'); + }); + it('is case-insensitive — "OpenClaw (Pixel)" still rejected', () => { const user = { username: 'openclaw-pixel', diff --git a/backend/utils/agentDisplayLabel.ts b/backend/utils/agentDisplayLabel.ts index d52928a27f..7fc1a9be9f 100644 --- a/backend/utils/agentDisplayLabel.ts +++ b/backend/utils/agentDisplayLabel.ts @@ -1,3 +1,19 @@ +// agentNames that name a shared runtime, not an agent. Only these can leak +// their runtime name as a displayName. A product agent that happens to share +// its name with its curated label keeps that label: 'Scout' on agentName 'scout' +// (TASK-551). commonly-bot and commonly-summarizer are product agents too, so +// their displayName shows even when it equals their agentName. A codex seat +// created without a displayName takes the officialDisplayName 'Codex' +// (agentIdentityService.ts), and a claude-code seat takes 'Claude Code', so both +// must stay here. +const SHARED_RUNTIME_AGENT_NAMES = new Set(['openclaw', 'codex', 'claude-code']); + +// Equal names ignoring case, spaces and hyphens, so the officialDisplayName +// 'Claude Code' is the same leak as the agentName 'claude-code'. +const sameName = (a: string, b: string): boolean => ( + a.toLowerCase().replace(/[\s-]/g, '') === b.toLowerCase().replace(/[\s-]/g, '') +); + // Resolve the human-readable label for an agent User row. The fallback // chain is intentional and load-bearing for any UI / pod-naming surface // that shows agent identity (agent-dm pod names, sidebar member rows): @@ -44,7 +60,9 @@ export function resolveAgentDisplayLabel( !!display && !!agentName && ( - (!!instanceId && instanceId !== 'default' && display.toLowerCase() === agentName.toLowerCase()) + (!!instanceId && instanceId !== 'default' + && SHARED_RUNTIME_AGENT_NAMES.has(agentName.toLowerCase()) + && sameName(display, agentName)) || ( !!instanceId && display.toLowerCase() === `${agentName.toLowerCase()} (${instanceId.toLowerCase()})`